diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index fe7f96e..2cdc4f2 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -54,12 +54,18 @@ kotlin {
dependencies {
implementation(libs.androidx.core.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(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
+ implementation(libs.kotlinx.coroutines.android)
+ implementation(libs.okhttp)
+ implementation(libs.gson)
debugImplementation(libs.androidx.compose.ui.tooling)
testImplementation(libs.junit)
+ testImplementation(libs.kotlinx.coroutines.test)
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 97b52b4..b2f6bd5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,17 +1,30 @@
+
+
+ android:exported="true"
+ android:launchMode="singleTop">
+
+
+
+
+
+
diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt
index caa3a8a..d91d091 100644
--- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt
+++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt
@@ -1,20 +1,67 @@
package com.ubisam.ubgitea
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
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
class MainActivity : ComponentActivity() {
+ private val viewModel: RepositoryListViewModel by viewModels {
+ RepositoryListViewModel.Factory(application)
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
+ val state by viewModel.state.collectAsState()
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)
}
}
diff --git a/app/src/main/java/com/ubisam/ubgitea/core/auth/android/AndroidKeystoreTokenVault.kt b/app/src/main/java/com/ubisam/ubgitea/core/auth/android/AndroidKeystoreTokenVault.kt
new file mode 100644
index 0000000..6e7755e
--- /dev/null
+++ b/app/src/main/java/com/ubisam/ubgitea/core/auth/android/AndroidKeystoreTokenVault.kt
@@ -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"
+ }
+}
diff --git a/app/src/main/java/com/ubisam/ubgitea/core/auth/android/PendingAuthStore.kt b/app/src/main/java/com/ubisam/ubgitea/core/auth/android/PendingAuthStore.kt
new file mode 100644
index 0000000..74b5ca1
--- /dev/null
+++ b/app/src/main/java/com/ubisam/ubgitea/core/auth/android/PendingAuthStore.kt
@@ -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"
+ }
+}
diff --git a/app/src/main/java/com/ubisam/ubgitea/core/network/HttpTransport.kt b/app/src/main/java/com/ubisam/ubgitea/core/network/HttpTransport.kt
new file mode 100644
index 0000000..c974fb0
--- /dev/null
+++ b/app/src/main/java/com/ubisam/ubgitea/core/network/HttpTransport.kt
@@ -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,
+)
+
+interface HttpTransport {
+ suspend fun postForm(url: String, fields: Map): 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): 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()
+}
diff --git a/app/src/main/java/com/ubisam/ubgitea/data/auth/AuthApi.kt b/app/src/main/java/com/ubisam/ubgitea/data/auth/AuthApi.kt
new file mode 100644
index 0000000..ac47a49
--- /dev/null
+++ b/app/src/main/java/com/ubisam/ubgitea/data/auth/AuthApi.kt
@@ -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(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(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 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?,
+ )
+}
diff --git a/app/src/main/java/com/ubisam/ubgitea/data/auth/TokenVault.kt b/app/src/main/java/com/ubisam/ubgitea/data/auth/TokenVault.kt
new file mode 100644
index 0000000..c6f0417
--- /dev/null
+++ b/app/src/main/java/com/ubisam/ubgitea/data/auth/TokenVault.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/com/ubisam/ubgitea/data/repositories/RepositoryListApi.kt b/app/src/main/java/com/ubisam/ubgitea/data/repositories/RepositoryListApi.kt
new file mode 100644
index 0000000..f65b05a
--- /dev/null
+++ b/app/src/main/java/com/ubisam/ubgitea/data/repositories/RepositoryListApi.kt
@@ -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 {
+ val repositories = linkedMapOf()
+ 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 = try {
+ val type = object : TypeToken>() {}.type
+ gson.fromJson>(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?)
+}
diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/OAuthCallbackHandler.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/OAuthCallbackHandler.kt
new file mode 100644
index 0000000..797af85
--- /dev/null
+++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/OAuthCallbackHandler.kt
@@ -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
+ }
+ }
+}
diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt
new file mode 100644
index 0000000..9c0966e
--- /dev/null
+++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt
@@ -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
+ }
diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt
new file mode 100644
index 0000000..d20f9df
--- /dev/null
+++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt
@@ -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) : RepositoryListUiState {
+ val accountName: String = accountName
+ val repositories: List = 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.Loading)
+ val state: StateFlow = mutableState.asStateFlow()
+ private val mutableBrowserRequests = MutableSharedFlow(extraBufferCapacity = 1)
+ val browserRequests: SharedFlow = 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 create(modelClass: Class): 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
+}
diff --git a/app/src/main/res/values/strings_repositories.xml b/app/src/main/res/values/strings_repositories.xml
new file mode 100644
index 0000000..f8702be
--- /dev/null
+++ b/app/src/main/res/values/strings_repositories.xml
@@ -0,0 +1,23 @@
+
+ UBGitea
+ 사내 Gitea에 로그인해 접근 가능한 저장소를 확인하세요.
+ Gitea로 로그인
+ 브라우저에서 로그인을 완료해 주세요.
+ 로그인 취소
+ 저장소를 불러오는 중입니다.
+ 접근 가능한 저장소가 없습니다.
+ %1$s 계정에서 접근 가능한 저장소가 없습니다.
+ %1$s 계정\n%2$s
+ 로그인이 만료되었습니다. 다시 로그인해 주세요.
+ 이 계정에는 저장소를 볼 권한이 없습니다.
+ 서버에 연결할 수 없습니다. 네트워크를 확인해 주세요.
+ Gitea 서버에서 오류가 발생했습니다.
+ 로그인 응답을 확인할 수 없습니다. 다시 시도해 주세요.
+ 다시 시도
+ 로그아웃
+ 비공개
+ 공개
+ 기본 브랜치: %1$s
+ 기본 브랜치 없음
+ %1$s 계정
+
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..759449d
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/test/java/com/ubisam/ubgitea/core/network/OkHttpTransportTest.kt b/app/src/test/java/com/ubisam/ubgitea/core/network/OkHttpTransportTest.kt
new file mode 100644
index 0000000..2fed8d5
--- /dev/null
+++ b/app/src/test/java/com/ubisam/ubgitea/core/network/OkHttpTransportTest.kt
@@ -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.
+ }
+ }
+}
diff --git a/app/src/test/java/com/ubisam/ubgitea/data/auth/AuthApiTest.kt b/app/src/test/java/com/ubisam/ubgitea/data/auth/AuthApiTest.kt
new file mode 100644
index 0000000..7b631a9
--- /dev/null
+++ b/app/src/test/java/com/ubisam/ubgitea/data/auth/AuthApiTest.kt
@@ -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 = emptyMap()
+ var bearerToken: String? = null
+
+ override suspend fun postForm(url: String, fields: Map): HttpResponse {
+ postedFields = fields
+ return postResponse
+ }
+
+ override suspend fun get(url: String, bearerToken: String): HttpResponse {
+ this.bearerToken = bearerToken
+ return getResponse
+ }
+ }
+}
diff --git a/app/src/test/java/com/ubisam/ubgitea/data/auth/TokenCodecTest.kt b/app/src/test/java/com/ubisam/ubgitea/data/auth/TokenCodecTest.kt
new file mode 100644
index 0000000..d12a523
--- /dev/null
+++ b/app/src/test/java/com/ubisam/ubgitea/data/auth/TokenCodecTest.kt
@@ -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)) }
+ }
+}
diff --git a/app/src/test/java/com/ubisam/ubgitea/data/repositories/RepositoryListApiTest.kt b/app/src/test/java/com/ubisam/ubgitea/data/repositories/RepositoryListApiTest.kt
new file mode 100644
index 0000000..f7c5eba
--- /dev/null
+++ b/app/src/test/java/com/ubisam/ubgitea/data/repositories/RepositoryListApiTest.kt
@@ -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) : HttpTransport {
+ val pages = mutableListOf()
+ val tokens = mutableListOf()
+
+ override suspend fun postForm(url: String, fields: Map) = 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()
+ }
+ }
+}
diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/OAuthCallbackHandlerTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/OAuthCallbackHandlerTest.kt
new file mode 100644
index 0000000..6818dad
--- /dev/null
+++ b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/OAuthCallbackHandlerTest.kt
@@ -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 }
+ }
+}
diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListErrorMappingTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListErrorMappingTest.kt
new file mode 100644
index 0000000..293085c
--- /dev/null
+++ b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListErrorMappingTest.kt
@@ -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())
+ }
+}
diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListUiStateTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListUiStateTest.kt
new file mode 100644
index 0000000..ab64dba
--- /dev/null
+++ b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListUiStateTest.kt
@@ -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).clear()
+ }
+ }
+}
diff --git a/docs/development-log.md b/docs/development-log.md
index 7975d41..eb4a685 100644
--- a/docs/development-log.md
+++ b/docs/development-log.md
@@ -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`.
- Next recommended task: TASK-002B Android OAuth integration, completing locally testable browser/storage/exchange boundaries before the external E2E gate.
- 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.
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ef191c1..72c8267 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -6,6 +6,9 @@ lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.11.0"
composeBom = "2025.09.00"
junit = "4.13.2"
+coroutines = "1.10.2"
+okhttp = "5.4.0"
+gson = "2.13.1"
[libraries]
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-material3 = { group = "androidx.compose.material3", name = "material3" }
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]
android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/tasks/002b-oauth-android-integration.md b/tasks/002b-oauth-android-integration.md
index 1194b5c..57cbc2c 100644
--- a/tasks/002b-oauth-android-integration.md
+++ b/tasks/002b-oauth-android-integration.md
@@ -1,6 +1,6 @@
# 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
Reviewer: Claude
diff --git a/tasks/004-gitea-api-client.md b/tasks/004-gitea-api-client.md
index bc06ab4..8ffa292 100644
--- a/tasks/004-gitea-api-client.md
+++ b/tasks/004-gitea-api-client.md
@@ -1,6 +1,6 @@
# 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
Reviewer: Claude
diff --git a/tasks/005-repository-branch-ui.md b/tasks/005-repository-branch-ui.md
index e58314e..d6b5a26 100644
--- a/tasks/005-repository-branch-ui.md
+++ b/tasks/005-repository-branch-ui.md
@@ -1,6 +1,6 @@
# 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
Reviewer: Claude
diff --git a/tasks/011-login-repository-list.md b/tasks/011-login-repository-list.md
new file mode 100644
index 0000000..17fd122
--- /dev/null
+++ b/tasks/011-login-repository-list.md
@@ -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.