feat: add Gitea login and repository list

This commit is contained in:
최민영 2026-08-25 23:21:47 +09:00
parent fd052654cf
commit a4451e9d16
27 changed files with 1412 additions and 6 deletions

View File

@ -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)
}

View File

@ -1,17 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.UBGitea">
<activity
android:name=".MainActivity"
android:exported="true">
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="gitea.ubisampaju.synology.me"
android:path="/ubisampaju/oauth/callback" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -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)
}
}

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -0,0 +1,93 @@
package com.ubisam.ubgitea.core.network
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
data class HttpResponse(
val statusCode: Int,
val body: String,
val headers: Map<String, String>,
)
interface HttpTransport {
suspend fun postForm(url: String, fields: Map<String, String>): HttpResponse
suspend fun get(url: String, bearerToken: String): HttpResponse
}
class OkHttpTransport(
private val client: OkHttpClient = OkHttpClient(),
private val responseReader: (Response) -> HttpResponse = ::readHttpResponse,
) : HttpTransport {
override suspend fun postForm(url: String, fields: Map<String, String>): HttpResponse {
val body = FormBody.Builder().apply {
fields.forEach { (name, value) -> add(name, value) }
}.build()
return execute(Request.Builder().url(url).post(body).build())
}
override suspend fun get(url: String, bearerToken: String): HttpResponse {
val request = Request.Builder()
.url(url)
.header("Authorization", "Bearer $bearerToken")
.get()
.build()
return execute(request)
}
private suspend fun execute(request: Request): HttpResponse =
suspendCancellableCoroutine { continuation ->
val call = client.newCall(request)
continuation.invokeOnCancellation { call.cancel() }
call.enqueue(
object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (continuation.isActive) continuation.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
try {
val converted = response.use(responseReader)
if (continuation.isActive) continuation.resume(converted)
} catch (failure: IOException) {
if (continuation.isActive) continuation.resumeWithException(failure)
} catch (failure: RuntimeException) {
if (continuation.isActive) {
continuation.resumeWithException(
IOException("Unable to read HTTP response", failure),
)
}
}
}
},
)
}
}
private fun readHttpResponse(response: Response): HttpResponse = HttpResponse(
statusCode = response.code,
body = response.body.string(),
headers = response.headers.names().associateWith { name -> response.header(name).orEmpty() },
)
sealed class ApiFailure(message: String) : Exception(message) {
class AuthenticationExpired : ApiFailure("Authentication expired")
class PermissionDenied : ApiFailure("Permission denied")
class InvalidResponse : ApiFailure("Invalid server response")
class Server : ApiFailure("Server error")
}
fun HttpResponse.requireSuccess(): HttpResponse = when (statusCode) {
in 200..299 -> this
401 -> throw ApiFailure.AuthenticationExpired()
403 -> throw ApiFailure.PermissionDenied()
in 500..599 -> throw ApiFailure.Server()
else -> throw ApiFailure.InvalidResponse()
}

View File

