feat: add Gitea login and repository list
This commit is contained in:
parent
fd052654cf
commit
a4451e9d16
@ -54,12 +54,18 @@ kotlin {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
implementation(libs.okhttp)
|
||||||
|
implementation(libs.gson)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,30 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.UBGitea">
|
android:theme="@style/Theme.UBGitea">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data
|
||||||
|
android:scheme="https"
|
||||||
|
android:host="gitea.ubisampaju.synology.me"
|
||||||
|
android:path="/ubisampaju/oauth/callback" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@ -1,20 +1,67 @@
|
|||||||
package com.ubisam.ubgitea
|
package com.ubisam.ubgitea
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import com.ubisam.ubgitea.ui.home.HomeScreen
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import com.ubisam.ubgitea.feature.repositories.RepositoryListScreen
|
||||||
|
import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel
|
||||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val viewModel: RepositoryListViewModel by viewModels {
|
||||||
|
RepositoryListViewModel.Factory(application)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
UBGiteaTheme {
|
UBGiteaTheme {
|
||||||
HomeScreen()
|
RepositoryListScreen(
|
||||||
|
state = state,
|
||||||
|
onSignIn = viewModel::signIn,
|
||||||
|
onCancelSignIn = viewModel::cancelSignIn,
|
||||||
|
onRetry = viewModel::retry,
|
||||||
|
onLogout = viewModel::logout,
|
||||||
|
)
|
||||||
|
LaunchedEffect(viewModel) {
|
||||||
|
viewModel.browserRequests.collect(::openSystemBrowser)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
consumeCallback(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
consumeCallback(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openSystemBrowser(uri: String) {
|
||||||
|
try {
|
||||||
|
startActivity(
|
||||||
|
Intent(Intent.ACTION_VIEW, Uri.parse(uri)).apply {
|
||||||
|
addCategory(Intent.CATEGORY_BROWSABLE)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
viewModel.browserLaunchFailed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun consumeCallback(intent: Intent?) {
|
||||||
|
val callback = intent?.dataString ?: return
|
||||||
|
setIntent(Intent(intent).setData(null))
|
||||||
|
viewModel.handleCallback(callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,99 @@
|
|||||||
|
package com.ubisam.ubgitea.core.auth.android
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import com.ubisam.ubgitea.data.auth.TokenCodec
|
||||||
|
import com.ubisam.ubgitea.data.auth.TokenSet
|
||||||
|
import com.ubisam.ubgitea.data.auth.TokenVault
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.util.Base64
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class AndroidKeystoreTokenVault(
|
||||||
|
context: Context,
|
||||||
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
private val codec: TokenCodec = TokenCodec(),
|
||||||
|
) : TokenVault {
|
||||||
|
private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
override suspend fun store(tokens: TokenSet) = withContext(ioDispatcher) {
|
||||||
|
mutex.withLock {
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
||||||
|
val ciphertext = cipher.doFinal(codec.encode(tokens))
|
||||||
|
val committed = preferences.edit()
|
||||||
|
.putString(KEY_IV, Base64.getEncoder().encodeToString(cipher.iv))
|
||||||
|
.putString(KEY_CIPHERTEXT, Base64.getEncoder().encodeToString(ciphertext))
|
||||||
|
.commit()
|
||||||
|
check(committed) { "Unable to persist encrypted authentication" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun read(): TokenSet? = withContext(ioDispatcher) {
|
||||||
|
mutex.withLock {
|
||||||
|
val iv = preferences.getString(KEY_IV, null) ?: return@withLock null
|
||||||
|
val ciphertext = preferences.getString(KEY_CIPHERTEXT, null) ?: return@withLock null
|
||||||
|
try {
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(
|
||||||
|
Cipher.DECRYPT_MODE,
|
||||||
|
getExistingKey() ?: return@withLock null,
|
||||||
|
GCMParameterSpec(GCM_TAG_BITS, Base64.getDecoder().decode(iv)),
|
||||||
|
)
|
||||||
|
codec.decode(cipher.doFinal(Base64.getDecoder().decode(ciphertext)))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
check(preferences.edit().clear().commit()) { "Unable to clear invalid authentication" }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clear() = withContext(ioDispatcher) {
|
||||||
|
mutex.withLock {
|
||||||
|
check(preferences.edit().clear().commit()) { "Unable to clear authentication" }
|
||||||
|
val keyStore = loadKeyStore()
|
||||||
|
if (keyStore.containsAlias(KEY_ALIAS)) keyStore.deleteEntry(KEY_ALIAS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOrCreateKey(): SecretKey = getExistingKey() ?: KeyGenerator
|
||||||
|
.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE)
|
||||||
|
.apply {
|
||||||
|
init(
|
||||||
|
KeyGenParameterSpec.Builder(
|
||||||
|
KEY_ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setRandomizedEncryptionRequired(true)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.generateKey()
|
||||||
|
|
||||||
|
private fun getExistingKey(): SecretKey? =
|
||||||
|
loadKeyStore().getKey(KEY_ALIAS, null) as? SecretKey
|
||||||
|
|
||||||
|
private fun loadKeyStore(): KeyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
||||||
|
const val KEY_ALIAS = "ubgitea.oauth.tokens"
|
||||||
|
const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
const val GCM_TAG_BITS = 128
|
||||||
|
const val PREFERENCES_NAME = "ubgitea_secure_auth"
|
||||||
|
const val KEY_IV = "iv"
|
||||||
|
const val KEY_CIPHERTEXT = "ciphertext"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
package com.ubisam.ubgitea.core.auth.android
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.ubisam.ubgitea.core.auth.protocol.OAuthTransaction
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
interface PendingAuthTransactionStore {
|
||||||
|
suspend fun save(transaction: OAuthTransaction)
|
||||||
|
suspend fun consume(): OAuthTransaction?
|
||||||
|
suspend fun clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
class PendingAuthStore(
|
||||||
|
context: Context,
|
||||||
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
) : PendingAuthTransactionStore {
|
||||||
|
private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
override suspend fun save(transaction: OAuthTransaction) = withContext(ioDispatcher) {
|
||||||
|
mutex.withLock {
|
||||||
|
val committed = preferences.edit()
|
||||||
|
.putString(KEY_VERIFIER, transaction.verifier)
|
||||||
|
.putString(KEY_STATE, transaction.state)
|
||||||
|
.putLong(KEY_CREATED_AT, transaction.createdAtEpochMillis)
|
||||||
|
.commit()
|
||||||
|
check(committed) { "Unable to persist pending authentication" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun consume(): OAuthTransaction? = withContext(ioDispatcher) {
|
||||||
|
mutex.withLock {
|
||||||
|
val verifier = preferences.getString(KEY_VERIFIER, null)
|
||||||
|
val state = preferences.getString(KEY_STATE, null)
|
||||||
|
val hasCreatedAt = preferences.contains(KEY_CREATED_AT)
|
||||||
|
val createdAt = preferences.getLong(KEY_CREATED_AT, 0L)
|
||||||
|
check(preferences.edit().clear().commit()) { "Unable to clear pending authentication" }
|
||||||
|
if (verifier == null || state == null || !hasCreatedAt) null
|
||||||
|
else OAuthTransaction(verifier, state, createdAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clear() = withContext(ioDispatcher) {
|
||||||
|
mutex.withLock {
|
||||||
|
check(preferences.edit().clear().commit()) { "Unable to clear pending authentication" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PREFERENCES_NAME = "ubgitea_pending_auth"
|
||||||
|
const val KEY_VERIFIER = "verifier"
|
||||||
|
const val KEY_STATE = "state"
|
||||||
|
const val KEY_CREATED_AT = "created_at"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
package com.ubisam.ubgitea.core.network
|
||||||
|
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.Callback
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
|
data class HttpResponse(
|
||||||
|
val statusCode: Int,
|
||||||
|
val body: String,
|
||||||
|
val headers: Map<String, String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface HttpTransport {
|
||||||
|
suspend fun postForm(url: String, fields: Map<String, String>): HttpResponse
|
||||||
|
suspend fun get(url: String, bearerToken: String): HttpResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
class OkHttpTransport(
|
||||||
|
private val client: OkHttpClient = OkHttpClient(),
|
||||||
|
private val responseReader: (Response) -> HttpResponse = ::readHttpResponse,
|
||||||
|
) : HttpTransport {
|
||||||
|
override suspend fun postForm(url: String, fields: Map<String, String>): HttpResponse {
|
||||||
|
val body = FormBody.Builder().apply {
|
||||||
|
fields.forEach { (name, value) -> add(name, value) }
|
||||||
|
}.build()
|
||||||
|
return execute(Request.Builder().url(url).post(body).build())
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun get(url: String, bearerToken: String): HttpResponse {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.header("Authorization", "Bearer $bearerToken")
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
return execute(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun execute(request: Request): HttpResponse =
|
||||||
|
suspendCancellableCoroutine { continuation ->
|
||||||
|
val call = client.newCall(request)
|
||||||
|
continuation.invokeOnCancellation { call.cancel() }
|
||||||
|
call.enqueue(
|
||||||
|
object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
if (continuation.isActive) continuation.resumeWithException(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
try {
|
||||||
|
val converted = response.use(responseReader)
|
||||||
|
if (continuation.isActive) continuation.resume(converted)
|
||||||
|
} catch (failure: IOException) {
|
||||||
|
if (continuation.isActive) continuation.resumeWithException(failure)
|
||||||
|
} catch (failure: RuntimeException) {
|
||||||
|
if (continuation.isActive) {
|
||||||
|
continuation.resumeWithException(
|
||||||
|
IOException("Unable to read HTTP response", failure),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readHttpResponse(response: Response): HttpResponse = HttpResponse(
|
||||||
|
statusCode = response.code,
|
||||||
|
body = response.body.string(),
|
||||||
|
headers = response.headers.names().associateWith { name -> response.header(name).orEmpty() },
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class ApiFailure(message: String) : Exception(message) {
|
||||||
|
class AuthenticationExpired : ApiFailure("Authentication expired")
|
||||||
|
class PermissionDenied : ApiFailure("Permission denied")
|
||||||
|
class InvalidResponse : ApiFailure("Invalid server response")
|
||||||
|
class Server : ApiFailure("Server error")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun HttpResponse.requireSuccess(): HttpResponse = when (statusCode) {
|
||||||
|
in 200..299 -> this
|
||||||
|
401 -> throw ApiFailure.AuthenticationExpired()
|
||||||
|
403 -> throw ApiFailure.PermissionDenied()
|
||||||
|
in 500..599 -> throw ApiFailure.Server()
|
||||||
|
else -> throw ApiFailure.InvalidResponse()
|
||||||
|
}
|
||||||
93
app/src/main/java/com/ubisam/ubgitea/data/auth/AuthApi.kt
Normal file
93
app/src/main/java/com/ubisam/ubgitea/data/auth/AuthApi.kt
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
package com.ubisam.ubgitea.data.auth
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.JsonParseException
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
import com.ubisam.ubgitea.core.auth.protocol.OAuthClientConfig
|
||||||
|
import com.ubisam.ubgitea.core.network.ApiFailure
|
||||||
|
import com.ubisam.ubgitea.core.network.HttpTransport
|
||||||
|
import com.ubisam.ubgitea.core.network.requireSuccess
|
||||||
|
|
||||||
|
data class TokenSet(
|
||||||
|
val accessToken: String,
|
||||||
|
val refreshToken: String?,
|
||||||
|
val expiresAtEpochMillis: Long?,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(accessToken.isNotBlank()) { "Access token cannot be blank" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = "TokenSet(REDACTED)"
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AuthenticatedUser(
|
||||||
|
val id: Long,
|
||||||
|
val login: String,
|
||||||
|
val displayName: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
class AuthApi(
|
||||||
|
private val config: OAuthClientConfig,
|
||||||
|
private val transport: HttpTransport,
|
||||||
|
private val gson: Gson = Gson(),
|
||||||
|
) {
|
||||||
|
suspend fun exchangeCode(
|
||||||
|
authorizationCode: String,
|
||||||
|
verifier: String,
|
||||||
|
nowEpochMillis: Long,
|
||||||
|
): TokenSet {
|
||||||
|
require(authorizationCode.isNotBlank()) { "Authorization code cannot be blank" }
|
||||||
|
val response = transport.postForm(
|
||||||
|
url = config.baseUri.resolve("/login/oauth/access_token").toString(),
|
||||||
|
fields = mapOf(
|
||||||
|
"grant_type" to "authorization_code",
|
||||||
|
"client_id" to config.clientId,
|
||||||
|
"redirect_uri" to config.redirectUri.toString(),
|
||||||
|
"code" to authorizationCode,
|
||||||
|
"code_verifier" to verifier,
|
||||||
|
),
|
||||||
|
).requireSuccess()
|
||||||
|
val dto = parse<TokenResponseDto>(response.body)
|
||||||
|
val expiresAt = dto.expiresIn
|
||||||
|
?.takeIf { it > 0 }
|
||||||
|
?.let { seconds -> nowEpochMillis.saturatingAdd(seconds.saturatingMilliseconds()) }
|
||||||
|
val accessToken = dto.accessToken?.takeIf(String::isNotBlank)
|
||||||
|
?: throw ApiFailure.InvalidResponse()
|
||||||
|
return TokenSet(accessToken, dto.refreshToken?.takeIf(String::isNotBlank), expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getAuthenticatedUser(accessToken: String): AuthenticatedUser {
|
||||||
|
val response = transport.get(
|
||||||
|
config.baseUri.resolve("/api/v1/user").toString(),
|
||||||
|
accessToken,
|
||||||
|
).requireSuccess()
|
||||||
|
val dto = parse<UserDto>(response.body)
|
||||||
|
val login = dto.login?.takeIf(String::isNotBlank) ?: throw ApiFailure.InvalidResponse()
|
||||||
|
if (dto.id <= 0) throw ApiFailure.InvalidResponse()
|
||||||
|
return AuthenticatedUser(dto.id, login, dto.fullName?.takeIf(String::isNotBlank))
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> parse(body: String): T = try {
|
||||||
|
gson.fromJson(body, T::class.java) ?: throw ApiFailure.InvalidResponse()
|
||||||
|
} catch (_: JsonParseException) {
|
||||||
|
throw ApiFailure.InvalidResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Long.saturatingAdd(value: Long): Long =
|
||||||
|
if (value > 0 && this > Long.MAX_VALUE - value) Long.MAX_VALUE else this + value
|
||||||
|
|
||||||
|
private fun Long.saturatingMilliseconds(): Long =
|
||||||
|
if (this > Long.MAX_VALUE / 1_000L) Long.MAX_VALUE else this * 1_000L
|
||||||
|
|
||||||
|
private data class TokenResponseDto(
|
||||||
|
@SerializedName("access_token") val accessToken: String?,
|
||||||
|
@SerializedName("refresh_token") val refreshToken: String?,
|
||||||
|
@SerializedName("expires_in") val expiresIn: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class UserDto(
|
||||||
|
val id: Long,
|
||||||
|
val login: String?,
|
||||||
|
@SerializedName("full_name") val fullName: String?,
|
||||||
|
)
|
||||||
|
}
|
||||||
28
app/src/main/java/com/ubisam/ubgitea/data/auth/TokenVault.kt
Normal file
28
app/src/main/java/com/ubisam/ubgitea/data/auth/TokenVault.kt
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package com.ubisam.ubgitea.data.auth
|
||||||
|
|
||||||
|
interface TokenVault {
|
||||||
|
suspend fun store(tokens: TokenSet)
|
||||||
|
suspend fun read(): TokenSet?
|
||||||
|
suspend fun clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
class TokenCodec {
|
||||||
|
fun encode(tokens: TokenSet): ByteArray = java.io.ByteArrayOutputStream().use { output ->
|
||||||
|
java.io.DataOutputStream(output).use { data ->
|
||||||
|
data.writeUTF(tokens.accessToken)
|
||||||
|
data.writeBoolean(tokens.refreshToken != null)
|
||||||
|
tokens.refreshToken?.let(data::writeUTF)
|
||||||
|
data.writeBoolean(tokens.expiresAtEpochMillis != null)
|
||||||
|
tokens.expiresAtEpochMillis?.let(data::writeLong)
|
||||||
|
}
|
||||||
|
output.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decode(bytes: ByteArray): TokenSet = java.io.DataInputStream(bytes.inputStream()).use { data ->
|
||||||
|
val accessToken = data.readUTF()
|
||||||
|
val refreshToken = if (data.readBoolean()) data.readUTF() else null
|
||||||
|
val expiresAt = if (data.readBoolean()) data.readLong() else null
|
||||||
|
if (data.available() != 0) throw IllegalArgumentException("Unexpected token payload")
|
||||||
|
TokenSet(accessToken, refreshToken, expiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
package com.ubisam.ubgitea.data.repositories
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.JsonParseException
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import com.ubisam.ubgitea.core.auth.protocol.OAuthClientConfig
|
||||||
|
import com.ubisam.ubgitea.core.network.ApiFailure
|
||||||
|
import com.ubisam.ubgitea.core.network.HttpTransport
|
||||||
|
import com.ubisam.ubgitea.core.network.requireSuccess
|
||||||
|
|
||||||
|
data class AccessibleRepository(
|
||||||
|
val id: Long,
|
||||||
|
val owner: String,
|
||||||
|
val name: String,
|
||||||
|
val description: String?,
|
||||||
|
val isPrivate: Boolean,
|
||||||
|
val defaultBranch: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
class RepositoryListApi(
|
||||||
|
private val config: OAuthClientConfig,
|
||||||
|
private val transport: HttpTransport,
|
||||||
|
private val gson: Gson = Gson(),
|
||||||
|
private val pageSize: Int = 50,
|
||||||
|
private val maxPages: Int = 100,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(pageSize in 1..100) { "Page size must be between 1 and 100" }
|
||||||
|
require(maxPages > 0) { "Max pages must be positive" }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun listAll(accessToken: String): List<AccessibleRepository> {
|
||||||
|
val repositories = linkedMapOf<Long, AccessibleRepository>()
|
||||||
|
for (page in 1..maxPages) {
|
||||||
|
val url = config.baseUri.resolve("/api/v1/user/repos?page=$page&limit=$pageSize").toString()
|
||||||
|
val response = transport.get(url, accessToken).requireSuccess()
|
||||||
|
val items = parse(response.body).map(::map)
|
||||||
|
items.forEach { repositories[it.id] = it }
|
||||||
|
|
||||||
|
val hasMore = response.headers.entries
|
||||||
|
.firstOrNull { it.key.equals("X-HasMore", ignoreCase = true) }
|
||||||
|
?.value
|
||||||
|
?.equals("true", ignoreCase = true)
|
||||||
|
if (hasMore == false || (hasMore == null && items.size < pageSize)) {
|
||||||
|
return repositories.values.toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw ApiFailure.InvalidResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parse(body: String): List<RepositoryDto> = try {
|
||||||
|
val type = object : TypeToken<List<RepositoryDto>>() {}.type
|
||||||
|
gson.fromJson<List<RepositoryDto>>(body, type) ?: throw ApiFailure.InvalidResponse()
|
||||||
|
} catch (_: JsonParseException) {
|
||||||
|
throw ApiFailure.InvalidResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun map(dto: RepositoryDto): AccessibleRepository {
|
||||||
|
val owner = dto.owner?.login?.takeIf(String::isNotBlank) ?: throw ApiFailure.InvalidResponse()
|
||||||
|
val name = dto.name?.takeIf(String::isNotBlank) ?: throw ApiFailure.InvalidResponse()
|
||||||
|
if (dto.id <= 0) throw ApiFailure.InvalidResponse()
|
||||||
|
return AccessibleRepository(
|
||||||
|
id = dto.id,
|
||||||
|
owner = owner,
|
||||||
|
name = name,
|
||||||
|
description = dto.description?.takeIf(String::isNotBlank),
|
||||||
|
isPrivate = dto.isPrivate,
|
||||||
|
defaultBranch = dto.defaultBranch?.takeIf(String::isNotBlank),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class RepositoryDto(
|
||||||
|
val id: Long,
|
||||||
|
val owner: OwnerDto?,
|
||||||
|
val name: String?,
|
||||||
|
val description: String?,
|
||||||
|
@SerializedName("private") val isPrivate: Boolean,
|
||||||
|
@SerializedName("default_branch") val defaultBranch: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class OwnerDto(val login: String?)
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
package com.ubisam.ubgitea.feature.repositories
|
||||||
|
|
||||||
|
import com.ubisam.ubgitea.core.auth.android.PendingAuthTransactionStore
|
||||||
|
import com.ubisam.ubgitea.core.auth.protocol.OAuthCallbackResult
|
||||||
|
import com.ubisam.ubgitea.core.auth.protocol.OAuthCallbackValidator
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.URISyntaxException
|
||||||
|
|
||||||
|
sealed interface HandledOAuthCallback {
|
||||||
|
data class Success(val authorizationCode: String, val verifier: String) : HandledOAuthCallback
|
||||||
|
data object Rejected : HandledOAuthCallback
|
||||||
|
}
|
||||||
|
|
||||||
|
class OAuthCallbackHandler(
|
||||||
|
private val pendingStore: PendingAuthTransactionStore,
|
||||||
|
private val validator: OAuthCallbackValidator,
|
||||||
|
private val now: () -> Long,
|
||||||
|
) {
|
||||||
|
suspend fun handle(rawUri: String): HandledOAuthCallback {
|
||||||
|
val transaction = pendingStore.consume()
|
||||||
|
?: return HandledOAuthCallback.Rejected
|
||||||
|
val uri = try {
|
||||||
|
URI(rawUri)
|
||||||
|
} catch (_: URISyntaxException) {
|
||||||
|
return HandledOAuthCallback.Rejected
|
||||||
|
}
|
||||||
|
return when (val result = validator.validate(uri, transaction, now())) {
|
||||||
|
is OAuthCallbackResult.Success -> HandledOAuthCallback.Success(
|
||||||
|
result.authorizationCode,
|
||||||
|
transaction.verifier,
|
||||||
|
)
|
||||||
|
is OAuthCallbackResult.Denied,
|
||||||
|
is OAuthCallbackResult.Invalid,
|
||||||
|
-> HandledOAuthCallback.Rejected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,164 @@
|
|||||||
|
package com.ubisam.ubgitea.feature.repositories
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.ubisam.ubgitea.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RepositoryListScreen(
|
||||||
|
state: RepositoryListUiState,
|
||||||
|
onSignIn: () -> Unit,
|
||||||
|
onCancelSignIn: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
|
||||||
|
when (state) {
|
||||||
|
RepositoryListUiState.SignedOut -> MessageState(
|
||||||
|
stringResource(R.string.repositories_sign_in_description),
|
||||||
|
stringResource(R.string.repositories_sign_in),
|
||||||
|
onSignIn,
|
||||||
|
Modifier.padding(padding),
|
||||||
|
)
|
||||||
|
RepositoryListUiState.WaitingForBrowser -> MessageState(
|
||||||
|
stringResource(R.string.repositories_waiting_browser),
|
||||||
|
stringResource(R.string.repositories_cancel_login),
|
||||||
|
onCancelSignIn,
|
||||||
|
Modifier.padding(padding),
|
||||||
|
)
|
||||||
|
RepositoryListUiState.Loading -> LoadingState(
|
||||||
|
stringResource(R.string.repositories_loading),
|
||||||
|
Modifier.padding(padding),
|
||||||
|
)
|
||||||
|
is RepositoryListUiState.Empty -> MessageState(
|
||||||
|
stringResource(R.string.repositories_empty_account, state.accountName),
|
||||||
|
stringResource(R.string.repositories_logout),
|
||||||
|
onLogout,
|
||||||
|
Modifier.padding(padding),
|
||||||
|
)
|
||||||
|
is RepositoryListUiState.Error -> {
|
||||||
|
val needsLogin = state.type == RepositoryListError.AUTHENTICATION_EXPIRED ||
|
||||||
|
state.type == RepositoryListError.INVALID_CALLBACK
|
||||||
|
MessageState(
|
||||||
|
state.accountName?.let {
|
||||||
|
stringResource(R.string.repositories_error_account, it, stringResource(state.type.messageResource))
|
||||||
|
} ?: stringResource(state.type.messageResource),
|
||||||
|
stringResource(if (needsLogin) R.string.repositories_sign_in else R.string.repositories_retry),
|
||||||
|
if (needsLogin) onSignIn else onRetry,
|
||||||
|
Modifier.padding(padding),
|
||||||
|
secondaryAction = if (state.canLogout) stringResource(R.string.repositories_logout) else null,
|
||||||
|
onSecondaryAction = onLogout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is RepositoryListUiState.Content -> RepositoryContent(
|
||||||
|
state,
|
||||||
|
onLogout,
|
||||||
|
Modifier.padding(padding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoadingState(message: String, modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Text(message, modifier = Modifier.padding(top = 16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageState(
|
||||||
|
message: String,
|
||||||
|
action: String,
|
||||||
|
onAction: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
secondaryAction: String? = null,
|
||||||
|
onSecondaryAction: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier.fillMaxSize().padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(message, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Button(onClick = onAction, modifier = Modifier.padding(top = 20.dp)) { Text(action) }
|
||||||
|
secondaryAction?.let {
|
||||||
|
OutlinedButton(onClick = onSecondaryAction, modifier = Modifier.padding(top = 8.dp)) { Text(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RepositoryContent(
|
||||||
|
state: RepositoryListUiState.Content,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(stringResource(R.string.repositories_title), style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Text(stringResource(R.string.repositories_account, state.accountName))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
OutlinedButton(onClick = onLogout) { Text(stringResource(R.string.repositories_logout)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(state.repositories, key = RepositoryRowUiModel::id) { repository ->
|
||||||
|
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||||
|
Text("${repository.owner}/${repository.name}", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (repository.isPrivate) R.string.repositories_private else R.string.repositories_public,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
repository.description?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
|
||||||
|
Text(
|
||||||
|
repository.defaultBranch?.let {
|
||||||
|
stringResource(R.string.repositories_default_branch, it)
|
||||||
|
} ?: stringResource(R.string.repositories_no_default_branch),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val RepositoryListError.messageResource: Int
|
||||||
|
get() = when (this) {
|
||||||
|
RepositoryListError.AUTHENTICATION_EXPIRED -> R.string.repositories_auth_expired
|
||||||
|
RepositoryListError.PERMISSION_DENIED -> R.string.repositories_permission_denied
|
||||||
|
RepositoryListError.OFFLINE -> R.string.repositories_offline
|
||||||
|
RepositoryListError.SERVER -> R.string.repositories_server_error
|
||||||
|
RepositoryListError.INVALID_CALLBACK -> R.string.repositories_invalid_callback
|
||||||
|
}
|
||||||
@ -0,0 +1,228 @@
|
|||||||
|
package com.ubisam.ubgitea.feature.repositories
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.ubisam.ubgitea.core.auth.android.AndroidKeystoreTokenVault
|
||||||
|
import com.ubisam.ubgitea.core.auth.android.PendingAuthStore
|
||||||
|
import com.ubisam.ubgitea.core.auth.protocol.OAuthAuthorizationRequestFactory
|
||||||
|
import com.ubisam.ubgitea.core.auth.protocol.OAuthCallbackValidator
|
||||||
|
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||||
|
import com.ubisam.ubgitea.core.network.ApiFailure
|
||||||
|
import com.ubisam.ubgitea.core.network.OkHttpTransport
|
||||||
|
import com.ubisam.ubgitea.data.auth.AuthApi
|
||||||
|
import com.ubisam.ubgitea.data.auth.TokenSet
|
||||||
|
import com.ubisam.ubgitea.data.auth.TokenVault
|
||||||
|
import com.ubisam.ubgitea.data.repositories.AccessibleRepository
|
||||||
|
import com.ubisam.ubgitea.data.repositories.RepositoryListApi
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.Collections
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
sealed interface RepositoryListUiState {
|
||||||
|
data object SignedOut : RepositoryListUiState
|
||||||
|
data object WaitingForBrowser : RepositoryListUiState
|
||||||
|
data object Loading : RepositoryListUiState
|
||||||
|
class Content(accountName: String, repositories: List<RepositoryRowUiModel>) : RepositoryListUiState {
|
||||||
|
val accountName: String = accountName
|
||||||
|
val repositories: List<RepositoryRowUiModel> = Collections.unmodifiableList(repositories.toList())
|
||||||
|
}
|
||||||
|
data class Empty(val accountName: String) : RepositoryListUiState
|
||||||
|
data class Error(
|
||||||
|
val type: RepositoryListError,
|
||||||
|
val accountName: String? = null,
|
||||||
|
val canLogout: Boolean = false,
|
||||||
|
) : RepositoryListUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RepositoryRowUiModel(
|
||||||
|
val id: Long,
|
||||||
|
val owner: String,
|
||||||
|
val name: String,
|
||||||
|
val description: String?,
|
||||||
|
val isPrivate: Boolean,
|
||||||
|
val defaultBranch: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class RepositoryListError {
|
||||||
|
AUTHENTICATION_EXPIRED,
|
||||||
|
PERMISSION_DENIED,
|
||||||
|
OFFLINE,
|
||||||
|
SERVER,
|
||||||
|
INVALID_CALLBACK,
|
||||||
|
}
|
||||||
|
|
||||||
|
class RepositoryListViewModel(
|
||||||
|
application: Application,
|
||||||
|
private val tokenVault: TokenVault = AndroidKeystoreTokenVault(application),
|
||||||
|
private val pendingAuthStore: PendingAuthStore = PendingAuthStore(application),
|
||||||
|
private val authApi: AuthApi = AuthApi(UBGiteaOAuthDefaults.config, OkHttpTransport()),
|
||||||
|
private val repositoryApi: RepositoryListApi = RepositoryListApi(UBGiteaOAuthDefaults.config, OkHttpTransport()),
|
||||||
|
private val now: () -> Long = System::currentTimeMillis,
|
||||||
|
) : AndroidViewModel(application) {
|
||||||
|
private val requestFactory = OAuthAuthorizationRequestFactory(UBGiteaOAuthDefaults.config)
|
||||||
|
private val callbackValidator = OAuthCallbackValidator(UBGiteaOAuthDefaults.config)
|
||||||
|
private val callbackHandler = OAuthCallbackHandler(pendingAuthStore, callbackValidator, now)
|
||||||
|
private val mutableState = MutableStateFlow<RepositoryListUiState>(RepositoryListUiState.Loading)
|
||||||
|
val state: StateFlow<RepositoryListUiState> = mutableState.asStateFlow()
|
||||||
|
private val mutableBrowserRequests = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||||
|
val browserRequests: SharedFlow<String> = mutableBrowserRequests.asSharedFlow()
|
||||||
|
private var operation: Job? = null
|
||||||
|
private var activeAccountName: String? = null
|
||||||
|
private var hasAuthenticatedSession = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
startOperation { restoreSession() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun signIn() {
|
||||||
|
startOperation {
|
||||||
|
val request = requestFactory.create(now())
|
||||||
|
pendingAuthStore.save(request.transaction)
|
||||||
|
mutableState.value = RepositoryListUiState.WaitingForBrowser
|
||||||
|
mutableBrowserRequests.emit(request.uri.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleCallback(uri: String) {
|
||||||
|
startOperation {
|
||||||
|
when (val result = callbackHandler.handle(uri)) {
|
||||||
|
is HandledOAuthCallback.Success -> authenticate(result.authorizationCode, result.verifier)
|
||||||
|
HandledOAuthCallback.Rejected -> mutableState.value = RepositoryListUiState.Error(
|
||||||
|
RepositoryListError.INVALID_CALLBACK,
|
||||||
|
activeAccountName,
|
||||||
|
hasAuthenticatedSession,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun retry() = startOperation { restoreSession() }
|
||||||
|
|
||||||
|
fun cancelSignIn() {
|
||||||
|
startOperation {
|
||||||
|
pendingAuthStore.clear()
|
||||||
|
mutableState.value = RepositoryListUiState.SignedOut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun browserLaunchFailed() {
|
||||||
|
startOperation {
|
||||||
|
pendingAuthStore.clear()
|
||||||
|
mutableState.value = RepositoryListUiState.Error(
|
||||||
|
RepositoryListError.INVALID_CALLBACK,
|
||||||
|
activeAccountName,
|
||||||
|
hasAuthenticatedSession,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
startOperation {
|
||||||
|
pendingAuthStore.clear()
|
||||||
|
tokenVault.clear()
|
||||||
|
activeAccountName = null
|
||||||
|
hasAuthenticatedSession = false
|
||||||
|
mutableState.value = RepositoryListUiState.SignedOut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun authenticate(code: String, verifier: String) {
|
||||||
|
mutableState.value = RepositoryListUiState.Loading
|
||||||
|
try {
|
||||||
|
val tokens = authApi.exchangeCode(code, verifier, now())
|
||||||
|
tokenVault.store(tokens)
|
||||||
|
hasAuthenticatedSession = true
|
||||||
|
loadRepositories(tokens)
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
if (failure is CancellationException) throw failure
|
||||||
|
if (failure is ApiFailure.AuthenticationExpired) {
|
||||||
|
tokenVault.clear()
|
||||||
|
activeAccountName = null
|
||||||
|
hasAuthenticatedSession = false
|
||||||
|
}
|
||||||
|
mutableState.value = RepositoryListUiState.Error(
|
||||||
|
failure.toRepositoryListError(),
|
||||||
|
activeAccountName,
|
||||||
|
hasAuthenticatedSession,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun restoreSession() {
|
||||||
|
mutableState.value = RepositoryListUiState.Loading
|
||||||
|
val tokens = tokenVault.read()
|
||||||
|
if (tokens == null) {
|
||||||
|
hasAuthenticatedSession = false
|
||||||
|
mutableState.value = RepositoryListUiState.SignedOut
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasAuthenticatedSession = true
|
||||||
|
if (tokens.expiresAtEpochMillis?.let { it <= now() } == true) {
|
||||||
|
tokenVault.clear()
|
||||||
|
activeAccountName = null
|
||||||
|
hasAuthenticatedSession = false
|
||||||
|
mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
loadRepositories(tokens)
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
if (failure is CancellationException) throw failure
|
||||||
|
if (failure is ApiFailure.AuthenticationExpired) {
|
||||||
|
tokenVault.clear()
|
||||||
|
activeAccountName = null
|
||||||
|
hasAuthenticatedSession = false
|
||||||
|
}
|
||||||
|
mutableState.value = RepositoryListUiState.Error(
|
||||||
|
failure.toRepositoryListError(),
|
||||||
|
activeAccountName,
|
||||||
|
hasAuthenticatedSession,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadRepositories(tokens: TokenSet) {
|
||||||
|
val user = authApi.getAuthenticatedUser(tokens.accessToken)
|
||||||
|
val accountName = user.displayName ?: user.login
|
||||||
|
activeAccountName = accountName
|
||||||
|
val repositories = repositoryApi.listAll(tokens.accessToken)
|
||||||
|
val rows = Collections.unmodifiableList(repositories.map { it.toUiModel() })
|
||||||
|
mutableState.value = if (rows.isEmpty()) RepositoryListUiState.Empty(accountName)
|
||||||
|
else RepositoryListUiState.Content(accountName, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startOperation(block: suspend () -> Unit) {
|
||||||
|
operation?.cancel()
|
||||||
|
operation = viewModelScope.launch { block() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AccessibleRepository.toUiModel() = RepositoryRowUiModel(
|
||||||
|
id, owner, name, description, isPrivate, defaultBranch,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Factory(private val application: Application) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
require(modelClass.isAssignableFrom(RepositoryListViewModel::class.java))
|
||||||
|
return RepositoryListViewModel(application) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Throwable.toRepositoryListError(): RepositoryListError = when (this) {
|
||||||
|
is ApiFailure.AuthenticationExpired -> RepositoryListError.AUTHENTICATION_EXPIRED
|
||||||
|
is ApiFailure.PermissionDenied -> RepositoryListError.PERMISSION_DENIED
|
||||||
|
is IOException -> RepositoryListError.OFFLINE
|
||||||
|
else -> RepositoryListError.SERVER
|
||||||
|
}
|
||||||
23
app/src/main/res/values/strings_repositories.xml
Normal file
23
app/src/main/res/values/strings_repositories.xml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="repositories_title">UBGitea</string>
|
||||||
|
<string name="repositories_sign_in_description">사내 Gitea에 로그인해 접근 가능한 저장소를 확인하세요.</string>
|
||||||
|
<string name="repositories_sign_in">Gitea로 로그인</string>
|
||||||
|
<string name="repositories_waiting_browser">브라우저에서 로그인을 완료해 주세요.</string>
|
||||||
|
<string name="repositories_cancel_login">로그인 취소</string>
|
||||||
|
<string name="repositories_loading">저장소를 불러오는 중입니다.</string>
|
||||||
|
<string name="repositories_empty">접근 가능한 저장소가 없습니다.</string>
|
||||||
|
<string name="repositories_empty_account">%1$s 계정에서 접근 가능한 저장소가 없습니다.</string>
|
||||||
|
<string name="repositories_error_account">%1$s 계정\n%2$s</string>
|
||||||
|
<string name="repositories_auth_expired">로그인이 만료되었습니다. 다시 로그인해 주세요.</string>
|
||||||
|
<string name="repositories_permission_denied">이 계정에는 저장소를 볼 권한이 없습니다.</string>
|
||||||
|
<string name="repositories_offline">서버에 연결할 수 없습니다. 네트워크를 확인해 주세요.</string>
|
||||||
|
<string name="repositories_server_error">Gitea 서버에서 오류가 발생했습니다.</string>
|
||||||
|
<string name="repositories_invalid_callback">로그인 응답을 확인할 수 없습니다. 다시 시도해 주세요.</string>
|
||||||
|
<string name="repositories_retry">다시 시도</string>
|
||||||
|
<string name="repositories_logout">로그아웃</string>
|
||||||
|
<string name="repositories_private">비공개</string>
|
||||||
|
<string name="repositories_public">공개</string>
|
||||||
|
<string name="repositories_default_branch">기본 브랜치: %1$s</string>
|
||||||
|
<string name="repositories_no_default_branch">기본 브랜치 없음</string>
|
||||||
|
<string name="repositories_account">%1$s 계정</string>
|
||||||
|
</resources>
|
||||||
9
app/src/main/res/xml/data_extraction_rules.xml
Normal file
9
app/src/main/res/xml/data_extraction_rules.xml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup disableIfNoEncryptionCapabilities="true">
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package com.ubisam.ubgitea.core.network
|
||||||
|
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import org.junit.Assert.fail
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class OkHttpTransportTest {
|
||||||
|
@Test
|
||||||
|
fun `response reader IOException completes request exceptionally`() = runTest {
|
||||||
|
val client = OkHttpClient.Builder().addInterceptor { chain ->
|
||||||
|
Response.Builder()
|
||||||
|
.request(chain.request())
|
||||||
|
.protocol(okhttp3.Protocol.HTTP_1_1)
|
||||||
|
.code(200)
|
||||||
|
.message("OK")
|
||||||
|
.body("body".toResponseBody("text/plain".toMediaType()))
|
||||||
|
.build()
|
||||||
|
}.build()
|
||||||
|
val transport = OkHttpTransport(client) { throw IOException("body read failed") }
|
||||||
|
|
||||||
|
try {
|
||||||
|
transport.get("https://example.test/", "token")
|
||||||
|
fail("Expected IOException")
|
||||||
|
} catch (_: IOException) {
|
||||||
|
// Expected.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
package com.ubisam.ubgitea.data.auth
|
||||||
|
|
||||||
|
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||||
|
import com.ubisam.ubgitea.core.network.HttpResponse
|
||||||
|
import com.ubisam.ubgitea.core.network.HttpTransport
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.fail
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AuthApiTest {
|
||||||
|
@Test
|
||||||
|
fun `code exchange uses PKCE and never sends a client secret`() = runTest {
|
||||||
|
val transport = RecordingTransport(
|
||||||
|
postResponse = HttpResponse(200, """{"access_token":"access","refresh_token":"refresh","expires_in":60}""", emptyMap()),
|
||||||
|
)
|
||||||
|
|
||||||
|
val tokens = AuthApi(UBGiteaOAuthDefaults.config, transport)
|
||||||
|
.exchangeCode("authorization-code", "verifier", 1_000L)
|
||||||
|
|
||||||
|
assertEquals("access", tokens.accessToken)
|
||||||
|
assertEquals("refresh", tokens.refreshToken)
|
||||||
|
assertEquals(61_000L, tokens.expiresAtEpochMillis)
|
||||||
|
assertEquals("authorization_code", transport.postedFields["grant_type"])
|
||||||
|
assertEquals("verifier", transport.postedFields["code_verifier"])
|
||||||
|
assertFalse(transport.postedFields.keys.any { it.contains("secret", ignoreCase = true) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `authenticated user maps display name`() = runTest {
|
||||||
|
val transport = RecordingTransport(
|
||||||
|
getResponse = HttpResponse(200, """{"id":7,"login":"paju","full_name":"Paju User"}""", emptyMap()),
|
||||||
|
)
|
||||||
|
|
||||||
|
val user = AuthApi(UBGiteaOAuthDefaults.config, transport).getAuthenticatedUser("token")
|
||||||
|
|
||||||
|
assertEquals(7L, user.id)
|
||||||
|
assertEquals("paju", user.login)
|
||||||
|
assertEquals("Paju User", user.displayName)
|
||||||
|
assertEquals("token", transport.bearerToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing user login is an invalid response`() = runTest {
|
||||||
|
val transport = RecordingTransport(
|
||||||
|
getResponse = HttpResponse(200, """{"id":7,"login":null}""", emptyMap()),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
AuthApi(UBGiteaOAuthDefaults.config, transport).getAuthenticatedUser("token")
|
||||||
|
fail("Expected InvalidResponse")
|
||||||
|
} catch (_: com.ubisam.ubgitea.core.network.ApiFailure.InvalidResponse) {
|
||||||
|
// Expected.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RecordingTransport(
|
||||||
|
private val postResponse: HttpResponse = HttpResponse(500, "", emptyMap()),
|
||||||
|
private val getResponse: HttpResponse = HttpResponse(500, "", emptyMap()),
|
||||||
|
) : HttpTransport {
|
||||||
|
var postedFields: Map<String, String> = emptyMap()
|
||||||
|
var bearerToken: String? = null
|
||||||
|
|
||||||
|
override suspend fun postForm(url: String, fields: Map<String, String>): HttpResponse {
|
||||||
|
postedFields = fields
|
||||||
|
return postResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun get(url: String, bearerToken: String): HttpResponse {
|
||||||
|
this.bearerToken = bearerToken
|
||||||
|
return getResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package com.ubisam.ubgitea.data.auth
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class TokenCodecTest {
|
||||||
|
private val codec = TokenCodec()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `round trips token fields without exposing them in toString`() {
|
||||||
|
val original = TokenSet("access-sensitive", "refresh-sensitive", 123L)
|
||||||
|
|
||||||
|
assertEquals(original, codec.decode(codec.encode(original)))
|
||||||
|
assertEquals("TokenSet(REDACTED)", original.toString())
|
||||||
|
assertFalse(original.toString().contains("sensitive"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects malformed payload`() {
|
||||||
|
assertThrows(Exception::class.java) { codec.decode(byteArrayOf(1, 2, 3)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
package com.ubisam.ubgitea.data.repositories
|
||||||
|
|
||||||
|
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||||
|
import com.ubisam.ubgitea.core.network.HttpResponse
|
||||||
|
import com.ubisam.ubgitea.core.network.HttpTransport
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.fail
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class RepositoryListApiTest {
|
||||||
|
@Test
|
||||||
|
fun `loads every page and removes duplicate repositories by id`() = runTest {
|
||||||
|
val transport = QueueTransport(
|
||||||
|
ArrayDeque(
|
||||||
|
listOf(
|
||||||
|
response("""[{"id":1,"owner":{"login":"team"},"name":"one","description":"First","private":true,"default_branch":"main"}]""", "true"),
|
||||||
|
response("""[{"id":1,"owner":{"login":"team"},"name":"one","description":"Updated","private":true,"default_branch":"main"},{"id":2,"owner":{"login":"team"},"name":"two","description":"","private":false,"default_branch":"develop"}]""", "false"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val repositories = RepositoryListApi(
|
||||||
|
UBGiteaOAuthDefaults.config,
|
||||||
|
transport,
|
||||||
|
pageSize = 1,
|
||||||
|
).listAll("access")
|
||||||
|
|
||||||
|
assertEquals(listOf(1L, 2L), repositories.map { it.id })
|
||||||
|
assertEquals("Updated", repositories.first().description)
|
||||||
|
assertEquals(null, repositories.last().description)
|
||||||
|
assertEquals(listOf(1, 2), transport.pages)
|
||||||
|
assertTrue(transport.tokens.all { it == "access" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `short page ends pagination when server omits has-more header`() = runTest {
|
||||||
|
val transport = QueueTransport(ArrayDeque(listOf(response("[]", null))))
|
||||||
|
|
||||||
|
val repositories = RepositoryListApi(UBGiteaOAuthDefaults.config, transport).listAll("access")
|
||||||
|
|
||||||
|
assertTrue(repositories.isEmpty())
|
||||||
|
assertEquals(listOf(1), transport.pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing owner is an invalid response`() = runTest {
|
||||||
|
val transport = QueueTransport(
|
||||||
|
ArrayDeque(listOf(response("""[{"id":1,"owner":null,"name":"repo"}]""", "false"))),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
RepositoryListApi(UBGiteaOAuthDefaults.config, transport).listAll("access")
|
||||||
|
fail("Expected InvalidResponse")
|
||||||
|
} catch (_: com.ubisam.ubgitea.core.network.ApiFailure.InvalidResponse) {
|
||||||
|
// Expected.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pagination is bounded`() = runTest {
|
||||||
|
val transport = QueueTransport(
|
||||||
|
ArrayDeque(List(2) { response("""[{"id":${it + 1},"owner":{"login":"team"},"name":"repo$it"}]""", "true") }),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
RepositoryListApi(
|
||||||
|
UBGiteaOAuthDefaults.config,
|
||||||
|
transport,
|
||||||
|
pageSize = 1,
|
||||||
|
maxPages = 2,
|
||||||
|
).listAll("access")
|
||||||
|
fail("Expected InvalidResponse")
|
||||||
|
} catch (_: com.ubisam.ubgitea.core.network.ApiFailure.InvalidResponse) {
|
||||||
|
// Expected.
|
||||||
|
}
|
||||||
|
assertEquals(listOf(1, 2), transport.pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun response(body: String, hasMore: String?) = HttpResponse(
|
||||||
|
statusCode = 200,
|
||||||
|
body = body,
|
||||||
|
headers = if (hasMore == null) emptyMap() else mapOf("X-HasMore" to hasMore),
|
||||||
|
)
|
||||||
|
|
||||||
|
private class QueueTransport(private val responses: ArrayDeque<HttpResponse>) : HttpTransport {
|
||||||
|
val pages = mutableListOf<Int>()
|
||||||
|
val tokens = mutableListOf<String>()
|
||||||
|
|
||||||
|
override suspend fun postForm(url: String, fields: Map<String, String>) = error("Unexpected POST")
|
||||||
|
|
||||||
|
override suspend fun get(url: String, bearerToken: String): HttpResponse {
|
||||||
|
pages += Regex("[?&]page=(\\d+)").find(url)!!.groupValues[1].toInt()
|
||||||
|
tokens += bearerToken
|
||||||
|
return responses.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package com.ubisam.ubgitea.feature.repositories
|
||||||
|
|
||||||
|
import com.ubisam.ubgitea.core.auth.android.PendingAuthTransactionStore
|
||||||
|
import com.ubisam.ubgitea.core.auth.protocol.OAuthCallbackValidator
|
||||||
|
import com.ubisam.ubgitea.core.auth.protocol.OAuthTransaction
|
||||||
|
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class OAuthCallbackHandlerTest {
|
||||||
|
private val transaction = OAuthTransaction("v".repeat(43), "expected-state", 1_000L)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `successful callback consumes pending state exactly once and returns verifier`() = runTest {
|
||||||
|
val store = FakePendingStore(transaction)
|
||||||
|
val handler = handler(store)
|
||||||
|
|
||||||
|
val result = handler.handle("${UBGiteaOAuthDefaults.config.redirectUri}?state=expected-state&code=code")
|
||||||
|
|
||||||
|
assertEquals(1, store.consumeCount)
|
||||||
|
assertEquals(null, store.pending)
|
||||||
|
assertTrue(result is HandledOAuthCallback.Success)
|
||||||
|
assertEquals(transaction.verifier, (result as HandledOAuthCallback.Success).verifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `denied and malformed callbacks still consume pending state`() = runTest {
|
||||||
|
listOf(
|
||||||
|
"${UBGiteaOAuthDefaults.config.redirectUri}?state=expected-state&error=access_denied",
|
||||||
|
"not a uri %",
|
||||||
|
).forEach { callback ->
|
||||||
|
val store = FakePendingStore(transaction)
|
||||||
|
|
||||||
|
assertSame(HandledOAuthCallback.Rejected, handler(store).handle(callback))
|
||||||
|
assertEquals(1, store.consumeCount)
|
||||||
|
assertEquals(null, store.pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handler(store: FakePendingStore) = OAuthCallbackHandler(
|
||||||
|
store,
|
||||||
|
OAuthCallbackValidator(UBGiteaOAuthDefaults.config),
|
||||||
|
) { 2_000L }
|
||||||
|
|
||||||
|
private class FakePendingStore(var pending: OAuthTransaction?) : PendingAuthTransactionStore {
|
||||||
|
var consumeCount = 0
|
||||||
|
override suspend fun save(transaction: OAuthTransaction) { pending = transaction }
|
||||||
|
override suspend fun consume(): OAuthTransaction? {
|
||||||
|
consumeCount++
|
||||||
|
return pending.also { pending = null }
|
||||||
|
}
|
||||||
|
override suspend fun clear() { pending = null }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package com.ubisam.ubgitea.feature.repositories
|
||||||
|
|
||||||
|
import com.ubisam.ubgitea.core.network.ApiFailure
|
||||||
|
import java.io.IOException
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class RepositoryListErrorMappingTest {
|
||||||
|
@Test
|
||||||
|
fun `maps authentication permission offline and server failures`() {
|
||||||
|
assertEquals(
|
||||||
|
RepositoryListError.AUTHENTICATION_EXPIRED,
|
||||||
|
ApiFailure.AuthenticationExpired().toRepositoryListError(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
RepositoryListError.PERMISSION_DENIED,
|
||||||
|
ApiFailure.PermissionDenied().toRepositoryListError(),
|
||||||
|
)
|
||||||
|
assertEquals(RepositoryListError.OFFLINE, IOException().toRepositoryListError())
|
||||||
|
assertEquals(RepositoryListError.SERVER, ApiFailure.Server().toRepositoryListError())
|
||||||
|
assertEquals(RepositoryListError.SERVER, ApiFailure.InvalidResponse().toRepositoryListError())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package com.ubisam.ubgitea.feature.repositories
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class RepositoryListUiStateTest {
|
||||||
|
@Test
|
||||||
|
fun `content takes an immutable repository snapshot`() {
|
||||||
|
val source = mutableListOf(RepositoryRowUiModel(1, "team", "repo", null, true, "main"))
|
||||||
|
val content = RepositoryListUiState.Content("account", source)
|
||||||
|
|
||||||
|
source.clear()
|
||||||
|
|
||||||
|
assertEquals(1, content.repositories.size)
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
(content.repositories as MutableList<RepositoryRowUiModel>).clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -40,3 +40,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl
|
|||||||
- Known limitations: System browser launch, App Link dispatch, code exchange, Keystore token vault, user identity, and real-server E2E are intentionally deferred to TASK-002B. E2E remains blocked by signing fingerprints and hosted `assetlinks.json`.
|
- Known limitations: System browser launch, App Link dispatch, code exchange, Keystore token vault, user identity, and real-server E2E are intentionally deferred to TASK-002B. E2E remains blocked by signing fingerprints and hosted `assetlinks.json`.
|
||||||
- Next recommended task: TASK-002B Android OAuth integration, completing locally testable browser/storage/exchange boundaries before the external E2E gate.
|
- Next recommended task: TASK-002B Android OAuth integration, completing locally testable browser/storage/exchange boundaries before the external E2E gate.
|
||||||
- Next owner: Codex.
|
- Next owner: Codex.
|
||||||
|
|
||||||
|
## 2026-08-25 — TASK-011
|
||||||
|
|
||||||
|
- Agent and role: Codex, implementer and integrator
|
||||||
|
- Commit/diff: current TASK-011 working-tree diff; user-owned `assetlinks.json` excluded
|
||||||
|
- Features implemented: System-browser Gitea OAuth login, verified HTTPS App Link callback, PKCE code exchange without a client secret, Keystore-backed token storage and logout, authenticated identity check, and a paginated list of every repository visible to the account.
|
||||||
|
- How it was implemented: Exact callback validation reuses TASK-002A; pending verifier/state is consumed once; OkHttp runs asynchronously with cancellation/error propagation; Gson DTOs fail closed into typed API errors; `/api/v1/user` precedes bounded/deduplicated `/api/v1/user/repos`; Compose exposes localized signed-out, browser-waiting/cancel, loading, content, empty, offline, permission, expired-auth, callback, and server-error states.
|
||||||
|
- Files changed: Android manifest and extraction rules, network/auth/repository data layers, repository ViewModel/Compose screen, dependencies/resources, focused tests, and TASK-002B/004/005/011 status documentation.
|
||||||
|
- Checks and results: Debug APK build passed; 57 unit tests across 16 suites passed; Android lint passed; diff and sensitive-pattern checks passed; no WebView or production client secret exists.
|
||||||
|
- Review findings and resolutions: Browser cancellation dead-end, HTTP response-read exception handling, signed-in error recovery/logout, nullable DTO validation, immutable UI snapshots, and bounded pagination coverage were fixed. Final independent re-review reported no remaining P0/P1.
|
||||||
|
- Known limitations: No Android device is connected, so the hosted App Link, system-browser return, live account login, repository result, and device Keystore behavior require one manual E2E run on the target phone.
|
||||||
|
- Next recommended task: Install the debug APK on the target Android device and verify login plus the internal repository list; after that, begin branch browsing.
|
||||||
|
- Next owner: Human product owner for device E2E, then Codex.
|
||||||
|
|||||||
@ -6,6 +6,9 @@ lifecycleRuntimeKtx = "2.9.4"
|
|||||||
activityCompose = "1.11.0"
|
activityCompose = "1.11.0"
|
||||||
composeBom = "2025.09.00"
|
composeBom = "2025.09.00"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
|
coroutines = "1.10.2"
|
||||||
|
okhttp = "5.4.0"
|
||||||
|
gson = "2.13.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@ -17,6 +20,12 @@ androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-toolin
|
|||||||
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
|
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# TASK-002B: Integrate OAuth with Android and verify App Links
|
# TASK-002B: Integrate OAuth with Android and verify App Links
|
||||||
|
|
||||||
Status: planned — implementation depends on TASK-002A; E2E verification depends on external App Link configuration
|
Status: implemented through TASK-011 — device E2E verification pending
|
||||||
Owner: Codex
|
Owner: Codex
|
||||||
Reviewer: Claude
|
Reviewer: Claude
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# TASK-004: Implement the read-only Gitea API client
|
# TASK-004: Implement the read-only Gitea API client
|
||||||
|
|
||||||
Status: planned — depends on TASK-003 and authentication contract
|
Status: partial through TASK-011 — identity and repository-list endpoints implemented; branch/tree/file/commit remain
|
||||||
Owner: Codex
|
Owner: Codex
|
||||||
Reviewer: Claude
|
Reviewer: Claude
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# TASK-005: Browse repositories and branches
|
# TASK-005: Browse repositories and branches
|
||||||
|
|
||||||
Status: planned — depends on TASK-002 and TASK-004
|
Status: partial through TASK-011 — repository list implemented; branch browsing remains
|
||||||
Owner: Codex
|
Owner: Codex
|
||||||
Reviewer: Claude
|
Reviewer: Claude
|
||||||
|
|
||||||
|
|||||||
70
tasks/011-login-repository-list.md
Normal file
70
tasks/011-login-repository-list.md
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# TASK-011: Sign in and list accessible repositories
|
||||||
|
|
||||||
|
Status: code complete and independently reviewed — device E2E pending
|
||||||
|
Owner: Codex
|
||||||
|
Reviewer: Claude
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
A user can sign in to the configured company Gitea through the system browser and see every repository visible to that account in a localized read-only list.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Included: system-browser OAuth, verified HTTPS App Link callback, PKCE code exchange, authenticated identity check, Keystore-backed token storage, logout, paged `/user/repos` loading, and login/loading/content/empty/error UI states.
|
||||||
|
- Excluded: branches, file browsing, commits, diffs, pull requests, issues, offline repository caching, and every write operation.
|
||||||
|
|
||||||
|
## Writable paths
|
||||||
|
|
||||||
|
- `app/build.gradle.kts`
|
||||||
|
- `gradle/libs.versions.toml`
|
||||||
|
- `app/src/main/AndroidManifest.xml`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/core/auth/android/`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/core/network/`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/data/auth/`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/data/repositories/`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/feature/repositories/`
|
||||||
|
- Matching test paths
|
||||||
|
- `app/src/main/res/values/strings_repositories.xml`
|
||||||
|
- `app/src/main/res/xml/data_extraction_rules.xml`
|
||||||
|
- `tasks/002b-oauth-android-integration.md`
|
||||||
|
- `tasks/004-gitea-api-client.md`
|
||||||
|
- `tasks/005-repository-branch-ui.md`
|
||||||
|
- `tasks/011-login-repository-list.md`
|
||||||
|
- `docs/development-log.md`
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Login opens an external browsable HTTPS intent and no WebView dependency or component exists.
|
||||||
|
- [ ] The manifest accepts only the configured verified HTTPS callback host and path.
|
||||||
|
- [ ] Callback processing uses TASK-002A validation and clears pending verifier/state on every terminal result.
|
||||||
|
- [ ] Code exchange sends client ID, redirect URI, original verifier, and no client secret.
|
||||||
|
- [ ] Access/refresh tokens are encrypted with an Android Keystore key, excluded from backup, redacted from string output, and removed on logout.
|
||||||
|
- [ ] The authenticated `/api/v1/user` call confirms the account before repository loading.
|
||||||
|
- [ ] `/api/v1/user/repos` follows pagination to completion, deduplicates by stable repository ID, and has a bounded page count.
|
||||||
|
- [ ] DTOs remain in data packages; UI models contain no token, HTTP, or Android persistence types.
|
||||||
|
- [ ] UI has localized signed-out, browser-waiting, loading, content, empty, authentication-expired, permission-denied, offline, and server-error states.
|
||||||
|
- [ ] Repository rows identify owner/name, visibility, optional description, and default branch; logout is explicit.
|
||||||
|
- [ ] Unit tests cover exchange form, callback terminal clearing, token codec, repository mapping/pagination/deduplication, and error mapping.
|
||||||
|
- [ ] Build, unit tests, lint, focused tests, secret scan, and independent review pass.
|
||||||
|
|
||||||
|
## External evidence
|
||||||
|
|
||||||
|
- OAuth application is configured as a public client with the registered redirect URI.
|
||||||
|
- `https://gitea.ubisampaju.synology.me/.well-known/assetlinks.json` publicly serves `com.ubisam.ubgitea` with both development certificate fingerprints.
|
||||||
|
|
||||||
|
## Required checks
|
||||||
|
|
||||||
|
```text
|
||||||
|
./gradlew assembleDebug
|
||||||
|
./gradlew testDebugUnitTest
|
||||||
|
./gradlew lintDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- `./gradlew testDebugUnitTest assembleDebug lintDebug` — passed; 57 tests across 16 suites, 0 failures/errors; debug APK and lint report generated.
|
||||||
|
- Focused authentication, callback, token-codec, HTTP transport, repository pagination/mapping, error mapping, and immutable UI-state tests passed.
|
||||||
|
- `git diff --check` and the sensitive-pattern scan passed; no WebView or production client secret exists.
|
||||||
|
- Independent review confirmed the browser/App Link, PKCE, Keystore/backup, identity-before-repositories, and bounded pagination design. Its P1 browser-cancellation, response-read, and token-retained error-recovery findings were fixed, together with signed-in error/logout UX, nullable DTO validation, immutable snapshots, and focused regression tests. Final re-review found no remaining P0/P1.
|
||||||
|
- Hosted `assetlinks.json` was visually confirmed by the product owner with both development signing fingerprints. No Android device is connected to this workstation, so actual browser callback and authenticated live-server E2E remain a manual verification step.
|
||||||
Loading…
x
Reference in New Issue
Block a user