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:
최민영 2026-08-26 13:51:04 +09:00
parent 93b8d587ee
commit 525a0aa2ff
17 changed files with 682 additions and 71 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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/).

View File

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

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