@ -0,0 +1,93 @@
package com.ubisam.ubgitea.data.auth
import com.google.gson.Gson
import com.google.gson.JsonParseException
import com.google.gson.annotations.SerializedName
import com.ubisam.ubgitea.core.auth.protocol.OAuthClientConfig
import com.ubisam.ubgitea.core.network.ApiFailure
import com.ubisam.ubgitea.core.network.HttpTransport
import com.ubisam.ubgitea.core.network.requireSuccess
data class TokenSet(
val accessToken: String,
val refreshToken: String?,
val expiresAtEpochMillis: Long?,
) {
init {
require(accessToken.isNotBlank()) { "Access token cannot be blank" }
}
override fun toString(): String = "TokenSet(REDACTED)"
}
data class AuthenticatedUser(
val id: Long,
val login: String,
val displayName: String?,
)
class AuthApi(
private val config: OAuthClientConfig,
private val transport: HttpTransport,
private val gson: Gson = Gson(),
) {
suspend fun exchangeCode(
authorizationCode: String,
verifier: String,
nowEpochMillis: Long,
): TokenSet {
require(authorizationCode.isNotBlank()) { "Authorization code cannot be blank" }
val response = transport.postForm(
url = config.baseUri.resolve("/login/oauth/access_token").toString(),
fields = mapOf(
"grant_type" to "authorization_code",
"client_id" to config.clientId,
"redirect_uri" to config.redirectUri.toString(),
"code" to authorizationCode,
"code_verifier" to verifier,
),
).requireSuccess()
val dto = parse<TokenResponseDto>(response.body)
val expiresAt = dto.expiresIn
?.takeIf { it > 0 }
?.let { seconds -> nowEpochMillis.saturatingAdd(seconds.saturatingMilliseconds()) }
val accessToken = dto.accessToken?.takeIf(String::isNotBlank)
?: throw ApiFailure.InvalidResponse()
return TokenSet(accessToken, dto.refreshToken?.takeIf(String::isNotBlank), expiresAt)
}
suspend fun getAuthenticatedUser(accessToken: String): AuthenticatedUser {
val response = transport.get(
config.baseUri.resolve("/api/v1/user").toString(),
accessToken,
).requireSuccess()
val dto = parse<UserDto>(response.body)
val login = dto.login?.takeIf(String::isNotBlank) ?: throw ApiFailure.InvalidResponse()
if (dto.id <= 0) throw ApiFailure.InvalidResponse()
return AuthenticatedUser(dto.id, login, dto.fullName?.takeIf(String::isNotBlank))
}
private inline fun <reified T> parse(body: String): T = try {
gson.fromJson(body, T::class.java) ?: throw ApiFailure.InvalidResponse()
} catch (_: JsonParseException) {
throw ApiFailure.InvalidResponse()
}
private fun Long.saturatingAdd(value: Long): Long =
if (value > 0 && this > Long.MAX_VALUE - value) Long.MAX_VALUE else this + value
private fun Long.saturatingMilliseconds(): Long =
if (this > Long.MAX_VALUE / 1_000L) Long.MAX_VALUE else this * 1_000L
private data class TokenResponseDto(
@SerializedName("access_token") val accessToken: String?,
@SerializedName("refresh_token") val refreshToken: String?,
@SerializedName("expires_in") val expiresIn: Long?,
)
private data class UserDto(
val id: Long,
val login: String?,
@SerializedName("full_name") val fullName: String?,
)
}

View File

@ -0,0 +1,28 @@
package com.ubisam.ubgitea.data.auth
interface TokenVault {
suspend fun store(tokens: TokenSet)
suspend fun read(): TokenSet?
suspend fun clear()
}
class TokenCodec {
fun encode(tokens: TokenSet): ByteArray = java.io.ByteArrayOutputStream().use { output ->
java.io.DataOutputStream(output).use { data ->
data.writeUTF(tokens.accessToken)
data.writeBoolean(tokens.refreshToken != null)
tokens.refreshToken?.let(data::writeUTF)
data.writeBoolean(tokens.expiresAtEpochMillis != null)
tokens.expiresAtEpochMillis?.let(data::writeLong)
}
output.toByteArray()
}
fun decode(bytes: ByteArray): TokenSet = java.io.DataInputStream(bytes.inputStream()).use { data ->
val accessToken = data.readUTF()
val refreshToken = if (data.readBoolean()) data.readUTF() else null
val expiresAt = if (data.readBoolean()) data.readLong() else null
if (data.available() != 0) throw IllegalArgumentException("Unexpected token payload")
TokenSet(accessToken, refreshToken, expiresAt)
}
}

View File

