From bb866b6488daa49b1cfc956e0a2f0a06fc942989 Mon Sep 17 00:00:00 2001 From: Sayanth Rock Date: Thu, 23 Jul 2026 13:49:10 +0530 Subject: [PATCH 01/20] Add configurable backend endpoint --- app/build.gradle.kts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c8490d62..9c506dd2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,6 +23,11 @@ val githubClientId = sequenceOf( System.getenv("GITHUB_CLIENT_ID"), bundledGitHubClientId ).firstOrNull { !it.isNullOrBlank() }.orEmpty() +val backendBaseUrl = sequenceOf( + localProperties.getProperty("GITHUB_ROCK_BACKEND_URL"), + System.getenv("GITHUB_ROCK_BACKEND_URL"), + providers.gradleProperty("GITHUB_ROCK_BACKEND_URL").orNull +).firstOrNull { !it.isNullOrBlank() }.orEmpty() val configuredVersionName = providers.gradleProperty("GITHUB_ROCK_VERSION_NAME").orNull ?.trim()?.takeIf(String::isNotBlank) ?: "0.1.0" val configuredVersionCode = providers.gradleProperty("GITHUB_ROCK_VERSION_CODE").orNull @@ -46,6 +51,11 @@ android { "GITHUB_CLIENT_ID", quotedBuildConfig(githubClientId) ) + buildConfigField( + "String", + "BACKEND_BASE_URL", + quotedBuildConfig(backendBaseUrl) + ) buildConfigField("String", "GITHUB_API_VERSION", "\"2022-11-28\"") } From dc3593e17decbd7c4ad33b1e3009abdff1449882 Mon Sep 17 00:00:00 2001 From: Sayanth Rock Date: Thu, 23 Jul 2026 13:49:45 +0530 Subject: [PATCH 02/20] Add backend API contract --- .../core/network/GitHubRockBackendApi.kt | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 app/src/main/java/com/sayanthrock/githubrock/core/network/GitHubRockBackendApi.kt diff --git a/app/src/main/java/com/sayanthrock/githubrock/core/network/GitHubRockBackendApi.kt b/app/src/main/java/com/sayanthrock/githubrock/core/network/GitHubRockBackendApi.kt new file mode 100644 index 00000000..39271506 --- /dev/null +++ b/app/src/main/java/com/sayanthrock/githubrock/core/network/GitHubRockBackendApi.kt @@ -0,0 +1,71 @@ +package com.sayanthrock.githubrock.core.network + +import com.sayanthrock.githubrock.core.model.DeviceCodeResponse +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST + +@Serializable +data class BackendHealthResponse( + val status: String, + val version: String, + val postgres: String, + val redis: String, + val meilisearch: String, + val timestamp: String, +) + +@Serializable +data class BackendPublicConfigResponse( + val apiVersion: String = "v1", + val minSupportedAppVersion: String, + val latestAppVersion: String, + val maintenanceMode: Boolean, + val features: Map = emptyMap(), +) + +@Serializable +data class BackendDevicePollRequest( + @SerialName("device_code") val deviceCode: String, +) + +@Serializable +data class BackendTokenRefreshRequest( + @SerialName("refresh_token") val refreshToken: String, +) + +@Serializable +data class BackendDeviceTokenResponse( + val state: String, + @SerialName("access_token") val accessToken: String? = null, + @SerialName("token_type") val tokenType: String? = null, + val scope: String? = null, + @SerialName("expires_in") val expiresIn: Long? = null, + @SerialName("refresh_token") val refreshToken: String? = null, + @SerialName("refresh_token_expires_in") val refreshTokenExpiresIn: Long? = null, + val message: String? = null, + val interval: Int? = null, +) + +interface GitHubRockBackendApi { + @GET("v1/health") + suspend fun health(): BackendHealthResponse + + @GET("v1/config") + suspend fun config(): BackendPublicConfigResponse + + @POST("v1/auth/device/start") + suspend fun startDeviceFlow(): DeviceCodeResponse + + @POST("v1/auth/device/poll") + suspend fun pollDeviceFlow( + @Body request: BackendDevicePollRequest, + ): BackendDeviceTokenResponse + + @POST("v1/auth/device/refresh") + suspend fun refreshToken( + @Body request: BackendTokenRefreshRequest, + ): BackendDeviceTokenResponse +} From 13063465c1dc17c5ff2fc0466b9761142aa3820d Mon Sep 17 00:00:00 2001 From: Sayanth Rock Date: Thu, 23 Jul 2026 13:50:31 +0530 Subject: [PATCH 03/20] Add runtime backend gateway --- .../githubrock/data/backend/BackendGateway.kt | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 app/src/main/java/com/sayanthrock/githubrock/data/backend/BackendGateway.kt diff --git a/app/src/main/java/com/sayanthrock/githubrock/data/backend/BackendGateway.kt b/app/src/main/java/com/sayanthrock/githubrock/data/backend/BackendGateway.kt new file mode 100644 index 00000000..06a30933 --- /dev/null +++ b/app/src/main/java/com/sayanthrock/githubrock/data/backend/BackendGateway.kt @@ -0,0 +1,179 @@ +package com.sayanthrock.githubrock.data.backend + +import android.content.Context +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import com.sayanthrock.githubrock.BuildConfig +import com.sayanthrock.githubrock.core.model.DeviceCodeResponse +import com.sayanthrock.githubrock.core.model.DeviceTokenResponse +import com.sayanthrock.githubrock.core.network.BackendDevicePollRequest +import com.sayanthrock.githubrock.core.network.BackendDeviceTokenResponse +import com.sayanthrock.githubrock.core.network.BackendHealthResponse +import com.sayanthrock.githubrock.core.network.BackendPublicConfigResponse +import com.sayanthrock.githubrock.core.network.BackendTokenRefreshRequest +import com.sayanthrock.githubrock.core.network.GitHubRockBackendApi +import dagger.hilt.android.qualifiers.ApplicationContext +import java.net.URI +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import retrofit2.Retrofit + +internal data class BackendConnectionSnapshot( + val endpoint: String, + val health: BackendHealthResponse, + val config: BackendPublicConfigResponse, +) + +@Singleton +class BackendEndpointStore @Inject constructor( + @ApplicationContext context: Context, +) { + private val preferences = context.applicationContext.getSharedPreferences( + PREFERENCES_NAME, + Context.MODE_PRIVATE, + ) + + fun endpoint(): String? = normalizedBackendBaseUrl( + preferences.getString(KEY_ENDPOINT, null) ?: BuildConfig.BACKEND_BASE_URL, + ) + + fun save(rawEndpoint: String): String { + val endpoint = requireNotNull(normalizedBackendBaseUrl(rawEndpoint)) { + "Enter the HTTPS URL of the deployed GitHub Rock Backend." + } + preferences.edit().putString(KEY_ENDPOINT, endpoint).apply() + return endpoint + } + + fun clear() { + preferences.edit().remove(KEY_ENDPOINT).apply() + } + + private companion object { + const val PREFERENCES_NAME = "github_rock_backend" + const val KEY_ENDPOINT = "base_url" + } +} + +@Singleton +class BackendGateway @Inject constructor( + private val json: Json, + @Named("authClient") private val client: OkHttpClient, + private val endpointStore: BackendEndpointStore, +) { + @Volatile private var cachedEndpoint: String? = null + @Volatile private var cachedApi: GitHubRockBackendApi? = null + + val isConfigured: Boolean get() = endpointStore.endpoint() != null + val configuredEndpoint: String? get() = endpointStore.endpoint() + + suspend fun check(rawEndpoint: String? = null): BackendConnectionSnapshot { + val endpoint = rawEndpoint?.let(::requireBackendBaseUrl) + ?: requireNotNull(endpointStore.endpoint()) { "GitHub Rock Backend is not connected." } + val api = api(endpoint) + return BackendConnectionSnapshot( + endpoint = endpoint, + health = api.health(), + config = api.config(), + ) + } + + suspend fun saveAndCheck(rawEndpoint: String): BackendConnectionSnapshot { + val endpoint = requireBackendBaseUrl(rawEndpoint) + val snapshot = check(endpoint) + endpointStore.save(endpoint) + return snapshot + } + + fun disconnect() { + endpointStore.clear() + synchronized(this) { + cachedEndpoint = null + cachedApi = null + } + } + + suspend fun startDeviceFlow(): DeviceCodeResponse = + apiForConfiguredEndpoint().startDeviceFlow() + + suspend fun pollDeviceFlow(deviceCode: String): DeviceTokenResponse = + apiForConfiguredEndpoint() + .pollDeviceFlow(BackendDevicePollRequest(deviceCode)) + .toDeviceTokenResponse() + + suspend fun refreshToken(refreshToken: String): DeviceTokenResponse = + apiForConfiguredEndpoint() + .refreshToken(BackendTokenRefreshRequest(refreshToken)) + .toDeviceTokenResponse() + + private fun apiForConfiguredEndpoint(): GitHubRockBackendApi { + val endpoint = requireNotNull(endpointStore.endpoint()) { + "GitHub Rock Backend is not connected." + } + return api(endpoint) + } + + private fun api(endpoint: String): GitHubRockBackendApi { + cachedApi?.takeIf { cachedEndpoint == endpoint }?.let { return it } + return synchronized(this) { + cachedApi?.takeIf { cachedEndpoint == endpoint } ?: Retrofit.Builder() + .baseUrl(endpoint) + .client(client) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(GitHubRockBackendApi::class.java) + .also { + cachedEndpoint = endpoint + cachedApi = it + } + } + } +} + +internal fun normalizedBackendBaseUrl(raw: String?): String? { + val candidate = raw?.trim()?.trimEnd('/').orEmpty() + if (candidate.isBlank()) return null + val uri = runCatching { URI(candidate) }.getOrNull() ?: return null + if (!uri.scheme.equals("https", ignoreCase = true)) return null + if (uri.host.isNullOrBlank() || uri.userInfo != null || uri.query != null || uri.fragment != null) return null + return "$candidate/" +} + +internal fun requireBackendBaseUrl(raw: String): String = + requireNotNull(normalizedBackendBaseUrl(raw)) { + "Use a valid HTTPS backend URL without query parameters or credentials." + } + +internal fun BackendDeviceTokenResponse.toDeviceTokenResponse(): DeviceTokenResponse = when (state) { + "authorized" -> DeviceTokenResponse( + accessToken = accessToken, + tokenType = tokenType, + scope = scope, + expiresIn = expiresIn, + refreshToken = refreshToken, + refreshTokenExpiresIn = refreshTokenExpiresIn, + ) + "pending" -> DeviceTokenResponse( + error = "authorization_pending", + errorDescription = message, + ) + "slow_down" -> DeviceTokenResponse( + error = "slow_down", + errorDescription = message, + ) + "expired" -> DeviceTokenResponse( + error = "expired_token", + errorDescription = message, + ) + "denied" -> DeviceTokenResponse( + error = "access_denied", + errorDescription = message, + ) + else -> DeviceTokenResponse( + error = "backend_error", + errorDescription = message ?: "GitHub Rock Backend could not complete authentication.", + ) +} From 4948f5f5687c2763b024736c3a68203e58b199e8 Mon Sep 17 00:00:00 2001 From: Sayanth Rock Date: Thu, 23 Jul 2026 13:51:26 +0530 Subject: [PATCH 04/20] Connect Device Flow to backend with fallback --- .../data/auth/DeviceFlowAuthRepository.kt | 118 ++++++++++++++---- 1 file changed, 91 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/sayanthrock/githubrock/data/auth/DeviceFlowAuthRepository.kt b/app/src/main/java/com/sayanthrock/githubrock/data/auth/DeviceFlowAuthRepository.kt index acb5ba7b..0641d3a2 100644 --- a/app/src/main/java/com/sayanthrock/githubrock/data/auth/DeviceFlowAuthRepository.kt +++ b/app/src/main/java/com/sayanthrock/githubrock/data/auth/DeviceFlowAuthRepository.kt @@ -6,38 +6,68 @@ import com.sayanthrock.githubrock.core.model.DeviceTokenResponse import com.sayanthrock.githubrock.core.network.GitHubAuthApi import com.sayanthrock.githubrock.core.security.StoredTokens import com.sayanthrock.githubrock.core.security.TokenStore -import kotlinx.coroutines.delay -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import com.sayanthrock.githubrock.data.backend.BackendGateway import java.time.Instant import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock class DeviceFlowException(message: String) : IllegalStateException(message) internal const val GITHUB_OAUTH_SCOPES = "repo workflow read:user user:email read:org notifications user:follow" +private enum class DeviceFlowTransport { Backend, DirectGitHub } + @Singleton class DeviceFlowAuthRepository @Inject constructor( private val api: GitHubAuthApi, - private val tokenStore: TokenStore + private val tokenStore: TokenStore, + private val backendGateway: BackendGateway, ) { - val isConfigured: Boolean get() = BuildConfig.GITHUB_CLIENT_ID.isNotBlank() + val isConfigured: Boolean + get() = backendGateway.isConfigured || BuildConfig.GITHUB_CLIENT_ID.isNotBlank() val hasSession: Boolean get() = tokenStore.read() != null private val pollMutex = Mutex() private var lastTokenRequestAtMillis = 0L private var requiredIntervalSeconds = MINIMUM_POLL_INTERVAL_SECONDS + @Volatile private var activeTransport = DeviceFlowTransport.DirectGitHub suspend fun begin(): DeviceCodeResponse { - check(isConfigured) { "Add GITHUB_CLIENT_ID to local.properties before using GitHub login." } - return api.requestDeviceCode( - clientId = BuildConfig.GITHUB_CLIENT_ID, - scope = GITHUB_OAUTH_SCOPES - ).also { device -> + check(isConfigured) { + "Connect GitHub Rock Backend or add GITHUB_CLIENT_ID before using GitHub login." + } + + val device = if (backendGateway.isConfigured) { + try { + backendGateway.startDeviceFlow().also { + activeTransport = DeviceFlowTransport.Backend + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (backendFailure: Exception) { + if (BuildConfig.GITHUB_CLIENT_ID.isBlank()) { + throw DeviceFlowException( + "GitHub Rock Backend could not start login: ${backendFailure.message ?: "connection failed"}" + ) + } + requestDirectDeviceCode().also { + activeTransport = DeviceFlowTransport.DirectGitHub + } + } + } else { + requestDirectDeviceCode().also { + activeTransport = DeviceFlowTransport.DirectGitHub + } + } + + return device.also { pollMutex.withLock { - requiredIntervalSeconds = device.interval.coerceAtLeast(MINIMUM_POLL_INTERVAL_SECONDS) + requiredIntervalSeconds = it.interval.coerceAtLeast(MINIMUM_POLL_INTERVAL_SECONDS) lastTokenRequestAtMillis = elapsedRealtimeMillis() } } @@ -59,9 +89,11 @@ class DeviceFlowAuthRepository @Inject constructor( "Device Flow is disabled for this GitHub OAuth App. Enable it in the OAuth App settings." ) "incorrect_client_credentials" -> throw DeviceFlowException( - "This build has an invalid GitHub OAuth client ID." + "This build or backend has an invalid GitHub OAuth client configuration." + ) + else -> throw DeviceFlowException( + response.errorDescription ?: "GitHub authentication failed." ) - else -> throw DeviceFlowException(response.errorDescription ?: "GitHub authentication failed.") } } throw DeviceFlowException("The device code expired. Start login again.") @@ -74,10 +106,8 @@ class DeviceFlowAuthRepository @Inject constructor( if (expiresAt > now + SESSION_EXPIRY_SKEW_SECONDS) return true if (!isRefreshTokenUsable(stored.refreshToken, stored.refreshExpiresAtEpochSeconds, now)) return false - val response = api.refreshToken( - clientId = BuildConfig.GITHUB_CLIENT_ID, - refreshToken = requireNotNull(stored.refreshToken) - ) + val refreshToken = requireNotNull(stored.refreshToken) + val response = refreshThroughBackendOrGitHub(refreshToken) val token = response.accessToken ?: return false tokenStore.save(response.toStoredTokens(token)) return true @@ -85,23 +115,57 @@ class DeviceFlowAuthRepository @Inject constructor( fun logout() = tokenStore.clear() + private suspend fun requestDirectDeviceCode(): DeviceCodeResponse { + check(BuildConfig.GITHUB_CLIENT_ID.isNotBlank()) { + "Add GITHUB_CLIENT_ID to local.properties before using direct GitHub login." + } + return api.requestDeviceCode( + clientId = BuildConfig.GITHUB_CLIENT_ID, + scope = GITHUB_OAUTH_SCOPES, + ) + } + + private suspend fun refreshThroughBackendOrGitHub(refreshToken: String): DeviceTokenResponse { + if (backendGateway.isConfigured) { + try { + val backendResponse = backendGateway.refreshToken(refreshToken) + if (!backendResponse.accessToken.isNullOrBlank()) return backendResponse + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + // A configured backend is preferred, but direct GitHub remains the availability fallback. + } + } + if (BuildConfig.GITHUB_CLIENT_ID.isBlank()) return DeviceTokenResponse(error = "refresh_unavailable") + return api.refreshToken( + clientId = BuildConfig.GITHUB_CLIENT_ID, + refreshToken = refreshToken, + ) + } + private suspend fun requestTokenAtAllowedInterval(device: DeviceCodeResponse): DeviceTokenResponse = pollMutex.withLock { val now = elapsedRealtimeMillis() val remainingDelay = remainingPollDelayMillis( lastRequestAtMillis = lastTokenRequestAtMillis, nowMillis = now, - intervalSeconds = requiredIntervalSeconds + intervalSeconds = requiredIntervalSeconds, ) if (remainingDelay > 0L) delay(remainingDelay) lastTokenRequestAtMillis = elapsedRealtimeMillis() - api.requestToken(BuildConfig.GITHUB_CLIENT_ID, device.deviceCode).also { response -> - requiredIntervalSeconds = nextPollIntervalSeconds( - currentIntervalSeconds = requiredIntervalSeconds, - error = response.error, - slowDownIncrementSeconds = SLOW_DOWN_INCREMENT_SECONDS + val response = when (activeTransport) { + DeviceFlowTransport.Backend -> backendGateway.pollDeviceFlow(device.deviceCode) + DeviceFlowTransport.DirectGitHub -> api.requestToken( + BuildConfig.GITHUB_CLIENT_ID, + device.deviceCode, ) } + requiredIntervalSeconds = nextPollIntervalSeconds( + currentIntervalSeconds = requiredIntervalSeconds, + error = response.error, + slowDownIncrementSeconds = SLOW_DOWN_INCREMENT_SECONDS, + ) + response } private fun DeviceTokenResponse.toStoredTokens(token: String): StoredTokens { @@ -110,7 +174,7 @@ class DeviceFlowAuthRepository @Inject constructor( accessToken = token, refreshToken = refreshToken, accessExpiresAtEpochSeconds = expiresIn?.let { now + it }, - refreshExpiresAtEpochSeconds = refreshTokenExpiresIn?.let { now + it } + refreshExpiresAtEpochSeconds = refreshTokenExpiresIn?.let { now + it }, ) } @@ -124,7 +188,7 @@ class DeviceFlowAuthRepository @Inject constructor( internal fun remainingPollDelayMillis( lastRequestAtMillis: Long, nowMillis: Long, - intervalSeconds: Int + intervalSeconds: Int, ): Long { val intervalMillis = intervalSeconds.coerceAtLeast(0) * 1_000L if (lastRequestAtMillis <= 0L) return intervalMillis @@ -136,7 +200,7 @@ private fun elapsedRealtimeMillis(): Long = System.nanoTime() / 1_000_000L internal fun nextPollIntervalSeconds( currentIntervalSeconds: Int, error: String?, - slowDownIncrementSeconds: Int = 5 + slowDownIncrementSeconds: Int = 5, ): Int = if (error == "slow_down") { currentIntervalSeconds + slowDownIncrementSeconds } else { @@ -146,6 +210,6 @@ internal fun nextPollIntervalSeconds( internal fun isRefreshTokenUsable( refreshToken: String?, refreshExpiresAtEpochSeconds: Long?, - nowEpochSeconds: Long + nowEpochSeconds: Long, ): Boolean = !refreshToken.isNullOrBlank() && (refreshExpiresAtEpochSeconds == null || refreshExpiresAtEpochSeconds > nowEpochSeconds + 60L) From 8dd6a24d71deeecad108028049fba752351636d6 Mon Sep 17 00:00:00 2001 From: Sayanth Rock Date: Thu, 23 Jul 2026 13:52:36 +0530 Subject: [PATCH 05/20] Add native backend connection centre --- .../ui/screens/BackendConnectionScreen.kt | 373 ++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 app/src/main/java/com/sayanthrock/githubrock/ui/screens/BackendConnectionScreen.kt diff --git a/app/src/main/java/com/sayanthrock/githubrock/ui/screens/BackendConnectionScreen.kt b/app/src/main/java/com/sayanthrock/githubrock/ui/screens/BackendConnectionScreen.kt new file mode 100644 index 00000000..fac1e832 --- /dev/null +++ b/app/src/main/java/com/sayanthrock/githubrock/ui/screens/BackendConnectionScreen.kt @@ -0,0 +1,373 @@ +package com.sayanthrock.githubrock.ui.screens + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.LinkOff +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Security +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.sayanthrock.githubrock.data.backend.BackendConnectionSnapshot +import com.sayanthrock.githubrock.data.backend.BackendGateway +import com.sayanthrock.githubrock.ui.components.GlassCard +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class BackendConnectionUiState( + val endpoint: String = "", + val snapshot: BackendConnectionSnapshot? = null, + val loading: Boolean = false, + val error: String? = null, +) + +@HiltViewModel +class BackendConnectionViewModel @Inject constructor( + private val gateway: BackendGateway, +) : ViewModel() { + private val _state = MutableStateFlow( + BackendConnectionUiState(endpoint = gateway.configuredEndpoint.orEmpty()) + ) + val state: StateFlow = _state.asStateFlow() + + init { + if (gateway.isConfigured) refresh() + } + + fun connect(rawEndpoint: String) { + viewModelScope.launch { + _state.update { it.copy(loading = true, error = null, endpoint = rawEndpoint.trim()) } + try { + val snapshot = gateway.saveAndCheck(rawEndpoint) + _state.value = BackendConnectionUiState( + endpoint = snapshot.endpoint, + snapshot = snapshot, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (problem: Exception) { + _state.update { + it.copy( + loading = false, + snapshot = null, + error = problem.message?.takeIf(String::isNotBlank) + ?: "The backend could not be reached.", + ) + } + } + } + } + + fun refresh() { + viewModelScope.launch { + _state.update { it.copy(loading = true, error = null) } + try { + val snapshot = gateway.check() + _state.value = BackendConnectionUiState( + endpoint = snapshot.endpoint, + snapshot = snapshot, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (problem: Exception) { + _state.update { + it.copy( + loading = false, + snapshot = null, + error = problem.message?.takeIf(String::isNotBlank) + ?: "The backend could not be reached.", + ) + } + } + } + } + + fun disconnect() { + gateway.disconnect() + _state.value = BackendConnectionUiState() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BackendConnectionScreen( + onBack: () -> Unit, + viewModel: BackendConnectionViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + var endpointText by remember { mutableStateOf(state.endpoint) } + + LaunchedEffect(state.endpoint) { + if (state.endpoint.isNotBlank()) endpointText = state.endpoint + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Backend connection") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + ), + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(16.dp, 12.dp, 16.dp, 48.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + BackendStatusCard(state) + } + item { + GlassCard { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "Server URL", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Black, + ) + Text( + "Enter the deployed HTTPS address for Sayanthrock-Developer/GitHub-Rock-Backend.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = endpointText, + onValueChange = { endpointText = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("https://api.example.com") }, + enabled = !state.loading, + ) + Button( + onClick = { viewModel.connect(endpointText) }, + enabled = endpointText.isNotBlank() && !state.loading, + modifier = Modifier.fillMaxWidth().height(54.dp), + ) { + if (state.loading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } else { + Icon(Icons.Default.Dns, contentDescription = null) + } + Spacer(Modifier.width(8.dp)) + Text("Save and test connection", fontWeight = FontWeight.Bold) + } + } + } + } + + state.snapshot?.let { snapshot -> + item { + BackendRuntimeCard(snapshot) + } + item { + BackendFeatureCard(snapshot) + } + } + + item { + GlassCard { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + Icon( + Icons.Default.Security, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Safe fallback", fontWeight = FontWeight.Bold) + Text( + "GitHub Rock uses the backend for Device Flow and token refresh when available. Direct GitHub API access remains enabled so repository work continues during backend maintenance.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + + if (state.endpoint.isNotBlank()) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + OutlinedButton( + onClick = viewModel::refresh, + enabled = !state.loading, + modifier = Modifier.weight(1f).height(52.dp), + ) { + Icon(Icons.Default.Refresh, contentDescription = null) + Spacer(Modifier.width(7.dp)) + Text("Retest") + } + TextButton( + onClick = viewModel::disconnect, + enabled = !state.loading, + modifier = Modifier.weight(1f).height(52.dp), + ) { + Icon(Icons.Default.LinkOff, contentDescription = null) + Spacer(Modifier.width(7.dp)) + Text("Disconnect") + } + } + } + } + } + } +} + +@Composable +private fun BackendStatusCard(state: BackendConnectionUiState) { + val connected = state.snapshot != null + val accent = if (connected) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error + Surface( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge, + color = accent.copy(alpha = .08f), + border = BorderStroke(1.dp, accent.copy(alpha = .28f)), + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (connected) Icons.Default.CheckCircle else Icons.Default.CloudOff, + contentDescription = null, + tint = accent, + modifier = Modifier.size(30.dp), + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + when { + state.loading -> "Checking backend…" + connected -> "Backend connected" + state.endpoint.isBlank() -> "Backend not configured" + else -> "Backend unavailable" + }, + fontWeight = FontWeight.Black, + color = accent, + ) + Text( + state.error ?: state.snapshot?.endpoint ?: "Add a deployed HTTPS endpoint to connect the app.", + maxLines = 3, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} + +@Composable +private fun BackendRuntimeCard(snapshot: BackendConnectionSnapshot) { + GlassCard { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Runtime health", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Black) + BackendValueRow("Backend", "${snapshot.health.status} · ${snapshot.health.version}") + BackendValueRow("PostgreSQL", snapshot.health.postgres) + BackendValueRow("Redis", snapshot.health.redis) + BackendValueRow("Meilisearch", snapshot.health.meilisearch) + BackendValueRow("Checked", snapshot.health.timestamp) + } + } +} + +@Composable +private fun BackendFeatureCard(snapshot: BackendConnectionSnapshot) { + GlassCard { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Mobile contract", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Black) + BackendValueRow("API", snapshot.config.apiVersion) + BackendValueRow("Minimum app", snapshot.config.minSupportedAppVersion) + BackendValueRow("Latest app", snapshot.config.latestAppVersion) + BackendValueRow("Maintenance", if (snapshot.config.maintenanceMode) "Enabled" else "Off") + HorizontalDivider() + snapshot.config.features.toSortedMap().forEach { (feature, enabled) -> + BackendValueRow(feature, if (enabled) "Available" else "Not available") + } + } + } +} + +@Composable +private fun BackendValueRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + label, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + value, + modifier = Modifier.weight(1f), + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} From 30a0bec7b3c194f39d93e2ce7e22ec214bf1d167 Mon Sep 17 00:00:00 2001 From: Sayanth Rock Date: Thu, 23 Jul 2026 13:53:18 +0530 Subject: [PATCH 06/20] Expose backend connection in app information --- .../ui/screens/AppInformationScreen.kt | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/sayanthrock/githubrock/ui/screens/AppInformationScreen.kt b/app/src/main/java/com/sayanthrock/githubrock/ui/screens/AppInformationScreen.kt index f4c5319c..a4c6db3c 100644 --- a/app/src/main/java/com/sayanthrock/githubrock/ui/screens/AppInformationScreen.kt +++ b/app/src/main/java/com/sayanthrock/githubrock/ui/screens/AppInformationScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.Security @@ -29,10 +30,10 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -49,16 +50,24 @@ fun AppInformationScreen(onBack: () -> Unit) { val context = LocalContext.current val information = remember(context) { AppInformationProvider.read(context) } var showCapabilities by rememberSaveable { mutableStateOf(false) } + var showBackend by rememberSaveable { mutableStateOf(false) } - if (showCapabilities) { - AndroidCapabilityCenterScreen(onBack = { showCapabilities = false }) - return + when { + showCapabilities -> { + AndroidCapabilityCenterScreen(onBack = { showCapabilities = false }) + return + } + showBackend -> { + BackendConnectionScreen(onBack = { showBackend = false }) + return + } } AppInformationContent( information = information, onBack = onBack, onOpenCapabilities = { showCapabilities = true }, + onOpenBackend = { showBackend = true }, onOpenSystemSettings = { context.startActivity( Intent( @@ -76,6 +85,7 @@ fun AppInformationContent( information: AppInformation, onBack: () -> Unit, onOpenCapabilities: () -> Unit, + onOpenBackend: () -> Unit, onOpenSystemSettings: () -> Unit ) { Scaffold( @@ -97,7 +107,7 @@ fun AppInformationContent( item { StandardScreenHeader( title = information.appName, - subtitle = "Application, Android SDK, device, installation, and permission details" + subtitle = "Application, backend, Android SDK, device, installation, and permission details" ) } item { StandardSectionHeader("Application") } @@ -133,6 +143,16 @@ fun AppInformationContent( ) ) } + item { + OutlinedButton( + onClick = onOpenBackend, + modifier = Modifier.fillMaxWidth().height(52.dp) + ) { + Icon(Icons.Default.Dns, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("GitHub Rock Backend connection", fontWeight = FontWeight.Bold) + } + } item { OutlinedButton( onClick = onOpenCapabilities, From fadafceedae09aefb9e86b2db1da05d87cde1372 Mon Sep 17 00:00:00 2001 From: Sayanth Rock Date: Thu, 23 Jul 2026 13:54:09 +0530 Subject: [PATCH 07/20] Expose backend connection snapshot to UI --- .../com/sayanthrock/githubrock/data/backend/BackendGateway.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/sayanthrock/githubrock/data/backend/BackendGateway.kt b/app/src/main/java/com/sayanthrock/githubrock/data/backend/BackendGateway.kt index 06a30933..65d93e8d 100644 --- a/app/src/main/java/com/sayanthrock/githubrock/data/backend/BackendGateway.kt +++ b/app/src/main/java/com/sayanthrock/githubrock/data/backend/BackendGateway.kt @@ -21,7 +21,7 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import retrofit2.Retrofit -internal data class BackendConnectionSnapshot( +data class BackendConnectionSnapshot( val endpoint: String, val health: BackendHealthResponse, val config: BackendPublicConfigResponse, From d746868d92a3ebe663073feecb79c6c779d5bb38 Mon Sep 17 00:00:00 2001 From: Sayanth Rock Date: Thu, 23 Jul 2026 13:54:29 +0530 Subject: [PATCH 08/20] Test backend connection entry point --- .../java/com/sayanthrock/githubrock/AppInformationScreenTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/androidTest/java/com/sayanthrock/githubrock/AppInformationScreenTest.kt b/app/src/androidTest/java/com/sayanthrock/githubrock/AppInformationScreenTest.kt index 98391834..9f81f10b 100644 --- a/app/src/androidTest/java/com/sayanthrock/githubrock/AppInformationScreenTest.kt +++ b/app/src/androidTest/java/com/sayanthrock/githubrock/AppInformationScreenTest.kt @@ -36,6 +36,7 @@ class AppInformationScreenTest { ), onBack = {}, onOpenCapabilities = {}, + onOpenBackend = {}, onOpenSystemSettings = {} ) } @@ -44,6 +45,7 @@ class AppInformationScreenTest { compose.onNodeWithText("SDK information").performScrollTo().assertIsDisplayed() compose.onNodeWithText("Target Android").performScrollTo().assertIsDisplayed() compose.onNodeWithText("API 36", useUnmergedTree = true).performScrollTo().assertIsDisplayed() + compose.onNodeWithText("GitHub Rock Backend connection").performScrollTo().assertIsDisplayed() compose.onNodeWithText("Android capabilities & permissions").performScrollTo().assertIsDisplayed() compose.onNodeWithText("Open Android app settings").performScrollTo().assertIsDisplayed() } From b7ec77dd5bd39302cd222389113dc3622015ec85 Mon Sep 17 00:00:00 2001 From: Sayanth Rock Date: Thu, 23 Jul 2026 13:54:54 +0530 Subject: [PATCH 09/20] Test Android backend contract --- .../githubrock/BackendContractTest.kt | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 app/src/test/java/com/sayanthrock/githubrock/BackendContractTest.kt diff --git a/app/src/test/java/com/sayanthrock/githubrock/BackendContractTest.kt b/app/src/test/java/com/sayanthrock/githubrock/BackendContractTest.kt new file mode 100644 index 00000000..ede4b508 --- /dev/null +++ b/app/src/test/java/com/sayanthrock/githubrock/BackendContractTest.kt @@ -0,0 +1,61 @@ +package com.sayanthrock.githubrock + +import com.sayanthrock.githubrock.core.network.BackendDeviceTokenResponse +import com.sayanthrock.githubrock.data.backend.normalizedBackendBaseUrl +import com.sayanthrock.githubrock.data.backend.toDeviceTokenResponse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class BackendContractTest { + @Test fun endpointPolicyNormalizesHttpsAndRejectsUnsafeUrls() { + assertEquals( + "https://api.sayanthrock.com/", + normalizedBackendBaseUrl(" https://api.sayanthrock.com/// ") + ) + assertEquals( + "https://example.com/github-rock/", + normalizedBackendBaseUrl("https://example.com/github-rock") + ) + assertNull(normalizedBackendBaseUrl("http://api.example.com")) + assertNull(normalizedBackendBaseUrl("https://user:secret@example.com")) + assertNull(normalizedBackendBaseUrl("https://example.com?token=secret")) + assertNull(normalizedBackendBaseUrl("")) + } + + @Test fun backendAuthorizationPreservesTokenExpiryAndRefreshData() { + val token = BackendDeviceTokenResponse( + state = "authorized", + accessToken = "access", + tokenType = "bearer", + scope = "repo workflow", + expiresIn = 28_800L, + refreshToken = "refresh", + refreshTokenExpiresIn = 15_811_200L, + ).toDeviceTokenResponse() + + assertEquals("access", token.accessToken) + assertEquals("refresh", token.refreshToken) + assertEquals(28_800L, token.expiresIn) + assertEquals(15_811_200L, token.refreshTokenExpiresIn) + } + + @Test fun backendPollingStatesMapToExistingDeviceFlowErrors() { + assertEquals( + "authorization_pending", + BackendDeviceTokenResponse(state = "pending").toDeviceTokenResponse().error, + ) + assertEquals( + "slow_down", + BackendDeviceTokenResponse(state = "slow_down").toDeviceTokenResponse().error, + ) + assertEquals( + "expired_token", + BackendDeviceTokenResponse(state = "expired").toDeviceTokenResponse().error, + ) + assertEquals( + "access_denied", + BackendDeviceTokenResponse(state = "denied").toDeviceTokenResponse().error, + ) + } +} From 90f6220a812e44814fefeec978c2ed59ff9c1016 Mon Sep 17 00:00:00 2001 From: Sayanth Rock Date: Thu, 23 Jul 2026 13:56:23 +0530 Subject: [PATCH 10/20] Inject backend URL into Android CI --- .github/workflows/android-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 4eee3608..b2931a6c 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -19,6 +19,7 @@ jobs: timeout-minutes: 45 env: GITHUB_CLIENT_ID: ${{ vars.PUBLIC_GITHUB_OAUTH_CLIENT_ID || 'Ov23lim8WhLjeUMqvuMj' }} + GITHUB_ROCK_BACKEND_URL: ${{ vars.GITHUB_ROCK_BACKEND_URL || '' }} steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 From e6a5f1debcec74d002227bc655714de13915569e Mon Sep 17 00:00:00 2001 From: Sayanth Rock Date: Thu, 23 Jul 2026 13:56:58 +0530 Subject: [PATCH 11/20] Document Android backend URL --- local.properties.example | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/local.properties.example b/local.properties.example index 50da97b7..946c721e 100644 --- a/local.properties.example +++ b/local.properties.example @@ -1,5 +1,7 @@ # Copy this file to local.properties. local.properties is ignored by Git. -# GitHub Rock uses OAuth App Device Flow, so only the public Client ID is needed. +# GitHub Rock uses OAuth App Device Flow, so only the public Client ID is needed in Android. +# The optional backend URL must point to a deployed HTTPS GitHub Rock Backend instance. # Never add a client secret, access token, keystore, private key, or password here. sdk.dir=/path/to/Android/Sdk GITHUB_CLIENT_ID=Ov23lim8WhLjeUMqvuMj +GITHUB_ROCK_BACKEND_URL=https://api.example.com/ From d67a867efce3c3184879d738b63378aad5965b0e Mon Sep 17 00:00:00 2001 From: Sayanth Rock Date: Thu, 23 Jul 2026 13:57:27 +0530 Subject: [PATCH 12/20] Document Android backend integration --- docs/BACKEND_CONNECTION.md | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/BACKEND_CONNECTION.md diff --git a/docs/BACKEND_CONNECTION.md b/docs/BACKEND_CONNECTION.md new file mode 100644 index 00000000..0dd156a5 --- /dev/null +++ b/docs/BACKEND_CONNECTION.md @@ -0,0 +1,50 @@ +# GitHub Rock Backend connection + +GitHub Rock can connect to the companion Ktor service in [`Sayanthrock-Developer/GitHub-Rock-Backend`](https://github.com/Sayanthrock-Developer/GitHub-Rock-Backend). + +The backend is optional by design. Normal repository, issue, pull-request, Actions, release, and download operations continue to call GitHub directly with the user's encrypted OAuth token. The backend currently provides public runtime health/configuration, GitHub OAuth Device Flow start/poll/refresh, and GitHub webhook intake. + +## 1. Deploy the backend + +Deploy the backend behind HTTPS and configure all production variables listed in its `.env.example`. The OAuth client secret belongs only on that server and must never be copied into this Android repository or an APK. + +Verify the deployed service: + +```text +GET https://your-backend.example/v1/health +GET https://your-backend.example/v1/config +``` + +`/v1/config` should report `oauthDeviceProxy=true`. Token refresh through the backend additionally requires `oauthRefreshProxy=true`. + +## 2. Connect from the Android app + +Open: + +**Profile → About → App information → GitHub Rock Backend connection** + +Enter the deployed HTTPS base URL and select **Save and test connection**. GitHub Rock checks both `/v1/health` and `/v1/config` before saving the endpoint. + +The endpoint can also be bundled at build time: + +```properties +GITHUB_ROCK_BACKEND_URL=https://your-backend.example/ +``` + +For GitHub Actions builds, create the repository variable `GITHUB_ROCK_BACKEND_URL`. + +## Authentication behavior + +1. When a backend endpoint is connected, Device Flow starts and polls through the backend. +2. Expiring OAuth tokens refresh through the backend so the OAuth client secret remains server-side. +3. If the backend is unavailable, GitHub Rock falls back to direct GitHub Device Flow when the public Client ID is present. +4. Access and refresh tokens are stored only in Android Keystore-backed local storage. The backend proxy is stateless and does not persist tokens. + +## Security boundaries + +- Only HTTPS backend URLs are accepted. +- URLs containing credentials, query parameters, or fragments are rejected. +- GitHub passwords are never requested or handled. +- The OAuth client secret is server-only. +- The backend does not replace GitHub authorization checks. +- Direct GitHub API access remains available during backend maintenance. From 76976a04898baad3263a6ff2820d23a9b3c84111 Mon Sep 17 00:00:00 2001 From: Sayanth Rock Date: Thu, 23 Jul 2026 13:59:27 +0530 Subject: [PATCH 13/20] Verify Android backend HTTP paths --- .../githubrock/BackendApiPathTest.kt | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 app/src/test/java/com/sayanthrock/githubrock/BackendApiPathTest.kt diff --git a/app/src/test/java/com/sayanthrock/githubrock/BackendApiPathTest.kt b/app/src/test/java/com/sayanthrock/githubrock/BackendApiPathTest.kt new file mode 100644 index 00000000..5b21d89f --- /dev/null +++ b/app/src/test/java/com/sayanthrock/githubrock/BackendApiPathTest.kt @@ -0,0 +1,76 @@ +package com.sayanthrock.githubrock + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import com.sayanthrock.githubrock.core.network.BackendDevicePollRequest +import com.sayanthrock.githubrock.core.network.BackendTokenRefreshRequest +import com.sayanthrock.githubrock.core.network.GitHubRockBackendApi +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +class BackendApiPathTest { + private lateinit var server: MockWebServer + private lateinit var api: GitHubRockBackendApi + + @Before fun setUp() { + server = MockWebServer().also { it.start() } + val json = Json { ignoreUnknownKeys = true } + api = Retrofit.Builder() + .baseUrl(server.url("/")) + .client(OkHttpClient()) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(GitHubRockBackendApi::class.java) + } + + @After fun tearDown() { + server.shutdown() + } + + @Test fun mobileClientUsesTheBackendV1Contract() = runBlocking { + server.enqueue(jsonResponse("""{ + "status":"healthy","version":"0.1.0","postgres":"up", + "redis":"up","meilisearch":"up","timestamp":"2026-07-23T00:00:00Z" + }""")) + server.enqueue(jsonResponse("""{ + "apiVersion":"v1","minSupportedAppVersion":"0.1.0", + "latestAppVersion":"0.1.0","maintenanceMode":false, + "features":{"oauthDeviceProxy":true,"oauthRefreshProxy":true} + }""")) + server.enqueue(jsonResponse("""{ + "device_code":"device","user_code":"ABCD-EFGH", + "verification_uri":"https://github.com/login/device", + "expires_in":900,"interval":5 + }""")) + server.enqueue(jsonResponse("""{"state":"pending"}""")) + server.enqueue(jsonResponse("""{ + "state":"authorized","access_token":"access","token_type":"bearer", + "refresh_token":"refresh","expires_in":28800 + }""")) + + api.health() + api.config() + api.startDeviceFlow() + api.pollDeviceFlow(BackendDevicePollRequest("device")) + api.refreshToken(BackendTokenRefreshRequest("refresh")) + + assertEquals("/v1/health", server.takeRequest().path) + assertEquals("/v1/config", server.takeRequest().path) + assertEquals("/v1/auth/device/start", server.takeRequest().path) + assertEquals("/v1/auth/device/poll", server.takeRequest().path) + assertEquals("/v1/auth/device/refresh", server.takeRequest().path) + } + + private fun jsonResponse(body: String): MockResponse = MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) +} From e3236a4e70a7e194bde9605b3af18e081e344a7b Mon Sep 17 00:00:00 2001 From: Sayanth Rock Date: Thu, 23 Jul 2026 14:02:34 +0530 Subject: [PATCH 14/20] Inject backend URL into release APKs --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4cd60a4a..273bfc1c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,7 @@ jobs: timeout-minutes: 45 env: GITHUB_CLIENT_ID: ${{ vars.PUBLIC_GITHUB_OAUTH_CLIENT_ID || 'Ov23lim8WhLjeUMqvuMj' }} + GITHUB_ROCK_BACKEND_URL: ${{ vars.GITHUB_ROCK_BACKEND_URL || '' }} EXPECTED_RELEASE_CERT_SHA256: ${{ vars.EXPECTED_RELEASE_CERT_SHA256 }} INPUT_VERSION: ${{ inputs.version }} INPUT_PRERELEASE: ${{ inputs.prerelease }} From 34bdc7ad4dfe94e72c01ba5ea99bdf0a0affc3f7 Mon Sep 17 00:00:00 2001 From: Sayanth Rock Date: Thu, 23 Jul 2026 14:04:23 +0530 Subject: [PATCH 15/20] Enforce backend compatibility and feature flags --- .../githubrock/data/backend/BackendGateway.kt | 69 +++++++++++++++++-- 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/sayanthrock/githubrock/data/backend/BackendGateway.kt b/app/src/main/java/com/sayanthrock/githubrock/data/backend/BackendGateway.kt index 65d93e8d..50efb177 100644 --- a/app/src/main/java/com/sayanthrock/githubrock/data/backend/BackendGateway.kt +++ b/app/src/main/java/com/sayanthrock/githubrock/data/backend/BackendGateway.kt @@ -74,10 +74,15 @@ class BackendGateway @Inject constructor( val endpoint = rawEndpoint?.let(::requireBackendBaseUrl) ?: requireNotNull(endpointStore.endpoint()) { "GitHub Rock Backend is not connected." } val api = api(endpoint) + val health = api.health() + val config = api.config() + require(config.apiVersion == SUPPORTED_API_VERSION) { + "Backend API ${config.apiVersion} is not compatible with this app." + } return BackendConnectionSnapshot( endpoint = endpoint, - health = api.health(), - config = api.config(), + health = health, + config = config, ) } @@ -96,18 +101,23 @@ class BackendGateway @Inject constructor( } } - suspend fun startDeviceFlow(): DeviceCodeResponse = - apiForConfiguredEndpoint().startDeviceFlow() + suspend fun startDeviceFlow(): DeviceCodeResponse { + val api = apiForConfiguredEndpoint() + validateBackendForApp(api.config(), "oauthDeviceProxy") + return api.startDeviceFlow() + } suspend fun pollDeviceFlow(deviceCode: String): DeviceTokenResponse = apiForConfiguredEndpoint() .pollDeviceFlow(BackendDevicePollRequest(deviceCode)) .toDeviceTokenResponse() - suspend fun refreshToken(refreshToken: String): DeviceTokenResponse = - apiForConfiguredEndpoint() - .refreshToken(BackendTokenRefreshRequest(refreshToken)) + suspend fun refreshToken(refreshToken: String): DeviceTokenResponse { + val api = apiForConfiguredEndpoint() + validateBackendForApp(api.config(), "oauthRefreshProxy") + return api.refreshToken(BackendTokenRefreshRequest(refreshToken)) .toDeviceTokenResponse() + } private fun apiForConfiguredEndpoint(): GitHubRockBackendApi { val endpoint = requireNotNull(endpointStore.endpoint()) { @@ -131,6 +141,10 @@ class BackendGateway @Inject constructor( } } } + + private companion object { + const val SUPPORTED_API_VERSION = "v1" + } } internal fun normalizedBackendBaseUrl(raw: String?): String? { @@ -147,6 +161,47 @@ internal fun requireBackendBaseUrl(raw: String): String = "Use a valid HTTPS backend URL without query parameters or credentials." } +internal fun validateBackendForApp( + config: BackendPublicConfigResponse, + requiredFeature: String, + currentVersion: String = BuildConfig.VERSION_NAME, +) { + require(config.apiVersion == "v1") { + "Backend API ${config.apiVersion} is not compatible with this app." + } + require(!config.maintenanceMode) { + "GitHub Rock Backend is temporarily in maintenance mode." + } + require(isVersionAtLeast(currentVersion, config.minSupportedAppVersion)) { + "GitHub Rock ${config.minSupportedAppVersion} or newer is required by the backend." + } + require(config.features[requiredFeature] == true) { + "Backend feature $requiredFeature is not available." + } +} + +internal fun isVersionAtLeast(current: String, minimum: String): Boolean { + val currentParts = versionParts(current) + val minimumParts = versionParts(minimum) + val width = maxOf(currentParts.size, minimumParts.size) + return (0 until width).firstNotNullOfOrNull { index -> + val currentPart = currentParts.getOrElse(index) { 0 } + val minimumPart = minimumParts.getOrElse(index) { 0 } + when { + currentPart > minimumPart -> true + currentPart < minimumPart -> false + else -> null + } + } ?: true +} + +private fun versionParts(value: String): List = value + .trim() + .removePrefix("v") + .substringBefore('-') + .split('.') + .map { it.toIntOrNull() ?: 0 } + internal fun BackendDeviceTokenResponse.toDeviceTokenResponse(): DeviceTokenResponse = when (state) { "authorized" -> DeviceTokenResponse( accessToken = accessToken, From c576cfddbed293ad29f13bb80f030cc1aef28ca5 Mon Sep 17 00:00:00 2001 From: Sayanth Rock Date: Thu, 23 Jul 2026 14:05:01 +0530 Subject: [PATCH 16/20] Test backend compatibility policy --- .../githubrock/BackendContractTest.kt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/app/src/test/java/com/sayanthrock/githubrock/BackendContractTest.kt b/app/src/test/java/com/sayanthrock/githubrock/BackendContractTest.kt index ede4b508..909d71f7 100644 --- a/app/src/test/java/com/sayanthrock/githubrock/BackendContractTest.kt +++ b/app/src/test/java/com/sayanthrock/githubrock/BackendContractTest.kt @@ -1,10 +1,16 @@ package com.sayanthrock.githubrock import com.sayanthrock.githubrock.core.network.BackendDeviceTokenResponse +import com.sayanthrock.githubrock.core.network.BackendPublicConfigResponse +import com.sayanthrock.githubrock.data.backend.isVersionAtLeast import com.sayanthrock.githubrock.data.backend.normalizedBackendBaseUrl import com.sayanthrock.githubrock.data.backend.toDeviceTokenResponse +import com.sayanthrock.githubrock.data.backend.validateBackendForApp import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Test class BackendContractTest { @@ -58,4 +64,35 @@ class BackendContractTest { BackendDeviceTokenResponse(state = "denied").toDeviceTokenResponse().error, ) } + + @Test fun semanticVersionPolicyHandlesDebugAndPatchVersions() { + assertTrue(isVersionAtLeast("0.2.0-debug", "0.1.9")) + assertTrue(isVersionAtLeast("v1.0.0", "1.0")) + assertFalse(isVersionAtLeast("0.1.9", "0.2.0")) + } + + @Test fun maintenanceAndDisabledFeaturesRejectTheBackendPath() { + val ready = BackendPublicConfigResponse( + minSupportedAppVersion = "0.1.0", + latestAppVersion = "0.2.0", + maintenanceMode = false, + features = mapOf("oauthDeviceProxy" to true), + ) + validateBackendForApp(ready, "oauthDeviceProxy", currentVersion = "0.1.0") + + assertThrows(IllegalArgumentException::class.java) { + validateBackendForApp( + ready.copy(maintenanceMode = true), + "oauthDeviceProxy", + currentVersion = "0.1.0", + ) + } + assertThrows(IllegalArgumentException::class.java) { + validateBackendForApp( + ready.copy(features = emptyMap()), + "oauthDeviceProxy", + currentVersion = "0.1.0", + ) + } + } } From 8caa7b94aeb53aeaf05154ff6fd85b0443073668 Mon Sep 17 00:00:00 2001 From: Sayanth Rock Date: Thu, 23 Jul 2026 14:07:29 +0530 Subject: [PATCH 17/20] Complete backend version compatibility tests --- .../com/sayanthrock/githubrock/BackendContractTest.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/test/java/com/sayanthrock/githubrock/BackendContractTest.kt b/app/src/test/java/com/sayanthrock/githubrock/BackendContractTest.kt index 909d71f7..49297e0d 100644 --- a/app/src/test/java/com/sayanthrock/githubrock/BackendContractTest.kt +++ b/app/src/test/java/com/sayanthrock/githubrock/BackendContractTest.kt @@ -69,6 +69,7 @@ class BackendContractTest { assertTrue(isVersionAtLeast("0.2.0-debug", "0.1.9")) assertTrue(isVersionAtLeast("v1.0.0", "1.0")) assertFalse(isVersionAtLeast("0.1.9", "0.2.0")) + assertFalse(isVersionAtLeast("0.1.0", "0.1.1")) } @Test fun maintenanceAndDisabledFeaturesRejectTheBackendPath() { @@ -94,5 +95,12 @@ class BackendContractTest { currentVersion = "0.1.0", ) } + assertThrows(IllegalArgumentException::class.java) { + validateBackendForApp( + ready.copy(apiVersion = "v2"), + "oauthDeviceProxy", + currentVersion = "0.1.0", + ) + } } } From bf3a5925299519dea6d183ddd790d6946d3f4653 Mon Sep 17 00:00:00 2001 From: Sayanth Rock Date: Thu, 23 Jul 2026 14:10:46 +0530 Subject: [PATCH 18/20] Document backend production readiness checks --- docs/BACKEND_CONNECTION.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/BACKEND_CONNECTION.md b/docs/BACKEND_CONNECTION.md index 0dd156a5..5db1944a 100644 --- a/docs/BACKEND_CONNECTION.md +++ b/docs/BACKEND_CONNECTION.md @@ -15,7 +15,16 @@ GET https://your-backend.example/v1/health GET https://your-backend.example/v1/config ``` -`/v1/config` should report `oauthDeviceProxy=true`. Token refresh through the backend additionally requires `oauthRefreshProxy=true`. +Before connecting a release build, confirm all of the following: + +- `/v1/health` responds from the public HTTPS hostname. +- `/v1/config` reports `apiVersion=v1`. +- `maintenanceMode` is false. +- `oauthDeviceProxy` is true. +- `oauthRefreshProxy` is true when expiring OAuth tokens are enabled. +- `minSupportedAppVersion` does not exceed the installed GitHub Rock version. + +GitHub Rock enforces this compatibility contract before using the backend for authentication. An incompatible or unavailable backend is skipped in favor of the direct-GitHub fallback when the public OAuth client ID is available. ## 2. Connect from the Android app From 1bec999ddd08fd1151169539f5192d75c80bc96f Mon Sep 17 00:00:00 2001 From: Sayanth Rock Date: Thu, 23 Jul 2026 14:12:37 +0530 Subject: [PATCH 19/20] Bootstrap Android CI for backend integration branch --- .github/workflows/android-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index b2931a6c..1c63e9f4 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -2,7 +2,7 @@ name: Android CI on: push: - branches: [main] + branches: [main, integration/backend-v1] pull_request: workflow_dispatch: From 73eab1a83bc3f392d5014693b968077c4af3c1ee Mon Sep 17 00:00:00 2001 From: Sayanth Rock Date: Thu, 23 Jul 2026 14:34:18 +0530 Subject: [PATCH 20/20] Restore Android CI to main and pull requests --- .github/workflows/android-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 1c63e9f4..b2931a6c 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -2,7 +2,7 @@ name: Android CI on: push: - branches: [main, integration/backend-v1] + branches: [main] pull_request: workflow_dispatch: