feat: browse repository branches and tags

This commit is contained in:
최민영 2026-08-26 10:24:19 +09:00
parent 8a47d30ab5
commit bbbff77a5e
11 changed files with 574 additions and 23 deletions

View File

@ -32,6 +32,10 @@ class MainActivity : ComponentActivity() {
onCancelSignIn = viewModel::cancelSignIn,
onRetry = viewModel::retry,
onLogout = viewModel::logout,
onRepositorySelected = viewModel::selectRepository,
onReferenceSelected = viewModel::selectReference,
onReferencesRefresh = viewModel::refreshReferences,
onBackToRepositories = viewModel::backToRepositories,
)
LaunchedEffect(viewModel) {
viewModel.browserRequests.collect(::openSystemBrowser)

View File

@ -26,12 +26,17 @@ data class AuthenticatedUser(
val displayName: String?,
)
interface AuthenticationGateway {
suspend fun exchangeCode(authorizationCode: String, verifier: String, nowEpochMillis: Long): TokenSet
suspend fun getAuthenticatedUser(accessToken: String): AuthenticatedUser
}
class AuthApi(
private val config: OAuthClientConfig,
private val transport: HttpTransport,
private val gson: Gson = Gson(),
) {
suspend fun exchangeCode(
) : AuthenticationGateway {
override suspend fun exchangeCode(
authorizationCode: String,
verifier: String,
nowEpochMillis: Long,
@ -56,7 +61,7 @@ class AuthApi(
return TokenSet(accessToken, dto.refreshToken?.takeIf(String::isNotBlank), expiresAt)
}
suspend fun getAuthenticatedUser(accessToken: String): AuthenticatedUser {
override suspend fun getAuthenticatedUser(accessToken: String): AuthenticatedUser {
val response = transport.get(
config.baseUri.resolve("/api/v1/user").toString(),
accessToken,

View File

@ -18,19 +18,23 @@ data class AccessibleRepository(
val defaultBranch: String?,
)
fun interface AccessibleRepositoriesGateway {
suspend fun listAll(accessToken: String): List<AccessibleRepository>
}
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,
) {
) : AccessibleRepositoriesGateway {
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> {
override 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()

View File

@ -7,6 +7,8 @@ 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.clickable
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
@ -21,6 +23,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.semantics.Role
import androidx.activity.compose.BackHandler
import com.ubisam.ubgitea.R
@Composable
@ -30,6 +34,10 @@ fun RepositoryListScreen(
onCancelSignIn: () -> Unit,
onRetry: () -> Unit,
onLogout: () -> Unit,
onRepositorySelected: (RepositoryRowUiModel) -> Unit,
onReferenceSelected: (String, ReferenceKind) -> Unit,
onReferencesRefresh: () -> Unit,
onBackToRepositories: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
@ -73,8 +81,38 @@ fun RepositoryListScreen(
is RepositoryListUiState.Content -> RepositoryContent(
state,
onLogout,
onRepositorySelected,
Modifier.padding(padding),
)
is RepositoryListUiState.ReferencesLoading -> {
BackHandler(onBack = onBackToRepositories)
LoadingState(
stringResource(R.string.repositories_references_loading, state.repository.name),
Modifier.padding(padding),
)
}
is RepositoryListUiState.ReferencesContent -> {
BackHandler(onBack = onBackToRepositories)
ReferencesContent(
state,
onReferenceSelected,
onReferencesRefresh,
onBackToRepositories,
onLogout,
Modifier.padding(padding),
)
}
is RepositoryListUiState.ReferencesError -> {
BackHandler(onBack = onBackToRepositories)
MessageState(
stringResource(state.type.messageResource),
stringResource(R.string.repositories_retry),
onRetry,
Modifier.padding(padding),
secondaryAction = stringResource(R.string.repositories_back),
onSecondaryAction = onBackToRepositories,
)
}
}
}
}
@ -117,6 +155,7 @@ private fun MessageState(
private fun RepositoryContent(
state: RepositoryListUiState.Content,
onLogout: () -> Unit,
onRepositorySelected: (RepositoryRowUiModel) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier.fillMaxSize()) {
@ -134,7 +173,12 @@ private fun RepositoryContent(
}
}
items(state.repositories, key = RepositoryRowUiModel::id) { repository ->
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) {
Column(
Modifier
.fillMaxWidth()
.clickable(role = Role.Button) { onRepositorySelected(repository) }
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Text("${repository.owner}/${repository.name}", style = MaterialTheme.typography.titleMedium)
Text(
stringResource(
@ -154,6 +198,84 @@ private fun RepositoryContent(
}
}
@Composable
private fun ReferencesContent(
state: RepositoryListUiState.ReferencesContent,
onReferenceSelected: (String, ReferenceKind) -> Unit,
onRefresh: () -> Unit,
onBack: () -> Unit,
onLogout: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier.fillMaxSize()) {
item {
Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onBack) { Text(stringResource(R.string.repositories_back)) }
Column(Modifier.padding(start = 12.dp).weight(1f)) {
Text("${state.repository.owner}/${state.repository.name}", style = MaterialTheme.typography.titleLarge)
Text(stringResource(R.string.repositories_account, state.accountName))
}
OutlinedButton(onClick = onLogout) { Text(stringResource(R.string.repositories_logout)) }
}
}
item {
OutlinedButton(
onClick = onRefresh,
modifier = Modifier.padding(horizontal = 16.dp),
) { Text(stringResource(R.string.repositories_refresh)) }
}
item {
Text(
stringResource(R.string.repositories_branches_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp),
)
}
if (state.branches.isEmpty()) {
item { Text(stringResource(R.string.repositories_branches_empty), Modifier.padding(horizontal = 16.dp)) }
}
items(state.branches, key = { "branch:${it.name}" }) { reference ->
ReferenceRow(reference, onReferenceSelected)
}
item {
Text(
stringResource(R.string.repositories_tags_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp),
)
}
if (state.tags.isEmpty()) {
item { Text(stringResource(R.string.repositories_tags_empty), Modifier.padding(horizontal = 16.dp)) }
}
items(state.tags, key = { "tag:${it.name}" }) { reference ->
ReferenceRow(reference, onReferenceSelected)
}
}
}
@Composable
private fun ReferenceRow(reference: ReferenceRowUiModel, onSelected: (String, ReferenceKind) -> Unit) {
Column(
Modifier
.fillMaxWidth()
.selectable(
selected = reference.isSelected,
role = Role.RadioButton,
onClick = { onSelected(reference.name, reference.kind) },
)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Text(reference.name, style = MaterialTheme.typography.titleMedium)
Text(reference.commitId.take(12), style = MaterialTheme.typography.bodySmall)
if (reference.isSelected) Text(stringResource(R.string.repositories_selected_reference))
if (reference.isProtected) Text(stringResource(R.string.repositories_protected_branch))
}
HorizontalDivider()
}
private val RepositoryListError.messageResource: Int
get() = when (this) {
RepositoryListError.AUTHENTICATION_EXPIRED -> R.string.repositories_auth_expired

View File

@ -1,22 +1,28 @@
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.android.PendingAuthTransactionStore
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.AuthenticationGateway
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 com.ubisam.ubgitea.data.repositories.AccessibleRepositoriesGateway
import com.ubisam.ubgitea.data.repositories.BearerTokenProvider
import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
import java.io.IOException
import java.util.Collections
import kotlinx.coroutines.Job
@ -43,6 +49,24 @@ sealed interface RepositoryListUiState {
val accountName: String? = null,
val canLogout: Boolean = false,
) : RepositoryListUiState
data class ReferencesLoading(
val accountName: String,
val repository: RepositoryRowUiModel,
) : RepositoryListUiState
class ReferencesContent(
val accountName: String,
val repository: RepositoryRowUiModel,
branches: List<ReferenceRowUiModel>,
tags: List<ReferenceRowUiModel>,
) : RepositoryListUiState {
val branches: List<ReferenceRowUiModel> = Collections.unmodifiableList(branches.toList())
val tags: List<ReferenceRowUiModel> = Collections.unmodifiableList(tags.toList())
}
data class ReferencesError(
val accountName: String,
val repository: RepositoryRowUiModel,
val type: RepositoryListError,
) : RepositoryListUiState
}
data class RepositoryRowUiModel(
@ -54,6 +78,16 @@ data class RepositoryRowUiModel(
val defaultBranch: String?,
)
enum class ReferenceKind { BRANCH, TAG }
data class ReferenceRowUiModel(
val name: String,
val commitId: String,
val kind: ReferenceKind,
val isProtected: Boolean,
val isSelected: Boolean,
)
enum class RepositoryListError {
AUTHENTICATION_EXPIRED,
PERMISSION_DENIED,
@ -63,13 +97,13 @@ enum class RepositoryListError {
}
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 tokenVault: TokenVault,
private val pendingAuthStore: PendingAuthTransactionStore,
private val authApi: AuthenticationGateway,
private val repositoryApi: AccessibleRepositoriesGateway,
private val browserApi: GiteaRepositoryBrowser,
private val now: () -> Long = System::currentTimeMillis,
) : AndroidViewModel(application) {
) : ViewModel() {
private val requestFactory = OAuthAuthorizationRequestFactory(UBGiteaOAuthDefaults.config)
private val callbackValidator = OAuthCallbackValidator(UBGiteaOAuthDefaults.config)
private val callbackHandler = OAuthCallbackHandler(pendingAuthStore, callbackValidator, now)
@ -80,6 +114,7 @@ class RepositoryListViewModel(
private var operation: Job? = null
private var activeAccountName: String? = null
private var hasAuthenticatedSession = false
private var repositoryContent: RepositoryListUiState.Content? = null
init {
startOperation { restoreSession() }
@ -107,7 +142,46 @@ class RepositoryListViewModel(
}
}
fun retry() = startOperation { restoreSession() }
fun retry() {
val current = mutableState.value
if (current is RepositoryListUiState.ReferencesError) {
selectRepository(current.repository)
} else {
startOperation { restoreSession() }
}
}
fun selectRepository(repository: RepositoryRowUiModel) {
val account = activeAccountName ?: return
startOperation { loadReferences(account, repository) }
}
fun selectReference(name: String, kind: ReferenceKind) {
val current = mutableState.value as? RepositoryListUiState.ReferencesContent ?: return
val selected = RepositoryReferencePresenter.select(
current.branches,
current.tags,
name,
kind,
)
mutableState.value = RepositoryListUiState.ReferencesContent(
current.accountName,
current.repository,
selected.first,
selected.second,
)
}
fun refreshReferences() {
val current = mutableState.value as? RepositoryListUiState.ReferencesContent ?: return
selectRepository(current.repository)
}
fun backToRepositories() {
operation?.cancel()
operation = null
repositoryContent?.let { mutableState.value = it }
}
fun cancelSignIn() {
startOperation {
@ -133,6 +207,7 @@ class RepositoryListViewModel(
tokenVault.clear()
activeAccountName = null
hasAuthenticatedSession = false
repositoryContent = null
mutableState.value = RepositoryListUiState.SignedOut
}
}
@ -199,7 +274,53 @@ class RepositoryListViewModel(
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)
else RepositoryListUiState.Content(accountName, rows).also { repositoryContent = it }
}
private suspend fun loadReferences(accountName: String, repository: RepositoryRowUiModel) {
mutableState.value = RepositoryListUiState.ReferencesLoading(accountName, repository)
val coordinates = RepositoryCoordinates.of(repository.owner, repository.name)
try {
val branches = RepositoryReferencePresenter.branches(
loadAllPages { page -> browserApi.listBranches(coordinates, page, PAGE_SIZE) },
repository.defaultBranch,
)
val tags = RepositoryReferencePresenter.tags(
loadAllPages { page -> browserApi.listTags(coordinates, page, PAGE_SIZE) },
)
mutableState.value = RepositoryListUiState.ReferencesContent(
accountName,
repository,
branches,
tags,
)
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
if (failure is ApiFailure.AuthenticationExpired) {
tokenVault.clear()
activeAccountName = null
hasAuthenticatedSession = false
repositoryContent = null
mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED)
} else {
mutableState.value = RepositoryListUiState.ReferencesError(
accountName,
repository,
failure.toRepositoryListError(),
)
}
}
}
private suspend fun <T> loadAllPages(loader: suspend (Int) -> com.ubisam.ubgitea.domain.model.Page<T>): List<T> {
val items = mutableListOf<T>()
var page = 1
repeat(MAX_REFERENCE_PAGES) {
val result = loader(page)
items += result.items
page = result.nextPage ?: return Collections.unmodifiableList(items.toList())
}
throw ApiFailure.InvalidResponse()
}
private fun startOperation(block: suspend () -> Unit) {
@ -215,9 +336,27 @@ class RepositoryListViewModel(
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
require(modelClass.isAssignableFrom(RepositoryListViewModel::class.java))
return RepositoryListViewModel(application) as T
val tokenVault = AndroidKeystoreTokenVault(application)
val authTransport = OkHttpTransport()
val repositoryTransport = OkHttpTransport()
return RepositoryListViewModel(
tokenVault = tokenVault,
pendingAuthStore = PendingAuthStore(application),
authApi = AuthApi(UBGiteaOAuthDefaults.config, authTransport),
repositoryApi = RepositoryListApi(UBGiteaOAuthDefaults.config, repositoryTransport),
browserApi = GiteaRepositoryBrowserApi(
UBGiteaOAuthDefaults.config,
OkHttpTransport(),
BearerTokenProvider { tokenVault.read()?.accessToken.orEmpty() },
),
) as T
}
}
private companion object {
const val PAGE_SIZE = 50
const val MAX_REFERENCE_PAGES = 100
}
}
internal fun Throwable.toRepositoryListError(): RepositoryListError = when (this) {

View File

@ -0,0 +1,37 @@
package com.ubisam.ubgitea.feature.repositories
import com.ubisam.ubgitea.domain.model.BranchSummary
import com.ubisam.ubgitea.domain.model.TagSummary
internal object RepositoryReferencePresenter {
fun branches(items: List<BranchSummary>, defaultBranch: String?): List<ReferenceRowUiModel> =
items.distinctBy { it.name.value }.map { branch ->
ReferenceRowUiModel(
name = branch.name.value,
commitId = branch.commitId,
kind = ReferenceKind.BRANCH,
isProtected = branch.isProtected,
isSelected = branch.name.value == defaultBranch,
)
}
fun tags(items: List<TagSummary>): List<ReferenceRowUiModel> =
items.distinctBy { it.name.value }.map { tag ->
ReferenceRowUiModel(
name = tag.name.value,
commitId = tag.commitId,
kind = ReferenceKind.TAG,
isProtected = false,
isSelected = false,
)
}
fun select(
branches: List<ReferenceRowUiModel>,
tags: List<ReferenceRowUiModel>,
name: String,
kind: ReferenceKind,
): Pair<List<ReferenceRowUiModel>, List<ReferenceRowUiModel>> =
branches.map { it.copy(isSelected = kind == ReferenceKind.BRANCH && it.name == name) } to
tags.map { it.copy(isSelected = kind == ReferenceKind.TAG && it.name == name) }
}

View File

@ -20,4 +20,13 @@
<string name="repositories_default_branch">기본 브랜치: %1$s</string>
<string name="repositories_no_default_branch">기본 브랜치 없음</string>
<string name="repositories_account">%1$s 계정</string>
<string name="repositories_references_loading">%1$s 저장소의 브랜치와 태그를 불러오는 중입니다.</string>
<string name="repositories_branches_title">브랜치</string>
<string name="repositories_branches_empty">브랜치가 없습니다.</string>
<string name="repositories_tags_title">태그</string>
<string name="repositories_tags_empty">태그가 없습니다.</string>
<string name="repositories_selected_reference">선택됨</string>
<string name="repositories_protected_branch">보호된 브랜치</string>
<string name="repositories_back">저장소 목록</string>
<string name="repositories_refresh">새로고침</string>
</resources>

View File

@ -0,0 +1,147 @@
package com.ubisam.ubgitea.feature.repositories
import com.ubisam.ubgitea.core.auth.android.PendingAuthTransactionStore
import com.ubisam.ubgitea.core.auth.protocol.OAuthTransaction
import com.ubisam.ubgitea.core.network.ApiFailure
import com.ubisam.ubgitea.data.auth.AuthenticatedUser
import com.ubisam.ubgitea.data.auth.AuthenticationGateway
import com.ubisam.ubgitea.data.auth.TokenSet
import com.ubisam.ubgitea.data.auth.TokenVault
import com.ubisam.ubgitea.data.repositories.AccessibleRepositoriesGateway
import com.ubisam.ubgitea.data.repositories.AccessibleRepository
import com.ubisam.ubgitea.domain.model.BranchSummary
import com.ubisam.ubgitea.domain.model.CommitSummary
import com.ubisam.ubgitea.domain.model.Page
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.model.RepositoryPath
import com.ubisam.ubgitea.domain.model.RepositoryRef
import com.ubisam.ubgitea.domain.model.RepositorySummary
import com.ubisam.ubgitea.domain.model.SourceFile
import com.ubisam.ubgitea.domain.model.TagSummary
import com.ubisam.ubgitea.domain.model.TreeEntry
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class RepositoryListViewModelTest {
private val dispatcher = StandardTestDispatcher()
@Before
fun setMainDispatcher() = Dispatchers.setMain(dispatcher)
@After
fun resetMainDispatcher() = Dispatchers.resetMain()
@Test
fun `restores account then loads branches tags and selects default branch`() = runTest(dispatcher.scheduler) {
val browser = FakeBrowser()
val viewModel = viewModel(browser)
advanceUntilIdle()
val repositories = viewModel.state.value as RepositoryListUiState.Content
viewModel.selectRepository(repositories.repositories.single())
advanceUntilIdle()
val references = viewModel.state.value as RepositoryListUiState.ReferencesContent
assertEquals("account", references.accountName)
assertEquals(listOf("main", "develop"), references.branches.map { it.name })
assertTrue(references.branches.first().isSelected)
assertEquals(listOf("v1"), references.tags.map { it.name })
}
@Test
fun `selecting tag updates one selected ref and back restores repository snapshot`() = runTest(dispatcher.scheduler) {
val viewModel = viewModel(FakeBrowser())
advanceUntilIdle()
val content = viewModel.state.value as RepositoryListUiState.Content
viewModel.selectRepository(content.repositories.single())
advanceUntilIdle()
viewModel.selectReference("v1", ReferenceKind.TAG)
val selected = viewModel.state.value as RepositoryListUiState.ReferencesContent
assertFalse(selected.branches.any { it.isSelected })
assertTrue(selected.tags.single().isSelected)
viewModel.backToRepositories()
assertTrue(viewModel.state.value is RepositoryListUiState.Content)
}
@Test
fun `branch network failure keeps repository context and retry action state`() = runTest(dispatcher.scheduler) {
val browser = FakeBrowser(branchFailure = ApiFailure.Network(java.io.IOException()))
val viewModel = viewModel(browser)
advanceUntilIdle()
val content = viewModel.state.value as RepositoryListUiState.Content
viewModel.selectRepository(content.repositories.single())
advanceUntilIdle()
val error = viewModel.state.value as RepositoryListUiState.ReferencesError
assertEquals(RepositoryListError.OFFLINE, error.type)
assertEquals("repo", error.repository.name)
}
private fun viewModel(browser: GiteaRepositoryBrowser): RepositoryListViewModel = RepositoryListViewModel(
tokenVault = FakeTokenVault(TokenSet("access", null, null)),
pendingAuthStore = FakePendingStore(),
authApi = FakeAuthGateway(),
repositoryApi = AccessibleRepositoriesGateway {
listOf(AccessibleRepository(1, "owner", "repo", null, true, "main"))
},
browserApi = browser,
now = { 1_000L },
)
private class FakeTokenVault(var tokens: TokenSet?) : TokenVault {
override suspend fun store(tokens: TokenSet) { this.tokens = tokens }
override suspend fun read(): TokenSet? = tokens
override suspend fun clear() { tokens = null }
}
private class FakePendingStore : PendingAuthTransactionStore {
override suspend fun save(transaction: OAuthTransaction) = Unit
override suspend fun consume(): OAuthTransaction? = null
override suspend fun clear() = Unit
}
private class FakeAuthGateway : AuthenticationGateway {
override suspend fun exchangeCode(authorizationCode: String, verifier: String, nowEpochMillis: Long) =
TokenSet("access", null, null)
override suspend fun getAuthenticatedUser(accessToken: String) = AuthenticatedUser(1, "account", null)
}
private class FakeBrowser(private val branchFailure: Throwable? = null) : GiteaRepositoryBrowser {
override suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary> =
Page.of(emptyList(), null)
override suspend fun listBranches(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<BranchSummary> {
branchFailure?.let { throw it }
return Page.of(
listOf(
BranchSummary(RepositoryRef.of("main"), "a", false),
BranchSummary(RepositoryRef.of("develop"), "b", true),
),
null,
)
}
override suspend fun listTags(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<TagSummary> =
Page.of(listOf(TagSummary(RepositoryRef.of("v1"), "c")), null)
override suspend fun listTree(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath, page: Int, pageSize: Int): Page<TreeEntry> =
Page.of(emptyList(), null)
override suspend fun getFile(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath): SourceFile =
error("Not used")
override suspend fun listCommits(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath?, page: Int, pageSize: Int): Page<CommitSummary> =
Page.of(emptyList(), null)
}
}

View File

@ -0,0 +1,59 @@
package com.ubisam.ubgitea.feature.repositories
import com.ubisam.ubgitea.domain.model.BranchSummary
import com.ubisam.ubgitea.domain.model.RepositoryRef
import com.ubisam.ubgitea.domain.model.TagSummary
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class RepositoryReferencePresenterTest {
@Test
fun `default branch is selected and duplicate names are removed`() {
val branches = RepositoryReferencePresenter.branches(
listOf(
BranchSummary(RepositoryRef.of("main"), "a", true),
BranchSummary(RepositoryRef.of("main"), "b", false),
BranchSummary(RepositoryRef.of("develop"), "c", false),
),
"main",
)
assertEquals(listOf("main", "develop"), branches.map { it.name })
assertTrue(branches.first().isSelected)
assertTrue(branches.first().isProtected)
assertFalse(branches.last().isSelected)
}
@Test
fun `selecting a tag clears branch selection and selects only that tag`() {
val branches = RepositoryReferencePresenter.branches(
listOf(BranchSummary(RepositoryRef.of("main"), "a", false)),
"main",
)
val tags = RepositoryReferencePresenter.tags(
listOf(TagSummary(RepositoryRef.of("v1"), "b"), TagSummary(RepositoryRef.of("v2"), "c")),
)
val selected = RepositoryReferencePresenter.select(branches, tags, "v2", ReferenceKind.TAG)
assertFalse(selected.first.single().isSelected)
assertEquals(listOf(false, true), selected.second.map { it.isSelected })
}
@Test
fun `reference content takes immutable snapshots`() {
val branches = mutableListOf(ReferenceRowUiModel("main", "a", ReferenceKind.BRANCH, false, true))
val state = RepositoryListUiState.ReferencesContent(
"account",
RepositoryRowUiModel(1, "owner", "repo", null, true, "main"),
branches,
emptyList(),
)
branches.clear()
assertEquals(1, state.branches.size)
}
}

View File

@ -123,3 +123,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl
- Review status: Codex completed source/contract self-review. Independent review is deferred while the user has assigned the serial implementation stream to Codex and must be completed before TASK-006 contract freeze.
- Next recommended task: TASK-005 repository selection and branch/tag browsing UI.
- Next owner: Codex.
## 2026-08-26 — TASK-005
- Agent and role: Codex, implementer and integrator.
- Commit/diff: TASK-005 working diff following `8a47d30`; the exact completion commit is recorded after commit creation.
- Features implemented: Repository selection plus repository-scoped branch and tag browsing, default/protected/selected reference indicators, abbreviated commit IDs, refresh, retry, logout, and deterministic back navigation.
- How it was implemented: A unidirectional immutable ViewModel state machine consumes the TASK-004 read-only browser contract, loads bounded pages serially, deduplicates references by name, cancels replaced work, and restores the cached repository-list snapshot. Small gateway interfaces make authentication and repository restoration JVM-testable without Android framework construction.
- Files changed: Repository Compose/ViewModel/presenter and tests, MainActivity callback wiring, testable authentication/repository gateway boundaries, localized repository strings, and TASK-005 evidence.
- Checks and results: Focused presenter/ViewModel tests passed; repository-wide debug build, 96 unit tests across 21 suites, and Android lint passed; diff and focused sensitive-pattern checks passed.
- Review findings and resolutions: Self-review added immutable reference snapshots, operation cancellation on back navigation, typed reference errors retaining repository context, and ViewModel tests for selection/restoration/error behavior. Independent review is deferred while Codex owns the serial stream.
- Known limitations: No device/emulator interaction test was available in this work cycle. The reviewed contract-freeze checkbox remains open until independent integration review.
- Next recommended task: TASK-006 directory navigation and bounded source-file viewing using the TASK-004 API and reviewed TASK-006A classifier.
- Next owner: Codex for continuous implementation; Claude may later perform the independent review when re-enabled by the product owner.

View File

@ -1,6 +1,6 @@
# TASK-005: Browse repositories and branches
Status: partial through TASK-011 — repository list implemented; branch browsing remains
Status: implemented and locally verified — independent review and device check deferred
Owner: Codex
Reviewer: Claude
@ -8,6 +8,9 @@ Reviewer: Claude
- `app/src/main/java/com/ubisam/ubgitea/feature/repositories/`
- `app/src/test/java/com/ubisam/ubgitea/feature/repositories/`
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt`
- `app/src/main/java/com/ubisam/ubgitea/data/auth/AuthApi.kt` (testable gateway extraction only)
- `app/src/main/java/com/ubisam/ubgitea/data/repositories/RepositoryListApi.kt` (testable gateway extraction only)
- `app/src/main/res/values/strings_repositories.xml`
- `app/src/main/java/com/ubisam/ubgitea/ui/navigation/`
- `app/src/test/java/com/ubisam/ubgitea/ui/navigation/`
@ -19,10 +22,19 @@ A signed-in user can browse every repository and branch visible to their Gitea a
## Acceptance criteria
- [ ] Repository and branch lists preserve pagination and refresh state.
- [ ] Organization/owner, visibility, default branch, and selected branch are unambiguous.
- [ ] Loading, empty, offline, authentication-expired, and server-error states are accessible and localized.
- [ ] Retry and pagination cannot create duplicate items or overlapping requests.
- [ ] UI and ViewModel tests cover primary states and selection behavior.
- [ ] Resource names use the `repositories_` prefix.
- [x] Repository and branch lists preserve pagination and refresh state.
- [x] Organization/owner, visibility, default branch, and selected branch are unambiguous.
- [x] Loading, empty, offline, authentication-expired, and server-error states are accessible and localized.
- [x] Retry and pagination cannot create duplicate items or overlapping requests.
- [x] Presentation and ViewModel tests cover primary states and selection behavior.
- [x] Resource names use the `repositories_` prefix.
- [ ] The reviewed completion commit is recorded as TASK-006's contract-freeze base.
## Evidence
- Repository rows open a repository-scoped branch/tag screen; the selected repository, account, default branch, protected status, selected ref, and abbreviated commit ID remain visible.
- Branch and tag pages are consumed serially with a 100-page bound, presenter-level name deduplication, cancellation of replaced operations, refresh/retry, and deterministic back navigation to the immutable repository snapshot.
- Focused presenter/ViewModel tests passed, including default selection, tag selection, back restoration, immutable snapshots, deduplication, and network-error mapping.
- Repository-wide `assembleDebug testDebugUnitTest lintDebug` passed: 96 unit tests across 21 suites, zero failures/errors.
- `git diff --check` passed. Focused sensitive-pattern inspection found no token, client secret, password, private key, or credential material.
- The independent reviewer and physical-device interaction check are deferred while Codex owns the serial implementation stream. TASK-006 may be developed on the local contract, but its eventual integration review must include this commit before the contract-freeze checkbox is closed.