@ -0,0 +1,83 @@
package com.ubisam.ubgitea.data.repositories
import com.google.gson.Gson
import com.google.gson.JsonParseException
import com.google.gson.annotations.SerializedName
import com.google.gson.reflect.TypeToken
import com.ubisam.ubgitea.core.auth.protocol.OAuthClientConfig
import com.ubisam.ubgitea.core.network.ApiFailure
import com.ubisam.ubgitea.core.network.HttpTransport
import com.ubisam.ubgitea.core.network.requireSuccess
data class AccessibleRepository(
val id: Long,
val owner: String,
val name: String,
val description: String?,
val isPrivate: Boolean,
val defaultBranch: String?,
)
class RepositoryListApi(
private val config: OAuthClientConfig,
private val transport: HttpTransport,
private val gson: Gson = Gson(),
private val pageSize: Int = 50,
private val maxPages: Int = 100,
) {
init {
require(pageSize in 1..100) { "Page size must be between 1 and 100" }
require(maxPages > 0) { "Max pages must be positive" }
}
suspend fun listAll(accessToken: String): List<AccessibleRepository> {
val repositories = linkedMapOf<Long, AccessibleRepository>()
for (page in 1..maxPages) {
val url = config.baseUri.resolve("/api/v1/user/repos?page=$page&limit=$pageSize").toString()
val response = transport.get(url, accessToken).requireSuccess()
val items = parse(response.body).map(::map)
items.forEach { repositories[it.id] = it }
val hasMore = response.headers.entries
.firstOrNull { it.key.equals("X-HasMore", ignoreCase = true) }
?.value
?.equals("true", ignoreCase = true)
if (hasMore == false || (hasMore == null && items.size < pageSize)) {
return repositories.values.toList()
}
}
throw ApiFailure.InvalidResponse()
}
private fun parse(body: String): List<RepositoryDto> = try {
val type = object : TypeToken<List<RepositoryDto>>() {}.type
gson.fromJson<List<RepositoryDto>>(body, type) ?: throw ApiFailure.InvalidResponse()
} catch (_: JsonParseException) {
throw ApiFailure.InvalidResponse()
}
private fun map(dto: RepositoryDto): AccessibleRepository {
val owner = dto.owner?.login?.takeIf(String::isNotBlank) ?: throw ApiFailure.InvalidResponse()
val name = dto.name?.takeIf(String::isNotBlank) ?: throw ApiFailure.InvalidResponse()
if (dto.id <= 0) throw ApiFailure.InvalidResponse()
return AccessibleRepository(
id = dto.id,
owner = owner,
name = name,
description = dto.description?.takeIf(String::isNotBlank),
isPrivate = dto.isPrivate,
defaultBranch = dto.defaultBranch?.takeIf(String::isNotBlank),
)
}
private data class RepositoryDto(
val id: Long,
val owner: OwnerDto?,
val name: String?,
val description: String?,
@SerializedName("private") val isPrivate: Boolean,
@SerializedName("default_branch") val defaultBranch: String?,
)
private data class OwnerDto(val login: String?)
}

View File

@ -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
}
}
}

View File

@ -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
}

View File

