diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0bf3e078d..e9bcc3f1c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -103,6 +103,15 @@ + + + + @@ -121,7 +130,6 @@ - diff --git a/app/src/main/java/com/aurora/store/ComposeActivity.kt b/app/src/main/java/com/aurora/store/ComposeActivity.kt index b98e6b6af..5ca6de392 100644 --- a/app/src/main/java/com/aurora/store/ComposeActivity.kt +++ b/app/src/main/java/com/aurora/store/ComposeActivity.kt @@ -152,7 +152,7 @@ class ComposeActivity : FragmentActivity() { } private fun resolveStartDestination(): Screen { - // Parcel-based navigation (e.g. from NotificationUtil) + // Parcel-based navigation (e.g. from NotificationUtil or DeepLinkConfirmActivity) IntentCompat.getParcelableExtra(intent, Screen.PARCEL_KEY, Screen::class.java) ?.let { return it } diff --git a/app/src/main/java/com/aurora/store/DeepLinkConfirmActivity.kt b/app/src/main/java/com/aurora/store/DeepLinkConfirmActivity.kt new file mode 100644 index 000000000..49ef5f3b4 --- /dev/null +++ b/app/src/main/java/com/aurora/store/DeepLinkConfirmActivity.kt @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store + +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.fragment.app.FragmentActivity +import com.aurora.store.compose.navigation.Screen +import com.aurora.store.compose.theme.AuroraTheme +import com.aurora.store.compose.ui.sheets.DeepLinkConfirmSheet +import com.aurora.store.util.Preferences + +/** + * Translucent trampoline that gates external [Intent.ACTION_VIEW] app/developer listing deep links + * (market:// and play.google.com links). These are the vector ads exploit to launch Aurora into a + * listing without intent, so a Play Store-style confirmation sheet is shown floating over the + * launching app before forwarding to [ComposeActivity]. When the user has opted out, or the intent + * doesn't resolve to a listing, it forwards immediately without prompting. + */ +class DeepLinkConfirmActivity : FragmentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + val target = resolveDeepLink() + val shouldConfirm = target != null && + Preferences.getBoolean(this, Preferences.PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK, true) + + if (!shouldConfirm) { + forwardToAurora(target) + return + } + + val referrerLabel = resolveReferrerLabel() + setContent { + AuroraTheme { + DeepLinkConfirmSheet( + targetLabel = target.deepLinkLabel(), + sourceLabel = referrerLabel, + onOpen = { forwardToAurora(target) }, + onDismiss = { finish() } + ) + } + } + } + + /** + * Resolves the listing requested by the incoming ACTION_VIEW intent, or null when the intent + * carries no app/developer id. + */ + private fun resolveDeepLink(): Screen? { + if (intent.action != Intent.ACTION_VIEW) return null + + val data = intent.data + val path = data?.path.orEmpty() + val id = data?.getQueryParameter("id") ?: return null + return when { + path.contains("/apps/dev") -> Screen.DevProfile(id) + else -> Screen.AppDetails(id) + } + } + + /** + * Best-effort human-readable name of the app that fired the intent, derived from the activity + * referrer. Resolves an android-app:// referrer to its app label, falling back to the raw host. + * Returns null when no referrer is available. + */ + private fun resolveReferrerLabel(): String? { + val ref = referrer ?: return null + val pkg = if (ref.scheme == "android-app") ref.host else null + if (pkg != null) { + return runCatching { + packageManager.getApplicationLabel( + packageManager.getApplicationInfo(pkg, 0) + ).toString() + }.getOrDefault(pkg) + } + return ref.host ?: ref.toString() + } + + private fun forwardToAurora(target: Screen?) { + startActivity( + Intent(this, ComposeActivity::class.java).apply { + target?.let { putExtra(Screen.PARCEL_KEY, it) } + // Start ComposeActivity fresh so the parcel is honoured even when Aurora is already + // running; without this a reused instance keeps its current screen. Mirrors the + // deep-link PendingIntents in NotificationUtil. + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + ) + finish() + } + + private fun Screen.deepLinkLabel(): String = when (this) { + is Screen.AppDetails -> packageName + is Screen.DevProfile -> developerId + else -> "" + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/preferences/security/SecurityPreferenceScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/preferences/security/SecurityPreferenceScreen.kt index 134fff43a..77fa2ac5c 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/preferences/security/SecurityPreferenceScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/preferences/security/SecurityPreferenceScreen.kt @@ -30,6 +30,7 @@ import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.util.AppLockAuthenticator import com.aurora.store.util.Preferences import com.aurora.store.util.Preferences.PREFERENCE_APP_LOCK_ENABLED +import com.aurora.store.util.Preferences.PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK import com.aurora.store.util.save @Composable @@ -43,6 +44,11 @@ private fun ScreenContent() { var appLockEnabled by remember { mutableStateOf(Preferences.getBoolean(context, PREFERENCE_APP_LOCK_ENABLED, false)) } + var confirmDeepLink by remember { + mutableStateOf( + Preferences.getBoolean(context, PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK, true) + ) + } fun setAppLock(enabled: Boolean) { // Refuse to enable the lock when the device has no biometric or screen-lock to fall back on @@ -54,6 +60,11 @@ private fun ScreenContent() { context.save(PREFERENCE_APP_LOCK_ENABLED, enabled) } + fun setConfirmDeepLink(enabled: Boolean) { + confirmDeepLink = enabled + context.save(PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK, enabled) + } + Scaffold( topBar = { TopAppBar(title = stringResource(R.string.title_security)) } ) { paddingValues -> @@ -75,6 +86,20 @@ private fun ScreenContent() { } ) } + + item { + ListItem( + modifier = Modifier.clickable { setConfirmDeepLink(!confirmDeepLink) }, + headlineContent = { Text(stringResource(R.string.confirm_deeplink_title)) }, + supportingContent = { Text(stringResource(R.string.confirm_deeplink_summary)) }, + trailingContent = { + Switch( + checked = confirmDeepLink, + onCheckedChange = { setConfirmDeepLink(it) } + ) + } + ) + } } } } diff --git a/app/src/main/java/com/aurora/store/compose/ui/sheets/DeepLinkConfirmSheet.kt b/app/src/main/java/com/aurora/store/compose/ui/sheets/DeepLinkConfirmSheet.kt new file mode 100644 index 000000000..f8d91b2d0 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/sheets/DeepLinkConfirmSheet.kt @@ -0,0 +1,140 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.sheets + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import com.aurora.store.R + +/** + * Play Store-style bottom sheet shown before opening an app/developer listing from an external + * deep link. Requires an explicit tap so ads cannot silently launch Aurora into a listing. + * + * @param targetLabel Package name or developer id the link points to + * @param sourceLabel Human-readable name of the app that fired the link, or null if unknown + * @param onOpen Invoked when the user confirms opening the listing + * @param onDismiss Invoked when the user cancels or dismisses the sheet + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeepLinkConfirmSheet( + targetLabel: String, + sourceLabel: String?, + onOpen: () -> Unit, + onDismiss: () -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_medium) + ) + ) { + Image( + painter = painterResource(R.drawable.ic_logo_alt), + contentDescription = null, + modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)) + ) + Text( + text = stringResource(R.string.confirm_deeplink_sheet_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f) + ) + } + + HorizontalDivider( + modifier = Modifier.padding(vertical = dimensionResource(R.dimen.spacing_xsmall)) + ) + + Column( + modifier = Modifier.padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ), + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_xsmall) + ) + ) { + Text( + text = stringResource(R.string.confirm_deeplink_sheet_message), + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = targetLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (!sourceLabel.isNullOrBlank()) { + Text( + text = stringResource(R.string.confirm_deeplink_sheet_source, sourceLabel), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.spacing_xsmall), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text(text = stringResource(R.string.action_cancel)) + } + TextButton(onClick = onOpen) { + Text(text = stringResource(R.string.action_open)) + } + } + + Spacer(Modifier.navigationBarsPadding()) + } + } +} diff --git a/app/src/main/java/com/aurora/store/util/Preferences.kt b/app/src/main/java/com/aurora/store/util/Preferences.kt index e01de6560..8741e82c4 100644 --- a/app/src/main/java/com/aurora/store/util/Preferences.kt +++ b/app/src/main/java/com/aurora/store/util/Preferences.kt @@ -81,6 +81,8 @@ object Preferences { const val PREFERENCE_APP_LOCK_ENABLED = "PREFERENCE_APP_LOCK_ENABLED" const val PREFERENCE_APP_LOCK_TIMEOUT = "PREFERENCE_APP_LOCK_TIMEOUT" + const val PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK = "PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK" + private var prefs: SharedPreferences? = null fun getPrefs(context: Context): SharedPreferences = when (BuildConfig.FLAVOR) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fe74bacc8..571357842 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -191,6 +191,11 @@ Unlock Unlock Aurora Store Enter phone screen lock pattern, PIN, password or fingerprint + Confirm external links + Ask before opening an app listing from a link, preventing ads from launching Aurora Store without your consent. + Continue to Aurora Store? + An external app wants to open Aurora Store + Opened from %1$s "Aurora Services is available and ready to install." Install Aurora Services 1.0.9 or above, or change the installer. Set up Aurora Services and grant all permissions first. diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index cc9850a12..f5a9b060e 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -35,6 +35,14 @@ true + + +