Skip to content
Merged
17 changes: 17 additions & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,23 @@ compose.desktop {
macOS {
iconFile.set(project.file("logo/app_icon.icns"))
bundleID = "zed.rainxch.githubstore"

// Register githubstore:// URI scheme so macOS opens the app for deep links
infoPlist {
extraKeysRawXml = """
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>GitHub Store Deep Link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>githubstore</string>
</array>
</dict>
</array>
""".trimIndent()
}
}
linux {
iconFile.set(project.file("logo/app_icon.png"))
Expand Down
42 changes: 41 additions & 1 deletion composeApp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@
<!-- Expose cache files via FileProvider for APK install -->
<activity
android:name=".MainActivity"
android:exported="true">
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

<!-- Auth callback (existing) -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />

Expand All @@ -44,6 +46,44 @@
android:host="callback"
android:scheme="githubstore" />
</intent-filter>

<!-- Custom scheme: githubstore://repo/{owner}/{repo} -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data
android:host="repo"
android:scheme="githubstore" />
</intent-filter>

<!-- GitHub repository links: https://github.com/{owner}/{repo} -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data
android:host="github.com"
android:pathPattern="/.*/..*"
android:scheme="https" />
</intent-filter>
Comment on lines +62 to +73
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Claiming github.com URLs without App Links verification is problematic.

This intent filter intercepts https://github.com URLs without android:autoVerify="true" and a corresponding /.well-known/assetlinks.json on github.com (which you don't control). This causes:

  1. Disambiguation dialogs: Android will prompt users to choose between your app and the browser/GitHub app for every matching URL, degrading UX.
  2. Overly broad pathPattern: "/.*/..*" matches not just /{owner}/{repo} but also /{owner}/{repo}/issues, /{owner}/{repo}/tree/main/src/..., profile URLs like /{user}/stars, etc. Android's pathPattern regex is limited and can't constrain to exactly two segments.
  3. User trust: Intercepting a major third-party domain may appear suspicious and could cause issues with Play Store review.

Consider removing this filter and instead relying solely on the custom githubstore:// scheme and your own domain (github-store.org). If you want to support GitHub URLs, implement a share-target or use ACTION_SEND with text filtering, which is opt-in by the user.

🤖 Prompt for AI Agents
In `@composeApp/src/androidMain/AndroidManifest.xml` around lines 62 - 73, Remove
the overly-broad intent filter that claims github.com (the <intent-filter> with
action android.intent.action.VIEW and <data android:host="github.com"
android:pathPattern="/.*/..*" android:scheme="https" />) from
AndroidManifest.xml; instead rely on your custom scheme (githubstore://) and
your verified domain (github-store.org), or implement an explicit user-initiated
mechanism like ACTION_SEND/share-target for GitHub links so you don’t intercept
third-party github.com URLs without autoVerify and assetlinks control.


<!-- App website links: https://github-store.org/app/?repo={owner}/{repo} -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data
android:host="github-store.org"
android:pathPrefix="/app/"
android:scheme="https" />
</intent-filter>
</activity>

<provider
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,46 @@
package zed.rainxch.githubstore

import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.util.Consumer

class MainActivity : ComponentActivity() {

private var deepLinkUri by mutableStateOf<String?>(null)

override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()

enableEdgeToEdge()

super.onCreate(savedInstanceState)

deepLinkUri = intent?.data?.toString()

setContent {
App()
DisposableEffect(Unit) {
val listener = Consumer<Intent> { newIntent ->
newIntent.data?.toString()?.let {
deepLinkUri = it
}
}
addOnNewIntentListener(listener)
onDispose {
removeOnNewIntentListener(listener)
}
}

App(deepLinkUri = deepLinkUri)
}
}
}
Expand Down
51 changes: 37 additions & 14 deletions composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,47 @@ package zed.rainxch.githubstore
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.viewmodel.koinViewModel
import zed.rainxch.core.presentation.theme.GithubStoreTheme
import zed.rainxch.core.presentation.utils.ApplyAndroidSystemBars
import zed.rainxch.githubstore.app.deeplink.DeepLinkDestination
import zed.rainxch.githubstore.app.deeplink.DeepLinkParser
import zed.rainxch.githubstore.app.navigation.AppNavigation
import zed.rainxch.githubstore.app.navigation.GithubStoreGraph
import zed.rainxch.githubstore.app.components.RateLimitDialog

@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@Preview
fun App() {
fun App(deepLinkUri: String? = null) {
val viewModel: MainViewModel = koinViewModel()
val state by viewModel.state.collectAsStateWithLifecycle()

val navBackStack = rememberNavController()

LaunchedEffect(deepLinkUri) {
deepLinkUri?.let { uri ->
when (val destination = DeepLinkParser.parse(uri)) {
is DeepLinkDestination.Repository -> {
navBackStack.navigate(
GithubStoreGraph.DetailsScreen(
owner = destination.owner,
repo = destination.repo
)
)
}

DeepLinkDestination.None -> { /* ignore unrecognized deep links */
}
}
}
}
Comment on lines +29 to +45
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Repeated identical deep link URIs won't re-trigger navigation.

LaunchedEffect(deepLinkUri) only re-runs when the key value changes. If the user opens the same githubstore://repo/owner/repo link twice in a row, deepLinkUri stays the same string and the effect won't fire again. On Android with singleTask, successive intents carrying the same URI will update deepLinkUri via onNewIntent, but the state value won't actually change if it's the same string.

Consider using a wrapper that includes a timestamp or incrementing counter to guarantee re-trigger:

data class DeepLinkEvent(val uri: String, val timestamp: Long = System.currentTimeMillis())

Alternatively, reset deepLinkUri to null after processing, though that requires a callback from App to the platform layer.

🤖 Prompt for AI Agents
In `@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt` around
lines 29 - 45, The effect using LaunchedEffect(deepLinkUri) won't re-run for
identical URIs; wrap the URI in an event that always changes (e.g., create
DeepLinkEvent(val uri: String, val timestamp: Long =
System.currentTimeMillis())) and switch the LaunchedEffect key to the
DeepLinkEvent, then call DeepLinkParser.parse(event.uri) and navigate with
navBackStack.navigate(GithubStoreGraph.DetailsScreen(owner = destination.owner,
repo = destination.repo)) as before; alternatively, if you prefer clearing
state, reset deepLinkUri to null after handling inside the same block so
subsequent identical intents update the state and re-trigger the effect.


GithubStoreTheme(
fontTheme = state.currentFontTheme,
appTheme = state.currentColorTheme,
Expand All @@ -31,19 +52,21 @@ fun App() {
) {
ApplyAndroidSystemBars(state.isDarkTheme)

if (state.showRateLimitDialog && state.rateLimitInfo != null) {
RateLimitDialog(
rateLimitInfo = state.rateLimitInfo,
isAuthenticated = state.isLoggedIn,
onDismiss = {
viewModel.onAction(MainAction.DismissRateLimitDialog)
},
onSignIn = {
viewModel.onAction(MainAction.DismissRateLimitDialog)

navBackStack.navigate(GithubStoreGraph.AuthenticationScreen)
}
)
if (state.showRateLimitDialog) {
state.rateLimitInfo?.let {
RateLimitDialog(
rateLimitInfo = it,
isAuthenticated = state.isLoggedIn,
onDismiss = {
viewModel.onAction(MainAction.DismissRateLimitDialog)
},
onSignIn = {
viewModel.onAction(MainAction.DismissRateLimitDialog)

navBackStack.navigate(GithubStoreGraph.AuthenticationScreen)
}
)
}
}

AppNavigation(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ import zed.rainxch.githubstore.core.presentation.res.*

@Composable
fun RateLimitDialog(
rateLimitInfo: RateLimitInfo?,
rateLimitInfo: RateLimitInfo,
isAuthenticated: Boolean,
onDismiss: () -> Unit,
onSignIn: () -> Unit
) {
val timeUntilReset = remember(rateLimitInfo) {
rateLimitInfo?.timeUntilReset()?.inWholeMinutes?.toInt()
rateLimitInfo.timeUntilReset().inWholeMinutes.toInt()
}

AlertDialog(
Expand Down Expand Up @@ -59,12 +59,12 @@ fun RateLimitDialog(
text = if (isAuthenticated) {
stringResource(
Res.string.rate_limit_used_all,
rateLimitInfo?.limit ?: 0
rateLimitInfo.limit
)
} else {
stringResource(
Res.string.rate_limit_used_all_free,
rateLimitInfo?.limit ?: 0
60
)
},
style = MaterialTheme.typography.bodyMedium,
Expand All @@ -74,7 +74,7 @@ fun RateLimitDialog(
Text(
text = stringResource(
Res.string.rate_limit_resets_in_minutes,
timeUntilReset ?: 0
timeUntilReset
),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
Expand All @@ -83,6 +83,7 @@ fun RateLimitDialog(

if (!isAuthenticated) {
Spacer(modifier = Modifier.height(8.dp))

Text(
text = stringResource(Res.string.rate_limit_tip_sign_in),
style = MaterialTheme.typography.bodySmall,
Expand Down Expand Up @@ -127,7 +128,11 @@ fun RateLimitDialog(
fun RateLimitDialogPreview() {
GithubStoreTheme {
RateLimitDialog(
rateLimitInfo = null,
rateLimitInfo = RateLimitInfo(
limit = 1000,
remaining = 2000,
resetTimestamp = 0L,
),
isAuthenticated = false,
onDismiss = {

Expand Down
Loading