@ -0,0 +1,228 @@
package com.ubisam.ubgitea.feature.repositories
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.ubisam.ubgitea.core.auth.android.AndroidKeystoreTokenVault
import com.ubisam.ubgitea.core.auth.android.PendingAuthStore
import com.ubisam.ubgitea.core.auth.protocol.OAuthAuthorizationRequestFactory
import com.ubisam.ubgitea.core.auth.protocol.OAuthCallbackValidator
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
import com.ubisam.ubgitea.core.network.ApiFailure
import com.ubisam.ubgitea.core.network.OkHttpTransport
import com.ubisam.ubgitea.data.auth.AuthApi
import com.ubisam.ubgitea.data.auth.TokenSet
import com.ubisam.ubgitea.data.auth.TokenVault
import com.ubisam.ubgitea.data.repositories.AccessibleRepository
import com.ubisam.ubgitea.data.repositories.RepositoryListApi
import java.io.IOException
import java.util.Collections
import kotlinx.coroutines.Job
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
sealed interface RepositoryListUiState {
data object SignedOut : RepositoryListUiState
data object WaitingForBrowser : RepositoryListUiState
data object Loading : RepositoryListUiState
class Content(accountName: String, repositories: List<RepositoryRowUiModel>) : RepositoryListUiState {
val accountName: String = accountName
val repositories: List<RepositoryRowUiModel> = Collections.unmodifiableList(repositories.toList())
}
data class Empty(val accountName: String) : RepositoryListUiState
data class Error(
val type: RepositoryListError,
val accountName: String? = null,
val canLogout: Boolean = false,
) : RepositoryListUiState
}
data class RepositoryRowUiModel(
val id: Long,
val owner: String,
val name: String,
val description: String?,
val isPrivate: Boolean,
val defaultBranch: String?,
)
enum class RepositoryListError {
AUTHENTICATION_EXPIRED,
PERMISSION_DENIED,
OFFLINE,
SERVER,
INVALID_CALLBACK,
}
class RepositoryListViewModel(
application: Application,
private val tokenVault: TokenVault = AndroidKeystoreTokenVault(application),
private val pendingAuthStore: PendingAuthStore = PendingAuthStore(application),
private val authApi: AuthApi = AuthApi(UBGiteaOAuthDefaults.config, OkHttpTransport()),
private val repositoryApi: RepositoryListApi = RepositoryListApi(UBGiteaOAuthDefaults.config, OkHttpTransport()),
private val now: () -> Long = System::currentTimeMillis,
) : AndroidViewModel(application) {
private val requestFactory = OAuthAuthorizationRequestFactory(UBGiteaOAuthDefaults.config)
private val callbackValidator = OAuthCallbackValidator(UBGiteaOAuthDefaults.config)
private val callbackHandler = OAuthCallbackHandler(pendingAuthStore, callbackValidator, now)
private val mutableState = MutableStateFlow<RepositoryListUiState>(RepositoryListUiState.Loading)
val state: StateFlow<RepositoryListUiState> = mutableState.asStateFlow()
private val mutableBrowserRequests = MutableSharedFlow<String>(extraBufferCapacity = 1)
val browserRequests: SharedFlow<String> = mutableBrowserRequests.asSharedFlow()
private var operation: Job? = null
private var activeAccountName: String? = null
private var hasAuthenticatedSession = false
init {
startOperation { restoreSession() }
}
fun signIn() {
startOperation {
val request = requestFactory.create(now())
pendingAuthStore.save(request.transaction)
mutableState.value = RepositoryListUiState.WaitingForBrowser
mutableBrowserRequests.emit(request.uri.toString())
}
}
fun handleCallback(uri: String) {
startOperation {
when (val result = callbackHandler.handle(uri)) {
is HandledOAuthCallback.Success -> authenticate(result.authorizationCode, result.verifier)
HandledOAuthCallback.Rejected -> mutableState.value = RepositoryListUiState.Error(
RepositoryListError.INVALID_CALLBACK,
activeAccountName,
hasAuthenticatedSession,
)
}
}
}
fun retry() = startOperation { restoreSession() }
fun cancelSignIn() {
startOperation {
pendingAuthStore.clear()
mutableState.value = RepositoryListUiState.SignedOut
}
}
fun browserLaunchFailed() {
startOperation {
pendingAuthStore.clear()
mutableState.value = RepositoryListUiState.Error(
RepositoryListError.INVALID_CALLBACK,
activeAccountName,
hasAuthenticatedSession,
)
}
}
fun logout() {
startOperation {
pendingAuthStore.clear()
tokenVault.clear()
activeAccountName = null
hasAuthenticatedSession = false
mutableState.value = RepositoryListUiState.SignedOut
}
}
private suspend fun authenticate(code: String, verifier: String) {
mutableState.value = RepositoryListUiState.Loading
try {
val tokens = authApi.exchangeCode(code, verifier, now())
tokenVault.store(tokens)
hasAuthenticatedSession = true
loadRepositories(tokens)
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
if (failure is ApiFailure.AuthenticationExpired) {
tokenVault.clear()
activeAccountName = null
hasAuthenticatedSession = false
}
mutableState.value = RepositoryListUiState.Error(
failure.toRepositoryListError(),
activeAccountName,
hasAuthenticatedSession,
)
}
}
private suspend fun restoreSession() {
mutableState.value = RepositoryListUiState.Loading
val tokens = tokenVault.read()
if (tokens == null) {
hasAuthenticatedSession = false
mutableState.value = RepositoryListUiState.SignedOut
return
}
hasAuthenticatedSession = true
if (tokens.expiresAtEpochMillis?.let { it <= now() } == true) {
tokenVault.clear()
activeAccountName = null
hasAuthenticatedSession = false
mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED)
return
}
try {
loadRepositories(tokens)
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
if (failure is ApiFailure.AuthenticationExpired) {
tokenVault.clear()
activeAccountName = null
hasAuthenticatedSession = false
}
mutableState.value = RepositoryListUiState.Error(
failure.toRepositoryListError(),
activeAccountName,
hasAuthenticatedSession,
)
}
}
private suspend fun loadRepositories(tokens: TokenSet) {
val user = authApi.getAuthenticatedUser(tokens.accessToken)
val accountName = user.displayName ?: user.login
activeAccountName = accountName
val repositories = repositoryApi.listAll(tokens.accessToken)
val rows = Collections.unmodifiableList(repositories.map { it.toUiModel() })
mutableState.value = if (rows.isEmpty()) RepositoryListUiState.Empty(accountName)
else RepositoryListUiState.Content(accountName, rows)
}
private fun startOperation(block: suspend () -> Unit) {
operation?.cancel()
operation = viewModelScope.launch { block() }
}
private fun AccessibleRepository.toUiModel() = RepositoryRowUiModel(
id, owner, name, description, isPrivate, defaultBranch,
)
class Factory(private val application: Application) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
require(modelClass.isAssignableFrom(RepositoryListViewModel::class.java))
return RepositoryListViewModel(application) as T
}
}
}
internal fun Throwable.toRepositoryListError(): RepositoryListError = when (this) {
is ApiFailure.AuthenticationExpired -> RepositoryListError.AUTHENTICATION_EXPIRED
is ApiFailure.PermissionDenied -> RepositoryListError.PERMISSION_DENIED
is IOException -> RepositoryListError.OFFLINE
else -> RepositoryListError.SERVER
}

