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.