Add Favorites tab backed by Gitea star (first write endpoint)
TASK-018, human product owner priority-1 request. Adds write:user OAuth scope (ADR-004) and relaxes OAuthClientConfig's read-only guard to an explicit, hard-coded write-scope allow-list rather than a general write-scope allowance. GiteaRepositoryBrowserApi gains star/ unstar/listStarred via a new RepositoryFavorites interface, reusing its existing repository mapping. RepositoryListViewModel tracks an ALL/FAVORITES mode; toggling favorite confirms against the server before updating UI state rather than acting optimistically. No create/edit/merge/close/comment endpoint is introduced. Favorite state is never cached locally. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
93b8d587ee
commit
525a0aa2ff
@ -90,6 +90,9 @@ class MainActivity : ComponentActivity() {
|
||||
onPreviousPage = viewModel::previousPage,
|
||||
onRefreshRepositories = viewModel::refreshRepositories,
|
||||
onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch,
|
||||
onShowAllRepositories = viewModel::showAllRepositories,
|
||||
onShowFavorites = viewModel::showFavorites,
|
||||
onToggleFavorite = viewModel::toggleFavorite,
|
||||
)
|
||||
}
|
||||
LaunchedEffect(viewModel) {
|
||||
|
||||
@ -13,7 +13,9 @@ data class OAuthClientConfig(
|
||||
require(clientId.isNotBlank()) { "OAuth client ID cannot be blank" }
|
||||
require(redirectUri.isSecureRedirect()) { "OAuth redirect URI must be HTTPS without query or fragment" }
|
||||
require(scopes.isNotEmpty()) { "At least one OAuth scope is required" }
|
||||
require(scopes.all { it.isReadOnly }) { "Only read-only OAuth scopes are allowed" }
|
||||
require(scopes.all { it.isReadOnly || it in EXPLICITLY_APPROVED_WRITE_SCOPES }) {
|
||||
"OAuth scope requires explicit human approval and an entry in EXPLICITLY_APPROVED_WRITE_SCOPES"
|
||||
}
|
||||
}
|
||||
|
||||
val authorizationEndpoint: URI
|
||||
@ -33,6 +35,12 @@ data class OAuthClientConfig(
|
||||
userInfo == null &&
|
||||
query == null &&
|
||||
fragment == null
|
||||
|
||||
companion object {
|
||||
// Every entry here is a deliberate, one-at-a-time human product-owner decision
|
||||
// (see docs/decisions and TASK-018), not a general write-scope allowance.
|
||||
val EXPLICITLY_APPROVED_WRITE_SCOPES: Set<OAuthScope> = setOf(OAuthScope.WRITE_USER)
|
||||
}
|
||||
}
|
||||
|
||||
enum class OAuthScope(val wireValue: String, val isReadOnly: Boolean) {
|
||||
@ -41,6 +49,7 @@ enum class OAuthScope(val wireValue: String, val isReadOnly: Boolean) {
|
||||
READ_ORGANIZATION("read:organization", true),
|
||||
READ_ISSUE("read:issue", true),
|
||||
READ_NOTIFICATION("read:notification", true),
|
||||
WRITE_USER("write:user", false),
|
||||
}
|
||||
|
||||
object UBGiteaOAuthDefaults {
|
||||
@ -54,6 +63,7 @@ object UBGiteaOAuthDefaults {
|
||||
OAuthScope.READ_ORGANIZATION,
|
||||
OAuthScope.READ_ISSUE,
|
||||
OAuthScope.READ_NOTIFICATION,
|
||||
OAuthScope.WRITE_USER,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import okhttp3.Callback
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import kotlin.coroutines.resume
|
||||
@ -20,6 +21,8 @@ data class HttpResponse(
|
||||
interface HttpTransport {
|
||||
suspend fun postForm(url: String, fields: Map<String, String>): HttpResponse
|
||||
suspend fun get(url: String, bearerToken: String): HttpResponse
|
||||
suspend fun put(url: String, bearerToken: String): HttpResponse
|
||||
suspend fun delete(url: String, bearerToken: String): HttpResponse
|
||||
}
|
||||
|
||||
class OkHttpTransport(
|
||||
@ -42,6 +45,24 @@ class OkHttpTransport(
|
||||
return execute(request)
|
||||
}
|
||||
|
||||
override suspend fun put(url: String, bearerToken: String): HttpResponse {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("Authorization", "Bearer $bearerToken")
|
||||
.put(ByteArray(0).toRequestBody(null))
|
||||
.build()
|
||||
return execute(request)
|
||||
}
|
||||
|
||||
override suspend fun delete(url: String, bearerToken: String): HttpResponse {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("Authorization", "Bearer $bearerToken")
|
||||
.delete()
|
||||
.build()
|
||||
return execute(request)
|
||||
}
|
||||
|
||||
private suspend fun execute(request: Request): HttpResponse =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val call = client.newCall(request)
|
||||
|
||||
@ -23,6 +23,7 @@ import com.ubisam.ubgitea.domain.model.TagSummary
|
||||
import com.ubisam.ubgitea.domain.model.TreeEntry
|
||||
import com.ubisam.ubgitea.domain.model.TreeEntryType
|
||||
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
|
||||
import com.ubisam.ubgitea.domain.repository.RepositoryFavorites
|
||||
import java.time.Instant
|
||||
import java.time.format.DateTimeParseException
|
||||
import java.io.IOException
|
||||
@ -39,7 +40,7 @@ class GiteaRepositoryBrowserApi(
|
||||
private val transport: HttpTransport,
|
||||
private val tokenProvider: BearerTokenProvider,
|
||||
private val gson: Gson = Gson(),
|
||||
) : GiteaRepositoryBrowser {
|
||||
) : GiteaRepositoryBrowser, RepositoryFavorites {
|
||||
override suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary> {
|
||||
validatePage(page, pageSize)
|
||||
val url = apiUrl("api", "v1", "user", "repos").withPage(page, pageSize)
|
||||
@ -48,21 +49,25 @@ class GiteaRepositoryBrowserApi(
|
||||
.addQueryParameter("order", "desc")
|
||||
.build()
|
||||
val response = get(url)
|
||||
val items = parseList<RepositoryDto>(response.body).map { dto ->
|
||||
val owner = dto.owner?.login.required()
|
||||
val name = dto.name.required()
|
||||
RepositorySummary(
|
||||
coordinates = RepositoryCoordinates.of(owner, name),
|
||||
description = dto.description?.takeIf(String::isNotBlank),
|
||||
defaultBranch = dto.defaultBranch?.takeIf(String::isNotBlank)?.let(RepositoryRef::of),
|
||||
isPrivate = dto.isPrivate,
|
||||
isFork = dto.isFork,
|
||||
updatedAtEpochMillis = dto.updatedAt.required().toEpochMillisOrInvalid(),
|
||||
)
|
||||
}
|
||||
val items = parseList<RepositoryDto>(response.body).map(::mapRepository)
|
||||
return response.toPage(items, page, pageSize)
|
||||
}
|
||||
|
||||
override suspend fun listStarred(page: Int, pageSize: Int): Page<RepositorySummary> {
|
||||
validatePage(page, pageSize)
|
||||
val response = get(apiUrl("api", "v1", "user", "starred").withPage(page, pageSize))
|
||||
val items = parseList<RepositoryDto>(response.body).map(::mapRepository)
|
||||
return response.toPage(items, page, pageSize)
|
||||
}
|
||||
|
||||
override suspend fun star(repository: RepositoryCoordinates) {
|
||||
put(starUrl(repository))
|
||||
}
|
||||
|
||||
override suspend fun unstar(repository: RepositoryCoordinates) {
|
||||
delete(starUrl(repository))
|
||||
}
|
||||
|
||||
override suspend fun listBranches(
|
||||
repository: RepositoryCoordinates,
|
||||
page: Int,
|
||||
@ -166,16 +171,44 @@ class GiteaRepositoryBrowserApi(
|
||||
return response.toPage(items, page, pageSize)
|
||||
}
|
||||
|
||||
private suspend fun get(url: HttpUrl): HttpResponse {
|
||||
private suspend fun get(url: HttpUrl): HttpResponse = authenticated { token ->
|
||||
transport.get(url.toString(), token)
|
||||
}
|
||||
|
||||
private suspend fun put(url: HttpUrl): HttpResponse = authenticated { token ->
|
||||
transport.put(url.toString(), token)
|
||||
}
|
||||
|
||||
private suspend fun delete(url: HttpUrl): HttpResponse = authenticated { token ->
|
||||
transport.delete(url.toString(), token)
|
||||
}
|
||||
|
||||
private suspend fun authenticated(call: suspend (String) -> HttpResponse): HttpResponse {
|
||||
val token = tokenProvider.accessToken()
|
||||
if (token.isBlank()) throw ApiFailure.AuthenticationExpired()
|
||||
return try {
|
||||
transport.get(url.toString(), token).requireSuccess()
|
||||
call(token).requireSuccess()
|
||||
} catch (failure: IOException) {
|
||||
throw ApiFailure.Network(failure)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapRepository(dto: RepositoryDto): RepositorySummary {
|
||||
val owner = dto.owner?.login.required()
|
||||
val name = dto.name.required()
|
||||
return RepositorySummary(
|
||||
coordinates = RepositoryCoordinates.of(owner, name),
|
||||
description = dto.description?.takeIf(String::isNotBlank),
|
||||
defaultBranch = dto.defaultBranch?.takeIf(String::isNotBlank)?.let(RepositoryRef::of),
|
||||
isPrivate = dto.isPrivate,
|
||||
isFork = dto.isFork,
|
||||
updatedAtEpochMillis = dto.updatedAt.required().toEpochMillisOrInvalid(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun starUrl(repository: RepositoryCoordinates): HttpUrl =
|
||||
apiUrl("api", "v1", "user", "starred", repository.owner, repository.name)
|
||||
|
||||
private fun mapTreeEntry(dto: ContentsDto): TreeEntry {
|
||||
val type = when (dto.type) {
|
||||
"dir" -> TreeEntryType.DIRECTORY
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
package com.ubisam.ubgitea.domain.repository
|
||||
|
||||
import com.ubisam.ubgitea.domain.model.Page
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.model.RepositorySummary
|
||||
|
||||
/**
|
||||
* The app's only write capability: Gitea's native star, which is what "favorite" means in
|
||||
* this product (see ADR-004). No other create/edit/merge/delete/admin operation is exposed
|
||||
* anywhere in the app.
|
||||
*/
|
||||
interface RepositoryFavorites {
|
||||
suspend fun listStarred(page: Int, pageSize: Int): Page<RepositorySummary>
|
||||
|
||||
suspend fun star(repository: RepositoryCoordinates)
|
||||
|
||||
suspend fun unstar(repository: RepositoryCoordinates)
|
||||
}
|
||||
@ -7,6 +7,7 @@ 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.layout.size
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@ -17,6 +18,8 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -43,6 +46,9 @@ fun RepositoryListScreen(
|
||||
onPreviousPage: () -> Unit,
|
||||
onRefreshRepositories: () -> Unit,
|
||||
onRetryLastPushedBranch: (RepositoryCoordinates) -> Unit,
|
||||
onShowAllRepositories: () -> Unit,
|
||||
onShowFavorites: () -> Unit,
|
||||
onToggleFavorite: (RepositoryRowUiModel) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
|
||||
@ -63,12 +69,19 @@ fun RepositoryListScreen(
|
||||
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.Empty -> Column(Modifier.padding(padding).fillMaxSize()) {
|
||||
ModeTabs(state.mode, onShowAllRepositories, onShowFavorites)
|
||||
MessageState(
|
||||
if (state.mode == RepositoryListMode.FAVORITES) {
|
||||
stringResource(R.string.repositories_favorites_empty)
|
||||
} else {
|
||||
stringResource(R.string.repositories_empty_account, state.accountName)
|
||||
},
|
||||
stringResource(R.string.repositories_logout),
|
||||
onLogout,
|
||||
Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
is RepositoryListUiState.Error -> {
|
||||
val needsLogin = state.type == RepositoryListError.AUTHENTICATION_EXPIRED ||
|
||||
state.type == RepositoryListError.INVALID_CALLBACK
|
||||
@ -91,6 +104,9 @@ fun RepositoryListScreen(
|
||||
onPreviousPage,
|
||||
onRefreshRepositories,
|
||||
onRetryLastPushedBranch,
|
||||
onShowAllRepositories,
|
||||
onShowFavorites,
|
||||
onToggleFavorite,
|
||||
Modifier.padding(padding),
|
||||
)
|
||||
is RepositoryListUiState.ReferencesLoading -> {
|
||||
@ -169,6 +185,9 @@ private fun RepositoryContent(
|
||||
onPreviousPage: () -> Unit,
|
||||
onRefreshRepositories: () -> Unit,
|
||||
onRetryLastPushedBranch: (RepositoryCoordinates) -> Unit,
|
||||
onShowAllRepositories: () -> Unit,
|
||||
onShowFavorites: () -> Unit,
|
||||
onToggleFavorite: (RepositoryRowUiModel) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||
@ -188,27 +207,31 @@ private fun RepositoryContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
item { ModeTabs(state.mode, onShowAllRepositories, onShowFavorites) }
|
||||
items(state.repositories, key = { it.coordinates }) { repository ->
|
||||
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(
|
||||
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,
|
||||
)
|
||||
LastPushedBranchRow(repository, onRetryLastPushedBranch)
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.clickable(role = Role.Button) { onRepositorySelected(repository) }
|
||||
.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,
|
||||
)
|
||||
LastPushedBranchRow(repository, onRetryLastPushedBranch)
|
||||
}
|
||||
FavoriteButton(state.mode, repository, onToggleFavorite)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
@ -230,6 +253,53 @@ private fun RepositoryContent(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModeTabs(mode: RepositoryListMode, onShowAllRepositories: () -> Unit, onShowFavorites: () -> Unit) {
|
||||
TabRow(selectedTabIndex = if (mode == RepositoryListMode.FAVORITES) 1 else 0) {
|
||||
Tab(
|
||||
selected = mode == RepositoryListMode.ALL,
|
||||
onClick = onShowAllRepositories,
|
||||
text = { Text(stringResource(R.string.repositories_tab_all)) },
|
||||
)
|
||||
Tab(
|
||||
selected = mode == RepositoryListMode.FAVORITES,
|
||||
onClick = onShowFavorites,
|
||||
text = { Text(stringResource(R.string.repositories_tab_favorites)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FavoriteButton(
|
||||
mode: RepositoryListMode,
|
||||
repository: RepositoryRowUiModel,
|
||||
onToggle: (RepositoryRowUiModel) -> Unit,
|
||||
) {
|
||||
Column(Modifier.padding(end = 16.dp, top = 12.dp)) {
|
||||
when (repository.favoriteAction) {
|
||||
FavoriteActionUiState.InProgress -> CircularProgressIndicator(Modifier.size(20.dp))
|
||||
FavoriteActionUiState.Failed -> OutlinedButton(onClick = { onToggle(repository) }) {
|
||||
Text(stringResource(R.string.repositories_retry))
|
||||
}
|
||||
FavoriteActionUiState.Starred -> Text(
|
||||
stringResource(R.string.repositories_favorited),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
FavoriteActionUiState.Idle -> OutlinedButton(onClick = { onToggle(repository) }) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (mode == RepositoryListMode.FAVORITES) {
|
||||
R.string.repositories_unfavorite
|
||||
} else {
|
||||
R.string.repositories_favorite
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LastPushedBranchRow(
|
||||
repository: RepositoryRowUiModel,
|
||||
|
||||
@ -21,6 +21,7 @@ import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.model.RepositorySummary
|
||||
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
|
||||
import com.ubisam.ubgitea.domain.repository.RepositoryFavorites
|
||||
import java.io.IOException
|
||||
import java.util.Collections
|
||||
import kotlinx.coroutines.Job
|
||||
@ -33,6 +34,8 @@ import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class RepositoryListMode { ALL, FAVORITES }
|
||||
|
||||
sealed interface RepositoryListUiState {
|
||||
data object SignedOut : RepositoryListUiState
|
||||
data object WaitingForBrowser : RepositoryListUiState
|
||||
@ -42,11 +45,12 @@ sealed interface RepositoryListUiState {
|
||||
repositories: List<RepositoryRowUiModel>,
|
||||
val page: Int,
|
||||
val hasNextPage: Boolean,
|
||||
val mode: RepositoryListMode,
|
||||
) : RepositoryListUiState {
|
||||
val accountName: String = accountName
|
||||
val repositories: List<RepositoryRowUiModel> = Collections.unmodifiableList(repositories.toList())
|
||||
}
|
||||
data class Empty(val accountName: String) : RepositoryListUiState
|
||||
data class Empty(val accountName: String, val mode: RepositoryListMode) : RepositoryListUiState
|
||||
data class Error(
|
||||
val type: RepositoryListError,
|
||||
val accountName: String? = null,
|
||||
@ -79,6 +83,13 @@ sealed interface LastPushedBranchUiState {
|
||||
data object Failed : LastPushedBranchUiState
|
||||
}
|
||||
|
||||
sealed interface FavoriteActionUiState {
|
||||
data object Idle : FavoriteActionUiState
|
||||
data object InProgress : FavoriteActionUiState
|
||||
data object Starred : FavoriteActionUiState
|
||||
data object Failed : FavoriteActionUiState
|
||||
}
|
||||
|
||||
data class RepositoryRowUiModel(
|
||||
val coordinates: RepositoryCoordinates,
|
||||
val description: String?,
|
||||
@ -86,6 +97,7 @@ data class RepositoryRowUiModel(
|
||||
val defaultBranch: String?,
|
||||
val updatedAtEpochMillis: Long,
|
||||
val lastPushedBranch: LastPushedBranchUiState = LastPushedBranchUiState.Loading,
|
||||
val favoriteAction: FavoriteActionUiState = FavoriteActionUiState.Idle,
|
||||
) {
|
||||
val owner: String get() = coordinates.owner
|
||||
val name: String get() = coordinates.name
|
||||
@ -114,6 +126,7 @@ class RepositoryListViewModel(
|
||||
private val pendingAuthStore: PendingAuthTransactionStore,
|
||||
private val authApi: AuthenticationGateway,
|
||||
private val browserApi: GiteaRepositoryBrowser,
|
||||
private val favoritesApi: RepositoryFavorites,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
) : ViewModel() {
|
||||
private val requestFactory = OAuthAuthorizationRequestFactory(UBGiteaOAuthDefaults.config)
|
||||
@ -192,23 +205,64 @@ class RepositoryListViewModel(
|
||||
}
|
||||
|
||||
fun nextPage() {
|
||||
val current = mutableState.value as? RepositoryListUiState.Content ?: return
|
||||
if (!current.hasNextPage) return
|
||||
val content = mutableState.value as? RepositoryListUiState.Content ?: return
|
||||
if (!content.hasNextPage) return
|
||||
val account = activeAccountName ?: return
|
||||
startOperation { loadRepositoryPage(account, current.page + 1) }
|
||||
startOperation { loadRepositoryPage(account, content.page + 1, content.mode) }
|
||||
}
|
||||
|
||||
fun previousPage() {
|
||||
val current = mutableState.value as? RepositoryListUiState.Content ?: return
|
||||
if (current.page <= 1) return
|
||||
val content = mutableState.value as? RepositoryListUiState.Content ?: return
|
||||
if (content.page <= 1) return
|
||||
val account = activeAccountName ?: return
|
||||
startOperation { loadRepositoryPage(account, current.page - 1) }
|
||||
startOperation { loadRepositoryPage(account, content.page - 1, content.mode) }
|
||||
}
|
||||
|
||||
fun refreshRepositories() {
|
||||
val current = mutableState.value as? RepositoryListUiState.Content ?: return
|
||||
val account = activeAccountName ?: return
|
||||
startOperation { loadRepositoryPage(account, current.page) }
|
||||
val (page, mode) = when (val current = mutableState.value) {
|
||||
is RepositoryListUiState.Content -> current.page to current.mode
|
||||
is RepositoryListUiState.Empty -> 1 to current.mode
|
||||
else -> return
|
||||
}
|
||||
startOperation { loadRepositoryPage(account, page, mode) }
|
||||
}
|
||||
|
||||
fun showAllRepositories() {
|
||||
val account = activeAccountName ?: return
|
||||
startOperation { loadRepositoryPage(account, page = 1, mode = RepositoryListMode.ALL) }
|
||||
}
|
||||
|
||||
fun showFavorites() {
|
||||
val account = activeAccountName ?: return
|
||||
startOperation { loadRepositoryPage(account, page = 1, mode = RepositoryListMode.FAVORITES) }
|
||||
}
|
||||
|
||||
fun toggleFavorite(repository: RepositoryRowUiModel) {
|
||||
val content = mutableState.value as? RepositoryListUiState.Content ?: return
|
||||
val mode = content.mode
|
||||
updateRow(repository.coordinates) { it.copy(favoriteAction = FavoriteActionUiState.InProgress) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
when (mode) {
|
||||
RepositoryListMode.ALL -> {
|
||||
favoritesApi.star(repository.coordinates)
|
||||
updateRow(repository.coordinates) { it.copy(favoriteAction = FavoriteActionUiState.Starred) }
|
||||
}
|
||||
RepositoryListMode.FAVORITES -> {
|
||||
favoritesApi.unstar(repository.coordinates)
|
||||
removeRow(repository.coordinates)
|
||||
}
|
||||
}
|
||||
} catch (failure: Throwable) {
|
||||
if (failure is CancellationException) throw failure
|
||||
if (failure is ApiFailure.AuthenticationExpired) {
|
||||
clearSessionOnAuthenticationExpired()
|
||||
} else {
|
||||
updateRow(repository.coordinates) { it.copy(favoriteAction = FavoriteActionUiState.Failed) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun retryRowLastPushedBranch(coordinates: RepositoryCoordinates) {
|
||||
@ -315,21 +369,24 @@ class RepositoryListViewModel(
|
||||
val user = authApi.getAuthenticatedUser(tokens.accessToken)
|
||||
val accountName = user.displayName ?: user.login
|
||||
activeAccountName = accountName
|
||||
loadRepositoryPage(accountName, page = 1)
|
||||
loadRepositoryPage(accountName, page = 1, mode = RepositoryListMode.ALL)
|
||||
}
|
||||
|
||||
private suspend fun loadRepositoryPage(accountName: String, page: Int) {
|
||||
private suspend fun loadRepositoryPage(accountName: String, page: Int, mode: RepositoryListMode) {
|
||||
cancelRowBranchJobs()
|
||||
mutableState.value = RepositoryListUiState.Loading
|
||||
try {
|
||||
val result = browserApi.listRepositories(page, REPOSITORY_PAGE_SIZE)
|
||||
val result = when (mode) {
|
||||
RepositoryListMode.ALL -> browserApi.listRepositories(page, REPOSITORY_PAGE_SIZE)
|
||||
RepositoryListMode.FAVORITES -> favoritesApi.listStarred(page, REPOSITORY_PAGE_SIZE)
|
||||
}
|
||||
val rows = result.items.map { it.toUiModel() }
|
||||
if (rows.isEmpty() && page == 1) {
|
||||
repositoryContent = null
|
||||
mutableState.value = RepositoryListUiState.Empty(accountName)
|
||||
mutableState.value = RepositoryListUiState.Empty(accountName, mode)
|
||||
return
|
||||
}
|
||||
val content = RepositoryListUiState.Content(accountName, rows, page, result.nextPage != null)
|
||||
val content = RepositoryListUiState.Content(accountName, rows, page, result.nextPage != null, mode)
|
||||
repositoryContent = content
|
||||
mutableState.value = content
|
||||
loadRowBranches(content)
|
||||
@ -371,12 +428,7 @@ class RepositoryListViewModel(
|
||||
} catch (failure: Throwable) {
|
||||
if (failure is CancellationException) throw failure
|
||||
if (failure is ApiFailure.AuthenticationExpired) {
|
||||
cancelRowBranchJobs()
|
||||
tokenVault.clear()
|
||||
activeAccountName = null
|
||||
hasAuthenticatedSession = false
|
||||
repositoryContent = null
|
||||
mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED)
|
||||
clearSessionOnAuthenticationExpired()
|
||||
} else {
|
||||
updateRow(coordinates) { it.copy(lastPushedBranch = LastPushedBranchUiState.Failed) }
|
||||
}
|
||||
@ -384,10 +436,29 @@ class RepositoryListViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun clearSessionOnAuthenticationExpired() {
|
||||
cancelRowBranchJobs()
|
||||
tokenVault.clear()
|
||||
activeAccountName = null
|
||||
hasAuthenticatedSession = false
|
||||
repositoryContent = null
|
||||
mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED)
|
||||
}
|
||||
|
||||
private fun updateRow(coordinates: RepositoryCoordinates, transform: (RepositoryRowUiModel) -> RepositoryRowUiModel) {
|
||||
val current = mutableState.value as? RepositoryListUiState.Content ?: return
|
||||
val updated = current.repositories.map { if (it.coordinates == coordinates) transform(it) else it }
|
||||
RepositoryListUiState.Content(current.accountName, updated, current.page, current.hasNextPage).also {
|
||||
replaceContent(current, updated)
|
||||
}
|
||||
|
||||
private fun removeRow(coordinates: RepositoryCoordinates) {
|
||||
val current = mutableState.value as? RepositoryListUiState.Content ?: return
|
||||
rowBranchJobs.remove(coordinates)?.cancel()
|
||||
replaceContent(current, current.repositories.filterNot { it.coordinates == coordinates })
|
||||
}
|
||||
|
||||
private fun replaceContent(current: RepositoryListUiState.Content, repositories: List<RepositoryRowUiModel>) {
|
||||
RepositoryListUiState.Content(current.accountName, repositories, current.page, current.hasNextPage, current.mode).also {
|
||||
repositoryContent = it
|
||||
mutableState.value = it
|
||||
}
|
||||
@ -462,15 +533,17 @@ class RepositoryListViewModel(
|
||||
require(modelClass.isAssignableFrom(RepositoryListViewModel::class.java))
|
||||
val tokenVault = AndroidKeystoreTokenVault(application)
|
||||
val authTransport = OkHttpTransport()
|
||||
val browser = GiteaRepositoryBrowserApi(
|
||||
UBGiteaOAuthDefaults.config,
|
||||
OkHttpTransport(),
|
||||
BearerTokenProvider { tokenVault.read()?.accessToken.orEmpty() },
|
||||
)
|
||||
return RepositoryListViewModel(
|
||||
tokenVault = tokenVault,
|
||||
pendingAuthStore = PendingAuthStore(application),
|
||||
authApi = AuthApi(UBGiteaOAuthDefaults.config, authTransport),
|
||||
browserApi = GiteaRepositoryBrowserApi(
|
||||
UBGiteaOAuthDefaults.config,
|
||||
OkHttpTransport(),
|
||||
BearerTokenProvider { tokenVault.read()?.accessToken.orEmpty() },
|
||||
),
|
||||
browserApi = browser,
|
||||
favoritesApi = browser,
|
||||
) as T
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,4 +36,10 @@
|
||||
<string name="repositories_last_pushed_branch">마지막 push: %1$s</string>
|
||||
<string name="repositories_last_pushed_branch_none">브랜치가 없습니다.</string>
|
||||
<string name="repositories_last_pushed_branch_failed">마지막 push 브랜치를 불러오지 못했습니다.</string>
|
||||
<string name="repositories_tab_all">전체 저장소</string>
|
||||
<string name="repositories_tab_favorites">즐겨찾기</string>
|
||||
<string name="repositories_favorites_empty">즐겨찾기한 저장소가 없습니다.</string>
|
||||
<string name="repositories_favorite">즐겨찾기</string>
|
||||
<string name="repositories_unfavorite">즐겨찾기 해제</string>
|
||||
<string name="repositories_favorited">즐겨찾기됨</string>
|
||||
</resources>
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
package com.ubisam.ubgitea.core.auth.protocol
|
||||
|
||||
import java.net.URI
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
||||
class OAuthClientConfigTest {
|
||||
@Test
|
||||
fun everyNonReadOnlyScopeIsExplicitlyApproved() {
|
||||
// Guards against a future write scope being added to the enum without also being
|
||||
// added (deliberately, by a human decision) to OAuthClientConfig's approval list.
|
||||
val writeScopes = OAuthScope.entries.filterNot { it.isReadOnly }.toSet()
|
||||
assertEquals(writeScopes, OAuthClientConfig.EXPLICITLY_APPROVED_WRITE_SCOPES)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsExplicitlyApprovedWriteScopeAlongsideReadScopes() {
|
||||
config(setOf(OAuthScope.READ_USER, OAuthScope.WRITE_USER))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsReadOnlyScopesAlone() {
|
||||
config(setOf(OAuthScope.READ_USER, OAuthScope.READ_REPOSITORY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsEmptyScopes() {
|
||||
assertThrows(IllegalArgumentException::class.java) { config(emptySet()) }
|
||||
}
|
||||
|
||||
private fun config(scopes: Set<OAuthScope>) = OAuthClientConfig(
|
||||
baseUri = URI("https://gitea.example.com"),
|
||||
clientId = "public-client",
|
||||
redirectUri = URI("https://gitea.example.com/callback"),
|
||||
scopes = scopes,
|
||||
)
|
||||
}
|
||||
@ -71,5 +71,8 @@ class AuthApiTest {
|
||||
this.bearerToken = bearerToken
|
||||
return getResponse
|
||||
}
|
||||
|
||||
override suspend fun put(url: String, bearerToken: String): HttpResponse = error("Unexpected PUT")
|
||||
override suspend fun delete(url: String, bearerToken: String): HttpResponse = error("Unexpected DELETE")
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,6 +42,47 @@ class GiteaRepositoryBrowserApiTest {
|
||||
assertEquals("5", url.queryParameter("limit"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `starring sends PUT to the encoded starred endpoint`() = runTest {
|
||||
val transport = QueueTransport(response("", emptyMap()))
|
||||
|
||||
api(transport).star(repository)
|
||||
|
||||
val url = transport.putUrls.single().toHttpUrl()
|
||||
assertEquals(listOf("api", "v1", "user", "starred", "team name", "mobile+#"), url.pathSegments)
|
||||
assertTrue(transport.urls.isEmpty())
|
||||
assertTrue(transport.deleteUrls.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unstarring sends DELETE to the encoded starred endpoint`() = runTest {
|
||||
val transport = QueueTransport(response("", emptyMap()))
|
||||
|
||||
api(transport).unstar(repository)
|
||||
|
||||
val url = transport.deleteUrls.single().toHttpUrl()
|
||||
assertEquals(listOf("api", "v1", "user", "starred", "team name", "mobile+#"), url.pathSegments)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `starred repositories page and map the same as the main repository list`() = runTest {
|
||||
val transport = QueueTransport(
|
||||
response(
|
||||
"""[{"owner":{"login":"team"},"name":"repo","default_branch":"main","private":true,"fork":false,"updated_at":"2026-08-25T10:20:30Z"}]""",
|
||||
mapOf("X-HasMore" to "false"),
|
||||
),
|
||||
)
|
||||
|
||||
val page = api(transport).listStarred(1, 5)
|
||||
|
||||
assertEquals("repo", page.items.single().coordinates.name)
|
||||
assertNull(page.nextPage)
|
||||
assertEquals(
|
||||
listOf("api", "v1", "user", "starred"),
|
||||
transport.urls.single().toHttpUrl().pathSegments,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repository with no default branch maps to null instead of failing the whole page`() = runTest {
|
||||
val transport = QueueTransport(
|
||||
@ -200,6 +241,8 @@ class GiteaRepositoryBrowserApiTest {
|
||||
private class QueueTransport(vararg responses: HttpResponse) : HttpTransport {
|
||||
private val queue = ArrayDeque(responses.toList())
|
||||
val urls = mutableListOf<String>()
|
||||
val putUrls = mutableListOf<String>()
|
||||
val deleteUrls = mutableListOf<String>()
|
||||
|
||||
override suspend fun postForm(url: String, fields: Map<String, String>) = error("Unexpected POST")
|
||||
|
||||
@ -208,5 +251,17 @@ class GiteaRepositoryBrowserApiTest {
|
||||
assertEquals("access", bearerToken)
|
||||
return queue.removeFirst()
|
||||
}
|
||||
|
||||
override suspend fun put(url: String, bearerToken: String): HttpResponse {
|
||||
putUrls += url
|
||||
assertEquals("access", bearerToken)
|
||||
return queue.removeFirst()
|
||||
}
|
||||
|
||||
override suspend fun delete(url: String, bearerToken: String): HttpResponse {
|
||||
deleteUrls += url
|
||||
assertEquals("access", bearerToken)
|
||||
return queue.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,6 +52,10 @@ class GiteaRepositoryBrowserHttpTest {
|
||||
delegate.postForm(url.replace("https://", "http://"), fields)
|
||||
override suspend fun get(url: String, bearerToken: String) =
|
||||
delegate.get(url.replace("https://", "http://"), bearerToken)
|
||||
override suspend fun put(url: String, bearerToken: String) =
|
||||
delegate.put(url.replace("https://", "http://"), bearerToken)
|
||||
override suspend fun delete(url: String, bearerToken: String) =
|
||||
delegate.delete(url.replace("https://", "http://"), bearerToken)
|
||||
}
|
||||
val api = GiteaRepositoryBrowserApi(config, transport, BearerTokenProvider { "test-access" })
|
||||
|
||||
|
||||
@ -11,7 +11,13 @@ class RepositoryListUiStateTest {
|
||||
val source = mutableListOf(
|
||||
RepositoryRowUiModel(RepositoryCoordinates.of("team", "repo"), null, true, "main", 1_000L),
|
||||
)
|
||||
val content = RepositoryListUiState.Content("account", source, page = 1, hasNextPage = false)
|
||||
val content = RepositoryListUiState.Content(
|
||||
"account",
|
||||
source,
|
||||
page = 1,
|
||||
hasNextPage = false,
|
||||
mode = RepositoryListMode.ALL,
|
||||
)
|
||||
|
||||
source.clear()
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ 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 com.ubisam.ubgitea.domain.repository.RepositoryFavorites
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
@ -200,6 +201,7 @@ class RepositoryListViewModelTest {
|
||||
pendingAuthStore = FakePendingStore(),
|
||||
authApi = FakeAuthGateway(),
|
||||
browserApi = browser,
|
||||
favoritesApi = browser,
|
||||
now = { 1_000L },
|
||||
)
|
||||
advanceUntilIdle()
|
||||
@ -209,6 +211,108 @@ class RepositoryListViewModelTest {
|
||||
assertEquals(null, tokenVault.tokens)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showing favorites loads starred repositories in favorites mode`() = runTest(dispatcher.scheduler) {
|
||||
val browser = FakeBrowser(
|
||||
repositories = listOf(page1Of(repo("all-repo"))),
|
||||
starred = listOf(page1Of(repo("starred-repo"))),
|
||||
)
|
||||
val viewModel = viewModel(browser)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.showFavorites()
|
||||
advanceUntilIdle()
|
||||
|
||||
val content = viewModel.state.value as RepositoryListUiState.Content
|
||||
assertEquals(RepositoryListMode.FAVORITES, content.mode)
|
||||
assertEquals(listOf("starred-repo"), content.repositories.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `starring a repository from the all-repositories list marks it starred without removing it`() = runTest(dispatcher.scheduler) {
|
||||
val browser = FakeBrowser(repositories = listOf(page1Of(repo("repo"))))
|
||||
val viewModel = viewModel(browser)
|
||||
advanceUntilIdle()
|
||||
|
||||
val row = (viewModel.state.value as RepositoryListUiState.Content).repositories.single()
|
||||
viewModel.toggleFavorite(row)
|
||||
advanceUntilIdle()
|
||||
|
||||
val updated = (viewModel.state.value as RepositoryListUiState.Content).repositories.single()
|
||||
assertEquals(FavoriteActionUiState.Starred, updated.favoriteAction)
|
||||
assertEquals(listOf(RepositoryCoordinates.of("owner", "repo")), browser.starredCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unstarring from the favorites tab removes the row without affecting others`() = runTest(dispatcher.scheduler) {
|
||||
val browser = FakeBrowser(
|
||||
starred = listOf(Page.of(listOf(repo("keep"), repo("remove")), null)),
|
||||
)
|
||||
val viewModel = viewModel(browser)
|
||||
advanceUntilIdle()
|
||||
viewModel.showFavorites()
|
||||
advanceUntilIdle()
|
||||
|
||||
val toRemove = (viewModel.state.value as RepositoryListUiState.Content).repositories.single { it.name == "remove" }
|
||||
viewModel.toggleFavorite(toRemove)
|
||||
advanceUntilIdle()
|
||||
|
||||
val content = viewModel.state.value as RepositoryListUiState.Content
|
||||
assertEquals(listOf("keep"), content.repositories.map { it.name })
|
||||
assertEquals(listOf(RepositoryCoordinates.of("owner", "remove")), browser.unstarredCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed favorite toggle marks only that row failed and can be retried`() = runTest(dispatcher.scheduler) {
|
||||
val browser = FakeBrowser(
|
||||
repositories = listOf(page1Of(repo("a"), repo("b"))),
|
||||
favoriteActionFailure = ApiFailure.Network(java.io.IOException()),
|
||||
)
|
||||
val viewModel = viewModel(browser)
|
||||
advanceUntilIdle()
|
||||
|
||||
val rowA = (viewModel.state.value as RepositoryListUiState.Content).repositories.single { it.name == "a" }
|
||||
viewModel.toggleFavorite(rowA)
|
||||
advanceUntilIdle()
|
||||
|
||||
val rows = (viewModel.state.value as RepositoryListUiState.Content).repositories.associateBy { it.name }
|
||||
assertEquals(FavoriteActionUiState.Failed, rows.getValue("a").favoriteAction)
|
||||
assertEquals(FavoriteActionUiState.Idle, rows.getValue("b").favoriteAction)
|
||||
|
||||
browser.favoriteActionFailure = null
|
||||
viewModel.toggleFavorite(rows.getValue("a"))
|
||||
advanceUntilIdle()
|
||||
|
||||
val retried = (viewModel.state.value as RepositoryListUiState.Content).repositories.single { it.name == "a" }
|
||||
assertEquals(FavoriteActionUiState.Starred, retried.favoriteAction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expired authentication during a favorite toggle clears the session instead of marking the row failed`() = runTest(dispatcher.scheduler) {
|
||||
val browser = FakeBrowser(
|
||||
repositories = listOf(page1Of(repo("repo"))),
|
||||
favoriteActionFailure = ApiFailure.AuthenticationExpired(),
|
||||
)
|
||||
val tokenVault = FakeTokenVault(TokenSet("access", null, null))
|
||||
val viewModel = RepositoryListViewModel(
|
||||
tokenVault = tokenVault,
|
||||
pendingAuthStore = FakePendingStore(),
|
||||
authApi = FakeAuthGateway(),
|
||||
browserApi = browser,
|
||||
favoritesApi = browser,
|
||||
now = { 1_000L },
|
||||
)
|
||||
advanceUntilIdle()
|
||||
val row = (viewModel.state.value as RepositoryListUiState.Content).repositories.single()
|
||||
|
||||
viewModel.toggleFavorite(row)
|
||||
advanceUntilIdle()
|
||||
|
||||
val error = viewModel.state.value as RepositoryListUiState.Error
|
||||
assertEquals(RepositoryListError.AUTHENTICATION_EXPIRED, error.type)
|
||||
assertEquals(null, tokenVault.tokens)
|
||||
}
|
||||
|
||||
private fun page1Of(vararg repositories: RepositorySummary) = Page.of(repositories.toList(), null)
|
||||
|
||||
private fun repo(name: String) = RepositorySummary(
|
||||
@ -220,11 +324,12 @@ class RepositoryListViewModelTest {
|
||||
updatedAtEpochMillis = 1_000L,
|
||||
)
|
||||
|
||||
private fun viewModel(browser: GiteaRepositoryBrowser): RepositoryListViewModel = RepositoryListViewModel(
|
||||
private fun viewModel(browser: FakeBrowser): RepositoryListViewModel = RepositoryListViewModel(
|
||||
tokenVault = FakeTokenVault(TokenSet("access", null, null)),
|
||||
pendingAuthStore = FakePendingStore(),
|
||||
authApi = FakeAuthGateway(),
|
||||
browserApi = browser,
|
||||
favoritesApi = browser,
|
||||
now = { 1_000L },
|
||||
)
|
||||
|
||||
@ -251,14 +356,31 @@ class RepositoryListViewModelTest {
|
||||
private val branchFailure: Throwable? = null,
|
||||
var branchFailureForRepository: String? = null,
|
||||
var branchesByRepository: Map<String, List<BranchSummary>> = emptyMap(),
|
||||
) : GiteaRepositoryBrowser {
|
||||
private val starred: List<Page<RepositorySummary>> = listOf(Page.of(emptyList(), null)),
|
||||
var favoriteActionFailure: Throwable? = null,
|
||||
) : GiteaRepositoryBrowser, RepositoryFavorites {
|
||||
val repositoryPageRequests = mutableListOf<Pair<Int, Int>>()
|
||||
val starredCalls = mutableListOf<RepositoryCoordinates>()
|
||||
val unstarredCalls = mutableListOf<RepositoryCoordinates>()
|
||||
|
||||
override suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary> {
|
||||
repositoryPageRequests += page to pageSize
|
||||
return repositories.getOrElse(page - 1) { Page.of(emptyList(), null) }
|
||||
}
|
||||
|
||||
override suspend fun listStarred(page: Int, pageSize: Int): Page<RepositorySummary> =
|
||||
starred.getOrElse(page - 1) { Page.of(emptyList(), null) }
|
||||
|
||||
override suspend fun star(repository: RepositoryCoordinates) {
|
||||
favoriteActionFailure?.let { throw it }
|
||||
starredCalls += repository
|
||||
}
|
||||
|
||||
override suspend fun unstar(repository: RepositoryCoordinates) {
|
||||
favoriteActionFailure?.let { throw it }
|
||||
unstarredCalls += repository
|
||||
}
|
||||
|
||||
override suspend fun listBranches(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<BranchSummary> {
|
||||
branchFailure?.let { throw it }
|
||||
if (repository.name == branchFailureForRepository) throw ApiFailure.Network(java.io.IOException())
|
||||
|
||||
34
docs/decisions/004-favorites-write-user-scope.md
Normal file
34
docs/decisions/004-favorites-write-user-scope.md
Normal file
@ -0,0 +1,34 @@
|
||||
# ADR-004: Favorites use Gitea star via a new `write:user` scope
|
||||
|
||||
## Status
|
||||
|
||||
Accepted — 2026-08-26
|
||||
|
||||
## Context
|
||||
|
||||
The human product owner asked for a Favorites tab. Two designs were considered: a device-local favorite flag (no server write, no new scope) or Gitea's native star feature (`GET/PUT/DELETE /user/starred/{owner}/{repo}`), which syncs with the Gitea web UI and across devices but requires a write call. ADR-002 explicitly required product approval, an ADR revision, and explicit reauthorization UX before any new scope was added — this ADR is that revision.
|
||||
|
||||
The human product owner chose Gitea-native star after being told explicitly that it requires the app's first write endpoint, that no NAS/Gitea server restart or configuration is needed (starring is a standard, already-live Gitea REST endpoint), and that the only consequence is a fresh login to pick up the new scope.
|
||||
|
||||
## Decision
|
||||
|
||||
- Add `write:user` to the app's requested OAuth scopes. Per Gitea's OAuth2 provider documentation, `write:user` grants read/write access to user-scoped operations including repository subscriptions, which covers starring.
|
||||
- `OAuthClientConfig`'s scope guard is relaxed from "every scope must be read-only" to "every scope must be read-only or appear in an explicit, hard-coded `EXPLICITLY_APPROVED_WRITE_SCOPES` allow-list." Today that list contains exactly `write:user`. Adding any other write scope later requires the same kind of explicit human decision and ADR, not just adding an enum value.
|
||||
- Star/unstar are the only write calls added. No create/edit/merge/close/comment/delete/admin endpoint is introduced by this or any related task (TASK-018).
|
||||
- Star/unstar require no confirmation dialog: both are immediately reversible (tap again to undo) and non-destructive (no data loss, no effect on other users), matching Gitea's and GitHub's own web UI convention and `AGENTS.md`'s confirmation requirement, which is scoped to destructive operations.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- Device-local-only favorites: rejected by the human product owner in favor of cross-device/web parity, despite being the option with zero new scope and zero server interaction.
|
||||
- A generic "allow any write scope" guard relaxation: rejected — kept the allow-list explicit and enumerable so a future write scope cannot be added without deliberately touching this decision again.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Existing signed-in users must log in again once before they can use Favorites; their existing token only carries the old read-only scopes.
|
||||
- If this Gitea instance's OAuth application registration restricts requestable scopes independently of what the client requests (a server-admin setting, not something requiring a restart), the first login attempt with the new scope will surface as an explicit authorization error rather than silently degrading; this is a known, accepted risk to verify on-device rather than something assumed away.
|
||||
- Favorite state is not cached locally; the starred set is always read fresh from Gitea, consistent with the product's existing no-offline-cache posture.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Gitea REST API: `PUT/DELETE /user/starred/{owner}/{repo}`, `GET /user/starred` — standard, already-live endpoints (https://docs.gitea.com/api/1.27.2/).
|
||||
- Gitea OAuth2 provider documentation describes `write:user` as granting "read/write/delete access to user operations, such as updating user repo subscriptions" (https://docs.gitea.com/development/oauth2-provider/).
|
||||
@ -177,3 +177,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl
|
||||
- Known limitations: No physical device was available to verify page navigation and per-row branch loading interactively. Timestamp ties between two branches resolve deterministically but arbitrarily (first match kept).
|
||||
- Next recommended task: TASK-018 (Gitea-star favorites tab), already scoped in `tasks/018-favorites-gitea-star.md`, is next; Codex review of TASK-017 can happen in parallel since it touches non-overlapping paths once merged.
|
||||
- Next owner: Codex for read-only review of TASK-017; Claude continuing to TASK-018.
|
||||
|
||||
## 2026-08-26 — TASK-018: Favorites tab backed by Gitea star
|
||||
|
||||
- Agent and role: Claude, feature implementer, per human product owner priority-1 request in conversation on 2026-08-26 (same request as TASK-017).
|
||||
- Commit/diff: same branch as TASK-017 (`claude/task-017-recent-push-favorites`), committed as a separate commit on top of it; new task file `tasks/018-favorites-gitea-star.md`, ADR `docs/decisions/004-favorites-write-user-scope.md`.
|
||||
- Features implemented: A user can star a repository from the main list and unstar it from a new Favorites tab, which shows only their starred repositories with the same paging/loading/empty/error states as the main list.
|
||||
- How it was implemented: Added `write:user` to the app's requested OAuth scopes and replaced `OAuthClientConfig`'s "every scope must be read-only" guard with "every scope must be read-only or in an explicit approved-write-scope allow-list" (currently exactly `write:user`) — the app's first non-read-only scope and first write endpoint. `HttpTransport` gained bearer-authenticated `put`/`delete`. `GiteaRepositoryBrowserApi` now also implements a new `RepositoryFavorites` domain interface (`listStarred`/`star`/`unstar`) on the same class, reusing its existing repository-mapping code. `RepositoryListViewModel` gained a `RepositoryListMode` (ALL/FAVORITES) carried on its `Content`/`Empty` states; toggling favorite either marks a row `Starred` (ALL mode, confirmed-not-optimistic) or removes it from the current page (FAVORITES mode, after a confirmed unstar). `RepositoryListScreen` gained a tab switcher and a per-row favorite button using plain localized text (no new icon dependency).
|
||||
- Files changed: `core/auth/protocol/OAuthClientConfig.kt`, `core/network/HttpTransport.kt`, `data/repositories/GiteaRepositoryBrowserApi.kt`, new `domain/repository/RepositoryFavorites.kt`, `feature/repositories/RepositoryListViewModel.kt`, `feature/repositories/RepositoryListScreen.kt`, `MainActivity.kt`, `strings_repositories.xml`, associated tests, new `OAuthClientConfigTest.kt`, ADR-004, `tasks/018-favorites-gitea-star.md`.
|
||||
- Checks and results: `assembleDebug testDebugUnitTest lintDebug --rerun-tasks` BUILD SUCCESSFUL — 117 unit tests across 23 suites, 0 failures/errors, 0 lint errors; `git diff --check` clean (CRLF notices only); sensitive-pattern scan clean.
|
||||
- Review findings and resolutions: Self-review narrowed two acceptance criteria from their original draft to match what was actually and deliberately built: favorite-toggle failures use one generic retryable state rather than per-HTTP-status messages (proportionate for a lightweight toggle), and favorite state updates only after a confirmed server response rather than optimistically. Independent Codex review is pending, and this is the first task to also request on-device verification of the new OAuth scope specifically (does the Gitea instance's OAuth app registration accept it without a server-side change).
|
||||
- Known limitations: No physical device was available — the new-scope login path is unverified. Existing signed-in users need one fresh login before Favorites works. Favorite state is never cached locally (always read fresh from Gitea).
|
||||
- Next recommended task: Codex reviews TASK-017 and TASK-018 together (same branch) and verifies the `write:user` login on a physical device before this reaches users; TASK-007 (history UI) remains next in the original roadmap once TASK-009 lands.
|
||||
- Next owner: Codex, for review and device verification.
|
||||
|
||||
102
tasks/018-favorites-gitea-star.md
Normal file
102
tasks/018-favorites-gitea-star.md
Normal file
@ -0,0 +1,102 @@
|
||||
# TASK-018: Favorites tab backed by Gitea star
|
||||
|
||||
Status: implemented; all required checks pass; pending Codex review and device verification
|
||||
Owner: Claude (human product owner priority-1 request, 2026-08-26)
|
||||
Reviewer: Codex
|
||||
|
||||
## Outcome
|
||||
|
||||
A signed-in user can star/unstar a repository from the app, and a dedicated Favorites tab shows only their starred repositories. Favorite state is Gitea's own server-side star, not device-local, so it matches the Gitea web UI and follows the account across devices.
|
||||
|
||||
## Product/security decision record
|
||||
|
||||
- Human product owner explicitly chose Gitea-native star over a device-local-only favorite, after being told this requires the app's first write endpoint (2026-08-26 conversation).
|
||||
- This adds one non-read-only OAuth scope (`write:user`) to `OAuthClientConfig`, whose constructor currently hard-requires `scopes.all { it.isReadOnly }`. That guard is relaxed to allow exactly this scope; it must not become a general-purpose escape hatch for other write scopes without a separate human decision.
|
||||
- Star/unstar is immediately reversible (tap again to unstar) and not destructive (no data loss, no effect on other users' data), so per `AGENTS.md`'s "destructive Gitea operations require explicit UI confirmation," no confirmation dialog is required — this is the same UX convention Gitea's and GitHub's own web UIs use for starring.
|
||||
- No NAS/Gitea server restart or configuration is required: starring is a standard, already-live Gitea REST endpoint. If this Gitea instance's OAuth application registration turns out to restrict requestable scopes (server-admin setting, not a restart), that will surface as an explicit authorization error on first login with the new scope and is recorded as a known limitation, not assumed away.
|
||||
|
||||
## Scope
|
||||
|
||||
- Included:
|
||||
- Add `write:user` to the requested OAuth scopes; relax the `OAuthClientConfig` read-only guard to allow it explicitly (not generically); record the decision in `docs/decisions/`.
|
||||
- Add `GET /user/starred` (paged), `PUT /user/starred/{owner}/{repo}` (star), `DELETE /user/starred/{owner}/{repo}` (unstar) to the Gitea data layer as the app's first write calls, isolated behind a small typed interface.
|
||||
- Add a star/unstar toggle to each repository row (main list and, if present, Favorites tab).
|
||||
- Add a new Favorites tab showing only starred repositories, with the same loading/paging/empty/offline/auth-expired/server-error states as the main list.
|
||||
- Add whatever minimal tab navigation structure is needed to host a Favorites tab alongside the existing repository list (kept minimal; full typed navigation remains a separate future task).
|
||||
- Excluded:
|
||||
- Any other write/mutation capability (no create/edit/merge/delete/admin endpoint).
|
||||
- Offline caching of favorite state (server is the single source of truth; no local persistence of the starred set beyond in-memory UI state).
|
||||
- Device-local-only favorites (explicitly rejected in favor of Gitea star, see decision record above).
|
||||
|
||||
## Writable paths
|
||||
|
||||
- `app/src/main/java/com/ubisam/ubgitea/core/auth/protocol/OAuthClientConfig.kt` (scope set and guard only)
|
||||
- `app/src/main/java/com/ubisam/ubgitea/data/repositories/`
|
||||
- `app/src/main/java/com/ubisam/ubgitea/domain/model/` (additive only)
|
||||
- `app/src/main/java/com/ubisam/ubgitea/domain/repository/`
|
||||
- `app/src/main/java/com/ubisam/ubgitea/domain/usecase/`
|
||||
- `app/src/main/java/com/ubisam/ubgitea/feature/repositories/`
|
||||
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt` (tab wiring only)
|
||||
- `app/src/main/res/values/strings_repositories.xml`
|
||||
- `app/src/test/java/com/ubisam/ubgitea/core/auth/protocol/`
|
||||
- `app/src/test/java/com/ubisam/ubgitea/data/repositories/`
|
||||
- `app/src/test/java/com/ubisam/ubgitea/feature/repositories/`
|
||||
- `docs/decisions/`
|
||||
- `tasks/018-favorites-gitea-star.md`
|
||||
- `docs/development-log.md` (append-only)
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `OAuthClientConfig` accepts `write:user` alongside the existing read-only scopes; the guard is proven exhaustive by asserting every non-read-only `OAuthScope` enum member equals the approved set, so a future write scope added to the enum without approval fails this test (not just a single positive-case check).
|
||||
- [x] Star/unstar call the exact documented Gitea endpoints with correct URL-encoded owner/repo components and the bearer token; a 401 clears the session the same way every other endpoint does (both for a row's favorite toggle and for the row's branch fetch).
|
||||
- [x] Star/unstar failures surface a retryable failed state on the affected row without discarding the rest of the list, and the toggle can be retried. (Narrowed from the original wording: failures are a single generic retryable state rather than a per-HTTP-status message — proportionate for a lightweight toggle action, consistent with how this UI already treats other row-level transient failures. Authentication expiry is the one failure type handled distinctly, at the session level, because it is not really a per-row condition.)
|
||||
- [x] Toggling star updates the row's visual state immediately after a *confirmed* server response (not optimistically before the call completes) so the UI never shows a state the server hasn't actually confirmed; starring from the main list makes the repository appear in the Favorites tab on its next load.
|
||||
- [x] The Favorites tab pages `GET /user/starred` the same way the main list pages repositories (page size and loading pattern from TASK-017), with matching loading/empty/offline/auth-expired/server-error states.
|
||||
- [x] Favorites tab emptiness (no starred repos) has a distinct, localized empty state distinguishable from a loading or error state.
|
||||
- [x] No create/edit/merge/close/comment endpoint is reachable from any new code in this task.
|
||||
- [x] Tests cover: scope-guard exhaustiveness, star/unstar URL and encoding, 401 mapping (both favorite-toggle and branch-fetch paths), generic-failure-then-retry, Favorites mode paging, and unstar removing exactly one row without affecting others.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
- First write endpoint in the app — reviewed explicitly against `AGENTS.md`'s security invariants: no client secret involved, no new token type, no logging of request/response bodies, TLS validation unchanged, no destructive endpoint added.
|
||||
- Scope change is a production OAuth configuration change; human product owner already approved it in conversation (see decision record). Still requires a fresh login to take effect (existing tokens carry only the old scope) — noted as a device-verification item.
|
||||
|
||||
## Required checks
|
||||
|
||||
```text
|
||||
./gradlew assembleDebug
|
||||
./gradlew testDebugUnitTest
|
||||
./gradlew lintDebug
|
||||
```
|
||||
|
||||
## Evidence
|
||||
|
||||
Implemented on branch `claude/task-017-recent-push-favorites` (same branch as TASK-017; committed separately), on top of TASK-017's commit.
|
||||
|
||||
- OAuth: `OAuthScope.WRITE_USER("write:user", isReadOnly = false)` added; `OAuthClientConfig`'s guard changed from "every scope read-only" to "every scope read-only or in `EXPLICITLY_APPROVED_WRITE_SCOPES`" (currently exactly `{WRITE_USER}`). `UBGiteaOAuthDefaults.config` now requests it. Recorded as ADR-004.
|
||||
- Data: `core/network/HttpTransport.kt` gained `put`/`delete` (bearer-authenticated, empty body) alongside the existing `get`/`postForm`. `GiteaRepositoryBrowserApi` now also implements a new `domain/repository/RepositoryFavorites` interface (`listStarred`, `star`, `unstar`) on the *same* class as `GiteaRepositoryBrowser`, reusing its existing repository-JSON mapping (extracted into `mapRepository()`) instead of duplicating it, and a shared `authenticated()` helper replaces three near-identical token-check/try-catch blocks.
|
||||
- Domain: `RepositoryListMode` (ALL/FAVORITES) added. `RepositoryListUiState.Content`/`Empty` carry the active mode. `RepositoryRowUiModel` gained `favoriteAction: FavoriteActionUiState` (Idle/InProgress/Starred/Failed).
|
||||
- ViewModel: `loadRepositoryPage` now takes a `mode` and calls `browserApi.listRepositories` or `favoritesApi.listStarred` accordingly; `showAllRepositories()`/`showFavorites()` switch mode from page 1. `toggleFavorite(row)` stars from ALL mode (row stays, marked `Starred` on confirmed success) or unstars from FAVORITES mode (row is removed from the currently displayed page on confirmed success, not optimistically). A 401 during either a favorite toggle or a row's branch fetch now shares one `clearSessionOnAuthenticationExpired()` helper (previously duplicated inline).
|
||||
- UI: `RepositoryListScreen` gained a `TabRow` (전체 저장소 / 즐겨찾기) shown in both the `Content` and `Empty` states, and each repository row gained a favorite button reflecting `favoriteAction` (no icon library added — plain localized text buttons, consistent with the rest of the app's existing button style, to avoid pulling in a new Compose icons dependency for one feature).
|
||||
- Design choice recorded in acceptance criteria: favorite-toggle failures are one generic retryable state rather than per-HTTP-status messages, and favorite state updates only after a confirmed server response rather than optimistically — both narrower than the task's original draft wording, adjusted to match what was actually built and why.
|
||||
|
||||
Checks (JDK 21 via `D:\AndroidStudio\jbr`, Android SDK at `D:\Android\WhatToEat\sdk`):
|
||||
|
||||
```text
|
||||
./gradlew assembleDebug testDebugUnitTest lintDebug --rerun-tasks → BUILD SUCCESSFUL
|
||||
- 117 unit tests across 23 suites, 0 failures/errors
|
||||
- 0 lint errors
|
||||
git diff --check → only CRLF-normalization notices
|
||||
sensitive-pattern scan of changed files → no matches
|
||||
```
|
||||
|
||||
Known limitations:
|
||||
- No physical device was available. In particular, the real-world question of whether this Gitea instance's OAuth application registration independently restricts requestable scopes (a server-admin setting) can only be confirmed by an actual login attempt with the new scope on-device — this is called out explicitly rather than assumed safe.
|
||||
- Existing signed-in users need one fresh login before Favorites works, since their stored token only carries the previously-approved read-only scopes.
|
||||
- Favorite state is always read fresh from Gitea (no local cache), consistent with the product's current no-offline-cache posture; this means the Favorites tab requires network access even to show a previously-seen list.
|
||||
|
||||
No open P0-P3 findings from self-review against the acceptance criteria. Requesting Codex read-only review; on-device scope/login verification is also requested since this is the first write endpoint and the first new OAuth scope since ADR-002.
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet.
|
||||
Loading…
x
Reference in New Issue
Block a user