diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index d91d091..95f9ba6 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -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) diff --git a/app/src/main/java/com/ubisam/ubgitea/data/auth/AuthApi.kt b/app/src/main/java/com/ubisam/ubgitea/data/auth/AuthApi.kt index ac47a49..73da775 100644 --- a/app/src/main/java/com/ubisam/ubgitea/data/auth/AuthApi.kt +++ b/app/src/main/java/com/ubisam/ubgitea/data/auth/AuthApi.kt @@ -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, diff --git a/app/src/main/java/com/ubisam/ubgitea/data/repositories/RepositoryListApi.kt b/app/src/main/java/com/ubisam/ubgitea/data/repositories/RepositoryListApi.kt index f65b05a..c7ece33 100644 --- a/app/src/main/java/com/ubisam/ubgitea/data/repositories/RepositoryListApi.kt +++ b/app/src/main/java/com/ubisam/ubgitea/data/repositories/RepositoryListApi.kt @@ -18,19 +18,23 @@ data class AccessibleRepository( val defaultBranch: String?, ) +fun interface AccessibleRepositoriesGateway { + suspend fun listAll(accessToken: String): List +} + 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 { + override suspend fun listAll(accessToken: String): List { val repositories = linkedMapOf() for (page in 1..maxPages) { val url = config.baseUri.resolve("/api/v1/user/repos?page=$page&limit=$pageSize").toString() diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt index 9c0966e..4317562 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt @@ -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 diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt index b778256..6401de7 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt @@ -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, + tags: List, + ) : RepositoryListUiState { + val branches: List = Collections.unmodifiableList(branches.toList()) + val tags: List = 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 loadAllPages(loader: suspend (Int) -> com.ubisam.ubgitea.domain.model.Page): List { + val items = mutableListOf() + 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 create(modelClass: Class): 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) { diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryReferencePresenter.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryReferencePresenter.kt new file mode 100644 index 0000000..7b83ea1 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryReferencePresenter.kt @@ -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, defaultBranch: String?): List = + 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): List = + 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, + tags: List, + name: String, + kind: ReferenceKind, + ): Pair, List> = + branches.map { it.copy(isSelected = kind == ReferenceKind.BRANCH && it.name == name) } to + tags.map { it.copy(isSelected = kind == ReferenceKind.TAG && it.name == name) } +} diff --git a/app/src/main/res/values/strings_repositories.xml b/app/src/main/res/values/strings_repositories.xml index f8702be..4edf572 100644 --- a/app/src/main/res/values/strings_repositories.xml +++ b/app/src/main/res/values/strings_repositories.xml @@ -20,4 +20,13 @@ 기본 브랜치: %1$s 기본 브랜치 없음 %1$s 계정 + %1$s 저장소의 브랜치와 태그를 불러오는 중입니다. + 브랜치 + 브랜치가 없습니다. + 태그 + 태그가 없습니다. + 선택됨 + 보호된 브랜치 + 저장소 목록 + 새로고침 diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt new file mode 100644 index 0000000..3640419 --- /dev/null +++ b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt @@ -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 = + Page.of(emptyList(), null) + override suspend fun listBranches(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page { + 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 = + Page.of(listOf(TagSummary(RepositoryRef.of("v1"), "c")), null) + override suspend fun listTree(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath, page: Int, pageSize: Int): Page = + 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 = + Page.of(emptyList(), null) + } +} diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryReferencePresenterTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryReferencePresenterTest.kt new file mode 100644 index 0000000..8a93392 --- /dev/null +++ b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryReferencePresenterTest.kt @@ -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) + } +} diff --git a/docs/development-log.md b/docs/development-log.md index 2800bf6..fa9d169 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -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. diff --git a/tasks/005-repository-branch-ui.md b/tasks/005-repository-branch-ui.md index d6b5a26..1eafe0b 100644 --- a/tasks/005-repository-branch-ui.md +++ b/tasks/005-repository-branch-ui.md @@ -1,6 +1,6 @@ # TASK-005: Browse repositories and branches -Status: 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.