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