View File

@ -0,0 +1,23 @@
<resources>
<string name="repositories_title">UBGitea</string>
<string name="repositories_sign_in_description">사내 Gitea에 로그인해 접근 가능한 저장소를 확인하세요.</string>
<string name="repositories_sign_in">Gitea로 로그인</string>
<string name="repositories_waiting_browser">브라우저에서 로그인을 완료해 주세요.</string>
<string name="repositories_cancel_login">로그인 취소</string>
<string name="repositories_loading">저장소를 불러오는 중입니다.</string>
<string name="repositories_empty">접근 가능한 저장소가 없습니다.</string>
<string name="repositories_empty_account">%1$s 계정에서 접근 가능한 저장소가 없습니다.</string>
<string name="repositories_error_account">%1$s 계정\n%2$s</string>
<string name="repositories_auth_expired">로그인이 만료되었습니다. 다시 로그인해 주세요.</string>
<string name="repositories_permission_denied">이 계정에는 저장소를 볼 권한이 없습니다.</string>
<string name="repositories_offline">서버에 연결할 수 없습니다. 네트워크를 확인해 주세요.</string>
<string name="repositories_server_error">Gitea 서버에서 오류가 발생했습니다.</string>
<string name="repositories_invalid_callback">로그인 응답을 확인할 수 없습니다. 다시 시도해 주세요.</string>
<string name="repositories_retry">다시 시도</string>
<string name="repositories_logout">로그아웃</string>
<string name="repositories_private">비공개</string>
<string name="repositories_public">공개</string>
<string name="repositories_default_branch">기본 브랜치: %1$s</string>
<string name="repositories_no_default_branch">기본 브랜치 없음</string>
<string name="repositories_account">%1$s 계정</string>
</resources>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup disableIfNoEncryptionCapabilities="true">
<exclude domain="sharedpref" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="." />
</device-transfer>
</data-extraction-rules>

View File

@ -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.
}
}
}

View File

@ -0,0 +1,75 @@
package com.ubisam.ubgitea.data.auth
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
import com.ubisam.ubgitea.core.network.HttpResponse
import com.ubisam.ubgitea.core.network.HttpTransport
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.fail
import org.junit.Test
class AuthApiTest {
@Test
fun `code exchange uses PKCE and never sends a client secret`() = runTest {
val transport = RecordingTransport(
postResponse = HttpResponse(200, """{"access_token":"access","refresh_token":"refresh","expires_in":60}""", emptyMap()),
)
val tokens = AuthApi(UBGiteaOAuthDefaults.config, transport)
.exchangeCode("authorization-code", "verifier", 1_000L)
assertEquals("access", tokens.accessToken)
assertEquals("refresh", tokens.refreshToken)
assertEquals(61_000L, tokens.expiresAtEpochMillis)
assertEquals("authorization_code", transport.postedFields["grant_type"])
assertEquals("verifier", transport.postedFields["code_verifier"])
assertFalse(transport.postedFields.keys.any { it.contains("secret", ignoreCase = true) })
}
@Test
fun `authenticated user maps display name`() = runTest {
val transport = RecordingTransport(
getResponse = HttpResponse(200, """{"id":7,"login":"paju","full_name":"Paju User"}""", emptyMap()),
)
val user = AuthApi(UBGiteaOAuthDefaults.config, transport).getAuthenticatedUser("token")
assertEquals(7L, user.id)
assertEquals("paju", user.login)
assertEquals("Paju User", user.displayName)
assertEquals("token", transport.bearerToken)
}
@Test
fun `missing user login is an invalid response`() = runTest {
val transport = RecordingTransport(
getResponse = HttpResponse(200, """{"id":7,"login":null}""", emptyMap()),
)
try {
AuthApi(UBGiteaOAuthDefaults.config, transport).getAuthenticatedUser("token")
fail("Expected InvalidResponse")
} catch (_: com.ubisam.ubgitea.core.network.ApiFailure.InvalidResponse) {
// Expected.
}
}
private class RecordingTransport(
private val postResponse: HttpResponse = HttpResponse(500, "", emptyMap()),
private val getResponse: HttpResponse = HttpResponse(500, "", emptyMap()),
) : HttpTransport {
var postedFields: Map<String, String> = emptyMap()
var bearerToken: String? = null
override suspend fun postForm(url: String, fields: Map<String, String>): HttpResponse {
postedFields = fields
return postResponse
}
override suspend fun get(url: String, bearerToken: String): HttpResponse {
this.bearerToken = bearerToken
return getResponse
}
}
}

View File

@ -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)) }
}
}

View File

@ -0,0 +1,99 @@
package com.ubisam.ubgitea.data.repositories
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
import com.ubisam.ubgitea.core.network.HttpResponse
import com.ubisam.ubgitea.core.network.HttpTransport
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class RepositoryListApiTest {
@Test
fun `loads every page and removes duplicate repositories by id`() = runTest {
val transport = QueueTransport(
ArrayDeque(
listOf(
response("""[{"id":1,"owner":{"login":"team"},"name":"one","description":"First","private":true,"default_branch":"main"}]""", "true"),
response("""[{"id":1,"owner":{"login":"team"},"name":"one","description":"Updated","private":true,"default_branch":"main"},{"id":2,"owner":{"login":"team"},"name":"two","description":"","private":false,"default_branch":"develop"}]""", "false"),
),
),
)
val repositories = RepositoryListApi(
UBGiteaOAuthDefaults.config,
transport,
pageSize = 1,
).listAll("access")
assertEquals(listOf(1L, 2L), repositories.map { it.id })
assertEquals("Updated", repositories.first().description)
assertEquals(null, repositories.last().description)
assertEquals(listOf(1, 2), transport.pages)
assertTrue(transport.tokens.all { it == "access" })
}
@Test
fun `short page ends pagination when server omits has-more header`() = runTest {
val transport = QueueTransport(ArrayDeque(listOf(response("[]", null))))
val repositories = RepositoryListApi(UBGiteaOAuthDefaults.config, transport).listAll("access")
assertTrue(repositories.isEmpty())
assertEquals(listOf(1), transport.pages)
}
@Test
fun `missing owner is an invalid response`() = runTest {
val transport = QueueTransport(
ArrayDeque(listOf(response("""[{"id":1,"owner":null,"name":"repo"}]""", "false"))),
)
try {
RepositoryListApi(UBGiteaOAuthDefaults.config, transport).listAll("access")
fail("Expected InvalidResponse")
} catch (_: com.ubisam.ubgitea.core.network.ApiFailure.InvalidResponse) {
// Expected.
}
}
@Test
fun `pagination is bounded`() = runTest {
val transport = QueueTransport(
ArrayDeque(List(2) { response("""[{"id":${it + 1},"owner":{"login":"team"},"name":"repo$it"}]""", "true") }),
)
try {
RepositoryListApi(
UBGiteaOAuthDefaults.config,
transport,
pageSize = 1,
maxPages = 2,
).listAll("access")
fail("Expected InvalidResponse")
} catch (_: com.ubisam.ubgitea.core.network.ApiFailure.InvalidResponse) {
// Expected.
}
assertEquals(listOf(1, 2), transport.pages)
}
private fun response(body: String, hasMore: String?) = HttpResponse(
statusCode = 200,
body = body,
headers = if (hasMore == null) emptyMap() else mapOf("X-HasMore" to hasMore),
)
private class QueueTransport(private val responses: ArrayDeque<HttpResponse>) : HttpTransport {
val pages = mutableListOf<Int>()
val tokens = mutableListOf<String>()
override suspend fun postForm(url: String, fields: Map<String, String>) = error("Unexpected POST")
override suspend fun get(url: String, bearerToken: String): HttpResponse {
pages += Regex("[?&]page=(\\d+)").find(url)!!.groupValues[1].toInt()
tokens += bearerToken
return responses.removeFirst()
}
}
}

View File

@ -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 }
}
}

View File

@ -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())
}
}

View File

@ -0,0 +1,21 @@
package com.ubisam.ubgitea.feature.repositories
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class RepositoryListUiStateTest {
@Test
fun `content takes an immutable repository snapshot`() {
val source = mutableListOf(RepositoryRowUiModel(1, "team", "repo", null, true, "main"))
val content = RepositoryListUiState.Content("account", source)
source.clear()
assertEquals(1, content.repositories.size)
assertThrows(UnsupportedOperationException::class.java) {
@Suppress("UNCHECKED_CAST")
(content.repositories as MutableList<RepositoryRowUiModel>).clear()
}
}
}

