diff --git a/README.md b/README.md
index 171d103..4106fe8 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,11 @@ The app never connects directly to Supabase and includes no backend credentials.
- Explicit loading, empty, unavailable and retry states. **No demo fallback**.
- Gothwad Accounts sign-in (OAuth 2.0 Authorization Code + PKCE) is wired to
the real Accounts service, so the wishlist, the account library and reviews
- are stored in the Worker.
+ are stored in the Worker. The native redirect is
+ `kiwistore://oauth/callback` with client id `kiwi-store`; logins started by
+ older builds that still use `gothwadstore://oauth/callback` are accepted
+ during the transition, so both URIs have to be registered in Accounts until
+ those builds are gone.
Legacy demo screen files remain for design reference but are not reachable from
`MainActivity → MainStoreScreen`.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index bf16417..d8dc5eb 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -38,7 +38,9 @@ android {
buildConfigField("String", "ACCOUNTS_SIGNIN_URL", "\"${httpsProperty("ACCOUNTS_SIGNIN_URL", "https://accounts.gothwadtech.com/")}\"")
buildConfigField("String", "SHIZUKU_DOWNLOAD_URL", "\"${httpsProperty("SHIZUKU_DOWNLOAD_URL", "https://shizuku.rikka.app/")}\"")
buildConfigField("String", "OAUTH_CLIENT_ID", "\"${providers.gradleProperty("OAUTH_CLIENT_ID").orElse("kiwi-store").get()}\"")
- buildConfigField("String", "OAUTH_REDIRECT_URI", "\"gothwadstore://oauth/callback\"")
+ // Native redirect registered with Gothwad Accounts. Must match the
+ // `kiwistore` VIEW intent filter in AndroidManifest.xml.
+ buildConfigField("String", "OAUTH_REDIRECT_URI", "\"kiwistore://oauth/callback\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 14f7ff7..480b259 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -65,7 +65,20 @@
-
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/gothwad/store/data/auth/AuthManager.kt b/app/src/main/java/com/gothwad/store/data/auth/AuthManager.kt
index 860b75c..c8c97ef 100644
--- a/app/src/main/java/com/gothwad/store/data/auth/AuthManager.kt
+++ b/app/src/main/java/com/gothwad/store/data/auth/AuthManager.kt
@@ -20,6 +20,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
@@ -42,12 +43,21 @@ sealed interface AuthState {
*
* OAuth 2.0 Authorization Code + PKCE, exactly like the web storefront: the
* browser (Custom Tab) hits Accounts, Accounts redirects back to
- * `gothwadstore://oauth/callback`, and the app exchanges the code for tokens.
+ * `kiwistore://oauth/callback`, and the app exchanges the code for tokens.
* No client secret ever ships in the APK, and no Supabase key is involved.
+ *
+ * Transition: builds released before the scheme rename registered
+ * `gothwadstore://oauth/callback`. [handleRedirect] still accepts that scheme
+ * so a sign-in started by an old APK can finish after updating; the matching
+ * legacy intent filter lives in `AndroidManifest.xml`. Both go away together.
*/
-class AuthManager(private val context: Context) {
+class AuthManager(
+ private val context: Context,
+ private val accountsBaseUrl: String = BuildConfig.ACCOUNTS_API_BASE_URL,
+ httpClient: OkHttpClient = StoreNetwork.downloadClient,
+) {
- private val prefs = context.getSharedPreferences("gothwad_accounts", Context.MODE_PRIVATE)
+ private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val refreshMutex = Mutex()
private val _state = MutableStateFlow(AuthState.Loading)
@@ -56,7 +66,7 @@ class AuthManager(private val context: Context) {
private val _lastError = MutableStateFlow(null)
val lastError: StateFlow = _lastError.asStateFlow()
- private val client = StoreNetwork.downloadClient.newBuilder()
+ private val client = httpClient.newBuilder()
.callTimeout(45, TimeUnit.SECONDS)
.build()
@@ -67,25 +77,13 @@ class AuthManager(private val context: Context) {
}
// ── Sign-in ────────────────────────────────────────────────────────────
- fun startSignIn(scope: String = "profile email offline_access"): Result = runCatching {
+ fun startSignIn(scope: String = DEFAULT_SCOPE): Result = runCatching {
val verifier = randomUrlSafe(32)
val challenge = base64Url(MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray()))
val state = randomUrlSafe(16)
- prefs.edit()
- .putString(KEY_VERIFIER, verifier)
- .putString(KEY_STATE, state)
- .putLong(KEY_STARTED, System.currentTimeMillis())
- .apply()
+ rememberPendingSignIn(verifier, state)
- val url = Uri.parse("${BuildConfig.ACCOUNTS_API_BASE_URL}oauth/authorize").buildUpon()
- .appendQueryParameter("client_id", BuildConfig.OAUTH_CLIENT_ID)
- .appendQueryParameter("redirect_uri", BuildConfig.OAUTH_REDIRECT_URI)
- .appendQueryParameter("response_type", "code")
- .appendQueryParameter("scope", scope)
- .appendQueryParameter("state", state)
- .appendQueryParameter("code_challenge", challenge)
- .appendQueryParameter("code_challenge_method", "S256")
- .build()
+ val url = authorizeUrl(scope, state, challenge)
val customTabs = CustomTabsIntent.Builder()
.setShowTitle(true)
@@ -99,9 +97,39 @@ class AuthManager(private val context: Context) {
}
}
- /** @return true when the deep link belonged to the OAuth callback. */
+ /**
+ * Stores a pending PKCE session. [startSignIn] uses it, and unit tests use
+ * it to exercise [exchangeCode] without opening a Custom Tab.
+ */
+ internal fun rememberPendingSignIn(verifier: String, state: String) {
+ prefs.edit()
+ .putString(KEY_VERIFIER, verifier)
+ .putString(KEY_STATE, state)
+ .putLong(KEY_STARTED, System.currentTimeMillis())
+ .apply()
+ }
+
+ /** Accounts consent URL; `redirect_uri` must match [BuildConfig.OAUTH_REDIRECT_URI]. */
+ internal fun authorizeUrl(scope: String, state: String, codeChallenge: String): Uri =
+ Uri.parse("${accountsBaseUrl}oauth/authorize").buildUpon()
+ .appendQueryParameter("client_id", BuildConfig.OAUTH_CLIENT_ID)
+ .appendQueryParameter("redirect_uri", BuildConfig.OAUTH_REDIRECT_URI)
+ .appendQueryParameter("response_type", "code")
+ .appendQueryParameter("scope", scope)
+ .appendQueryParameter("state", state)
+ .appendQueryParameter("code_challenge", codeChallenge)
+ .appendQueryParameter("code_challenge_method", "S256")
+ .build()
+
+ /**
+ * @return true when the deep link belonged to the OAuth callback.
+ *
+ * Only the current `kiwistore` scheme (plus the legacy `gothwadstore`
+ * scheme for the transition window) is accepted; anything else is ignored.
+ */
fun handleRedirect(uri: Uri): Boolean {
- if (uri.scheme != "gothwadstore" || uri.host != "oauth") return false
+ if (!isOAuthCallback(uri)) return false
+
val error = uri.getQueryParameter("error")
if (error != null) {
_lastError.value = "Gothwad sign-in was cancelled ($error)."
@@ -129,6 +157,16 @@ class AuthManager(private val context: Context) {
return true
}
+ /**
+ * True only for `kiwistore://oauth/callback` and the transition-only
+ * `gothwadstore://oauth/callback`. The host and path are checked too, so a
+ * look-alike scheme or a different deep link never reaches the token code.
+ */
+ internal fun isOAuthCallback(uri: Uri): Boolean =
+ uri.host == "oauth" &&
+ uri.path.orEmpty().startsWith("/callback") &&
+ uri.scheme in OAUTH_SCHEMES
+
suspend fun exchangeCode(code: String) {
val verifier = prefs.getString(KEY_VERIFIER, null)
?: error("Sign-in session expired. Please tap Sign in again.")
@@ -139,7 +177,7 @@ class AuthManager(private val context: Context) {
.put("client_id", BuildConfig.OAUTH_CLIENT_ID)
.put("code_verifier", verifier)
.toString()
- val json = postJson("${BuildConfig.ACCOUNTS_API_BASE_URL}oauth/token", body, null)
+ val json = postJson("${accountsBaseUrl}oauth/token", body, null)
val access = json.optString("access_token").takeIf { it.isNotBlank() }
?: error("Gothwad Accounts returned no access token.")
val refresh = json.optString("refresh_token").takeIf { it.isNotBlank() }
@@ -218,7 +256,7 @@ class AuthManager(private val context: Context) {
.put("refresh_token", refreshToken)
.put("client_id", BuildConfig.OAUTH_CLIENT_ID)
.toString()
- val json = postJson("${BuildConfig.ACCOUNTS_API_BASE_URL}oauth/token", body, null)
+ val json = postJson("${accountsBaseUrl}oauth/token", body, null)
val access = json.optString("access_token").takeIf { it.isNotBlank() }
?: error("Token refresh failed.")
val newRefresh = json.optString("refresh_token").takeIf { it.isNotBlank() } ?: refreshToken
@@ -233,7 +271,7 @@ class AuthManager(private val context: Context) {
private suspend fun fetchUserInfo(): GothwadUser? {
val token = accessTokenBlocking() ?: return null
val request = Request.Builder()
- .url("${BuildConfig.ACCOUNTS_API_BASE_URL}oauth/userinfo")
+ .url("${accountsBaseUrl}oauth/userinfo")
.header("Authorization", "Bearer $token")
.header("Accept", "application/json")
.build()
@@ -281,16 +319,30 @@ class AuthManager(private val context: Context) {
private fun base64Url(bytes: ByteArray): String =
Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
- private companion object {
- const val KEY_ACCESS = "access_token"
- const val KEY_REFRESH = "refresh_token"
- const val KEY_EXPIRES_AT = "expires_at"
- const val KEY_VERIFIER = "pkce_verifier"
- const val KEY_STATE = "oauth_state"
- const val KEY_STARTED = "started_at"
+ internal companion object {
+ /** Current OAuth redirect scheme; must match `BuildConfig.OAUTH_REDIRECT_URI`. */
+ const val OAUTH_SCHEME = "kiwistore"
+
+ /**
+ * Transition only: builds released before the rename sent Accounts back
+ * with this scheme. It is accepted so their in-flight sign-ins can
+ * finish after the update. Remove it together with the legacy intent
+ * filter in `AndroidManifest.xml` once no such build is in circulation.
+ */
+ const val LEGACY_OAUTH_SCHEME = "gothwadstore"
+
+ private val OAUTH_SCHEMES = setOf(OAUTH_SCHEME, LEGACY_OAUTH_SCHEME)
+
+ internal const val PREFS_NAME = "gothwad_accounts"
+ internal const val KEY_ACCESS = "access_token"
+ internal const val KEY_REFRESH = "refresh_token"
+ internal const val KEY_EXPIRES_AT = "expires_at"
+ internal const val KEY_VERIFIER = "pkce_verifier"
+ internal const val KEY_STATE = "oauth_state"
+ internal const val KEY_STARTED = "started_at"
/** The real scope set is stored when signing in; kept for readable signing. */
- const val DEFAULT_SCOPE = "profile email offline_access"
+ internal const val DEFAULT_SCOPE = "profile email offline_access"
}
}
diff --git a/app/src/main/java/com/gothwad/store/ui/screens/AccountScreens.kt b/app/src/main/java/com/gothwad/store/ui/screens/AccountScreens.kt
index a8f736c..0474bb0 100644
--- a/app/src/main/java/com/gothwad/store/ui/screens/AccountScreens.kt
+++ b/app/src/main/java/com/gothwad/store/ui/screens/AccountScreens.kt
@@ -506,7 +506,7 @@ fun SignInScreen(vm: StoreViewModel, nav: NavController) {
)
com.gothwad.store.ui.components.IconTextRow(
Icons.Filled.AccountCircle,
- "After you approve, Android returns to gothwadstore://oauth/callback and the app " +
+ "After you approve, Android returns to kiwistore://oauth/callback and the app " +
"exchanges the one-time code for tokens.",
)
com.gothwad.store.ui.components.IconTextRow(
diff --git a/app/src/main/java/com/gothwad/store/ui/screens/InstallScreens.kt b/app/src/main/java/com/gothwad/store/ui/screens/InstallScreens.kt
index 4d5a934..aaa8826 100644
--- a/app/src/main/java/com/gothwad/store/ui/screens/InstallScreens.kt
+++ b/app/src/main/java/com/gothwad/store/ui/screens/InstallScreens.kt
@@ -597,8 +597,8 @@ fun AdbScreen(vm: StoreViewModel, nav: NavController) {
item { SectionHeader(title = "Commands") }
item {
Column(Modifier.padding(horizontal = Spacing.screenH)) {
- CommandBlock("adb install -r gothwadstore.apk")
- CommandBlock("adb shell pm install -r /sdcard/Download/gothwadstore.apk")
+ CommandBlock("adb install -r KiwiStore.apk")
+ CommandBlock("adb shell pm install -r /sdcard/Download/KiwiStore.apk")
CommandBlock("adb shell pm list packages | grep gothwad")
CommandBlock("adb uninstall $packageName")
}
diff --git a/app/src/test/java/com/gothwad/store/AuthRedirectTest.kt b/app/src/test/java/com/gothwad/store/AuthRedirectTest.kt
new file mode 100644
index 0000000..b8cc3c4
--- /dev/null
+++ b/app/src/test/java/com/gothwad/store/AuthRedirectTest.kt
@@ -0,0 +1,141 @@
+package com.gothwad.store
+
+import android.app.Application
+import android.content.Intent
+import android.net.Uri
+import com.gothwad.store.data.auth.AuthManager
+import kotlinx.coroutines.runBlocking
+import okhttp3.OkHttpClient
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.json.JSONObject
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+/**
+ * The OAuth redirect moved from `gothwadstore://` to `kiwistore://`. These
+ * tests pin the new contract: the authorize request, the callback filter in the
+ * manifest and the token exchange must all use `kiwistore://oauth/callback`,
+ * while the old scheme is only tolerated for logins started by an old APK.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34], application = Application::class)
+class AuthRedirectTest {
+
+ private lateinit var server: MockWebServer
+ private lateinit var auth: AuthManager
+
+ @Before
+ fun setUp() {
+ server = MockWebServer()
+ server.start()
+ auth = AuthManager(
+ context = RuntimeEnvironment.getApplication(),
+ accountsBaseUrl = server.url("/api/").toString(),
+ httpClient = OkHttpClient(),
+ )
+ }
+
+ @After
+ fun tearDown() {
+ server.shutdown()
+ }
+
+ @Test
+ fun `build config points at kiwistore`() {
+ assertEquals("kiwistore://oauth/callback", BuildConfig.OAUTH_REDIRECT_URI)
+ assertEquals("kiwistore", Uri.parse(BuildConfig.OAUTH_REDIRECT_URI).scheme)
+ assertEquals(AuthManager.OAUTH_SCHEME, Uri.parse(BuildConfig.OAUTH_REDIRECT_URI).scheme)
+ assertEquals("kiwi-store", BuildConfig.OAUTH_CLIENT_ID)
+ }
+
+ @Test
+ fun `authorize request asks accounts to return to kiwistore`() {
+ val uri = auth.authorizeUrl(
+ scope = "profile email offline_access",
+ state = "state-123",
+ codeChallenge = "challenge-abc",
+ )
+
+ assertTrue(uri.path.orEmpty().endsWith("oauth/authorize"))
+ assertEquals("kiwistore://oauth/callback", uri.getQueryParameter("redirect_uri"))
+ assertEquals("kiwi-store", uri.getQueryParameter("client_id"))
+ assertEquals("code", uri.getQueryParameter("response_type"))
+ assertEquals("state-123", uri.getQueryParameter("state"))
+ assertEquals("challenge-abc", uri.getQueryParameter("code_challenge"))
+ assertEquals("S256", uri.getQueryParameter("code_challenge_method"))
+ }
+
+ @Test
+ fun `kiwistore callback is accepted`() {
+ assertTrue(auth.handleRedirect(Uri.parse("kiwistore://oauth/callback?error=access_denied")))
+ assertEquals(
+ "Gothwad sign-in was cancelled (access_denied).",
+ auth.lastError.value,
+ )
+ }
+
+ @Test
+ fun `legacy gothwadstore callback still works during the transition`() {
+ assertTrue(auth.handleRedirect(Uri.parse("gothwadstore://oauth/callback?error=access_denied")))
+ assertEquals(
+ "Gothwad sign-in was cancelled (access_denied).",
+ auth.lastError.value,
+ )
+ }
+
+ @Test
+ fun `foreign schemes, hosts and paths are ignored`() {
+ assertFalse(auth.handleRedirect(Uri.parse("https://kiwistore.gothwadtech.com/oauth/callback?code=x")))
+ assertFalse(auth.handleRedirect(Uri.parse("kiwistoreevil://oauth/callback?code=x")))
+ assertFalse(auth.handleRedirect(Uri.parse("kiwistore://other/callback?code=x")))
+ assertFalse(auth.handleRedirect(Uri.parse("kiwistore://oauth/reset?code=x")))
+ assertFalse(auth.handleRedirect(Uri.parse("gothwadstore://other/callback?code=x")))
+ }
+
+ @Test
+ fun `token exchange sends the kiwistore redirect uri`() {
+ server.enqueue(
+ MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"access_token":"access-1","refresh_token":"refresh-1","expires_in":3600}"""),
+ )
+ auth.rememberPendingSignIn(verifier = "verifier-abc", state = "state-123")
+
+ runBlocking { auth.exchangeCode("code-xyz") }
+
+ val recorded = server.takeRequest()
+ assertEquals("/api/oauth/token", recorded.path)
+ val body = JSONObject(recorded.body.readUtf8())
+ assertEquals("authorization_code", body.getString("grant_type"))
+ assertEquals("code-xyz", body.getString("code"))
+ assertEquals("kiwistore://oauth/callback", body.getString("redirect_uri"))
+ assertEquals("kiwi-store", body.getString("client_id"))
+ assertEquals("verifier-abc", body.getString("code_verifier"))
+ }
+
+ @Test
+ fun `manifest routes both schemes to the store activity`() {
+ val context = RuntimeEnvironment.getApplication()
+ val activity = MainActivity::class.java.name
+
+ fun handlers(url: String) = context.packageManager.queryIntentActivities(
+ Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE),
+ 0,
+ ).orEmpty().map { it.activityInfo.name }
+
+ assertTrue(handlers("kiwistore://oauth/callback").contains(activity))
+ assertTrue(handlers("kiwistore://oauth/callback?code=abc").contains(activity))
+ // Transition only; removed together with AuthManager.LEGACY_OAUTH_SCHEME.
+ assertTrue(handlers("gothwadstore://oauth/callback").contains(activity))
+ assertFalse(handlers("kiwistore://oauth/other").contains(activity))
+ }
+}
diff --git a/docs/ANDROID_INTEGRATION.md b/docs/ANDROID_INTEGRATION.md
index fbe82b8..4746f85 100644
--- a/docs/ANDROID_INTEGRATION.md
+++ b/docs/ANDROID_INTEGRATION.md
@@ -69,6 +69,29 @@ validation, implement secure token storage excluded from backup, session
validation/expiry and logout. Then wire `/me`, wishlist/library and review/report
mutations. No tokens, fake user or sign-in success are persisted by this phase.
+## Account redirect (native OAuth)
+
+| Setting | Value |
+| --- | --- |
+| Client id | `kiwi-store` (`OAUTH_CLIENT_ID`, override with `-POAUTH_CLIENT_ID=...`) |
+| Redirect URI | `kiwistore://oauth/callback` (`OAUTH_REDIRECT_URI` in `app/build.gradle.kts`) |
+| Authorize endpoint | `oauth/authorize` (Authorization Code + PKCE S256) |
+| Token endpoint | `oauth/token` with the same `redirect_uri` |
+
+The manifest declares a VIEW/BROWSABLE intent filter for the `kiwistore`
+scheme. It also keeps a `gothwadstore` filter as a transition aid: releases
+before the rename handed the callback to `gothwadstore://oauth/callback`, and
+an app update can land while such a sign-in is in flight. `AuthManager`
+accepts that legacy scheme only for this window — the request built in
+`authorizeUrl` and the code exchange in `exchangeCode` always use the
+`kiwistore` value from `BuildConfig`. Remove both the legacy filter and
+`AuthManager.LEGACY_OAUTH_SCHEME` together once no old build is in use, and
+unregister the old URI in Accounts at the same time.
+
+Accounts must have both redirect URIs registered while the transition lasts;
+an unregistered `redirect_uri` makes the token exchange fail even though the
+browser handed the code back to the app.
+
## Tests and verification status
Added MockWebServer tests for query encoding, published-only results,