View File

@ -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.

View File

@ -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" }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,70 @@
# TASK-011: Sign in and list accessible repositories
Status: code complete and independently reviewed — device E2E pending
Owner: Codex
Reviewer: Claude
## Outcome
A user can sign in to the configured company Gitea through the system browser and see every repository visible to that account in a localized read-only list.
## Scope
- Included: system-browser OAuth, verified HTTPS App Link callback, PKCE code exchange, authenticated identity check, Keystore-backed token storage, logout, paged `/user/repos` loading, and login/loading/content/empty/error UI states.
- Excluded: branches, file browsing, commits, diffs, pull requests, issues, offline repository caching, and every write operation.
## Writable paths
- `app/build.gradle.kts`
- `gradle/libs.versions.toml`
- `app/src/main/AndroidManifest.xml`
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt`
- `app/src/main/java/com/ubisam/ubgitea/core/auth/android/`
- `app/src/main/java/com/ubisam/ubgitea/core/network/`
- `app/src/main/java/com/ubisam/ubgitea/data/auth/`
- `app/src/main/java/com/ubisam/ubgitea/data/repositories/`
- `app/src/main/java/com/ubisam/ubgitea/feature/repositories/`
- Matching test paths
- `app/src/main/res/values/strings_repositories.xml`
- `app/src/main/res/xml/data_extraction_rules.xml`
- `tasks/002b-oauth-android-integration.md`
- `tasks/004-gitea-api-client.md`
- `tasks/005-repository-branch-ui.md`
- `tasks/011-login-repository-list.md`
- `docs/development-log.md`
## Acceptance criteria
- [ ] Login opens an external browsable HTTPS intent and no WebView dependency or component exists.
- [ ] The manifest accepts only the configured verified HTTPS callback host and path.
- [ ] Callback processing uses TASK-002A validation and clears pending verifier/state on every terminal result.
- [ ] Code exchange sends client ID, redirect URI, original verifier, and no client secret.
- [ ] Access/refresh tokens are encrypted with an Android Keystore key, excluded from backup, redacted from string output, and removed on logout.
- [ ] The authenticated `/api/v1/user` call confirms the account before repository loading.
- [ ] `/api/v1/user/repos` follows pagination to completion, deduplicates by stable repository ID, and has a bounded page count.
- [ ] DTOs remain in data packages; UI models contain no token, HTTP, or Android persistence types.
- [ ] UI has localized signed-out, browser-waiting, loading, content, empty, authentication-expired, permission-denied, offline, and server-error states.
- [ ] Repository rows identify owner/name, visibility, optional description, and default branch; logout is explicit.
- [ ] Unit tests cover exchange form, callback terminal clearing, token codec, repository mapping/pagination/deduplication, and error mapping.
- [ ] Build, unit tests, lint, focused tests, secret scan, and independent review pass.
## External evidence
- OAuth application is configured as a public client with the registered redirect URI.
- `https://gitea.ubisampaju.synology.me/.well-known/assetlinks.json` publicly serves `com.ubisam.ubgitea` with both development certificate fingerprints.
## Required checks
```text
./gradlew assembleDebug
./gradlew testDebugUnitTest
./gradlew lintDebug
```
## Evidence
- `./gradlew testDebugUnitTest assembleDebug lintDebug` — passed; 57 tests across 16 suites, 0 failures/errors; debug APK and lint report generated.
- Focused authentication, callback, token-codec, HTTP transport, repository pagination/mapping, error mapping, and immutable UI-state tests passed.
- `git diff --check` and the sensitive-pattern scan passed; no WebView or production client secret exists.
- Independent review confirmed the browser/App Link, PKCE, Keystore/backup, identity-before-repositories, and bounded pagination design. Its P1 browser-cancellation, response-read, and token-retained error-recovery findings were fixed, together with signed-in error/logout UX, nullable DTO validation, immutable snapshots, and focused regression tests. Final re-review found no remaining P0/P1.
- Hosted `assetlinks.json` was visually confirmed by the product owner with both development signing fingerprints. No Android device is connected to this workstation, so actual browser callback and authenticated live-server E2E remain a manual verification step.