Add confirmation gate for external app-listing deep links
Ad networks can exploit Aurora's market:// and play.google.com VIEW intent-filters to launch the app straight into a listing without user intent (issue #1450). Route these external deep links through a new translucent DeepLinkConfirmActivity that shows a Play Store-style bottom sheet over the launching app; the listing only opens after an explicit tap, defeating auto-launch regardless of referrer. - DeepLinkConfirmActivity: resolves the listing, shows the sheet, then forwards to ComposeActivity via the Screen parcel (Open) or finishes (Cancel). Forwards directly when the user opts out. - DeepLinkConfirmSheet: bottom sheet matching the existing sheets/ convention; shows the target id and the launching app when known. - Move the VIEW intent-filters off ComposeActivity onto the new activity, using a transparent edge-to-edge translucent theme. - Add a "Confirm external links" toggle in Security (default on).
This commit is contained in:
@@ -103,6 +103,15 @@
|
|||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SHOW_APP_INFO" />
|
<action android:name="android.intent.action.SHOW_APP_INFO" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- Confirmation gate for external app-listing deep links (market:// & play.google.com) -->
|
||||||
|
<activity
|
||||||
|
android:name=".DeepLinkConfirmActivity"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:theme="@style/AppTheme.Translucent.Transparent">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
@@ -121,7 +130,6 @@
|
|||||||
<data android:scheme="https" />
|
<data android:scheme="https" />
|
||||||
<data android:host="market.android.com" />
|
<data android:host="market.android.com" />
|
||||||
<data android:host="play.google.com" />
|
<data android:host="play.google.com" />
|
||||||
<data android:host="play.google.com" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ class ComposeActivity : FragmentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveStartDestination(): Screen {
|
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)
|
IntentCompat.getParcelableExtra(intent, Screen.PARCEL_KEY, Screen::class.java)
|
||||||
?.let { return it }
|
?.let { return it }
|
||||||
|
|
||||||
|
|||||||
105
app/src/main/java/com/aurora/store/DeepLinkConfirmActivity.kt
Normal file
105
app/src/main/java/com/aurora/store/DeepLinkConfirmActivity.kt
Normal file
@@ -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 -> ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import com.aurora.store.compose.preview.ThemePreviewProvider
|
|||||||
import com.aurora.store.util.AppLockAuthenticator
|
import com.aurora.store.util.AppLockAuthenticator
|
||||||
import com.aurora.store.util.Preferences
|
import com.aurora.store.util.Preferences
|
||||||
import com.aurora.store.util.Preferences.PREFERENCE_APP_LOCK_ENABLED
|
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
|
import com.aurora.store.util.save
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -43,6 +44,11 @@ private fun ScreenContent() {
|
|||||||
var appLockEnabled by remember {
|
var appLockEnabled by remember {
|
||||||
mutableStateOf(Preferences.getBoolean(context, PREFERENCE_APP_LOCK_ENABLED, false))
|
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) {
|
fun setAppLock(enabled: Boolean) {
|
||||||
// Refuse to enable the lock when the device has no biometric or screen-lock to fall back on
|
// 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)
|
context.save(PREFERENCE_APP_LOCK_ENABLED, enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setConfirmDeepLink(enabled: Boolean) {
|
||||||
|
confirmDeepLink = enabled
|
||||||
|
context.save(PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = { TopAppBar(title = stringResource(R.string.title_security)) }
|
topBar = { TopAppBar(title = stringResource(R.string.title_security)) }
|
||||||
) { paddingValues ->
|
) { 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) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,8 @@ object Preferences {
|
|||||||
const val PREFERENCE_APP_LOCK_ENABLED = "PREFERENCE_APP_LOCK_ENABLED"
|
const val PREFERENCE_APP_LOCK_ENABLED = "PREFERENCE_APP_LOCK_ENABLED"
|
||||||
const val PREFERENCE_APP_LOCK_TIMEOUT = "PREFERENCE_APP_LOCK_TIMEOUT"
|
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
|
private var prefs: SharedPreferences? = null
|
||||||
|
|
||||||
fun getPrefs(context: Context): SharedPreferences = when (BuildConfig.FLAVOR) {
|
fun getPrefs(context: Context): SharedPreferences = when (BuildConfig.FLAVOR) {
|
||||||
|
|||||||
@@ -191,6 +191,11 @@
|
|||||||
<string name="app_lock_unlock">Unlock</string>
|
<string name="app_lock_unlock">Unlock</string>
|
||||||
<string name="app_lock_prompt_title">Unlock Aurora Store</string>
|
<string name="app_lock_prompt_title">Unlock Aurora Store</string>
|
||||||
<string name="app_lock_prompt_subtitle">Enter phone screen lock pattern, PIN, password or fingerprint</string>
|
<string name="app_lock_prompt_subtitle">Enter phone screen lock pattern, PIN, password or fingerprint</string>
|
||||||
|
<string name="confirm_deeplink_title">Confirm external links</string>
|
||||||
|
<string name="confirm_deeplink_summary">Ask before opening an app listing from a link, preventing ads from launching Aurora Store without your consent.</string>
|
||||||
|
<string name="confirm_deeplink_sheet_title">Continue to Aurora Store?</string>
|
||||||
|
<string name="confirm_deeplink_sheet_message">An external app wants to open Aurora Store</string>
|
||||||
|
<string name="confirm_deeplink_sheet_source">Opened from %1$s</string>
|
||||||
<string name="installer_service_available">"Aurora Services is available and ready to install."</string>
|
<string name="installer_service_available">"Aurora Services is available and ready to install."</string>
|
||||||
<string name="installer_service_unavailable">Install Aurora Services 1.0.9 or above, or change the installer.</string>
|
<string name="installer_service_unavailable">Install Aurora Services 1.0.9 or above, or change the installer.</string>
|
||||||
<string name="installer_service_misconfigured">Set up Aurora Services and grant all permissions first.</string>
|
<string name="installer_service_misconfigured">Set up Aurora Services and grant all permissions first.</string>
|
||||||
|
|||||||
@@ -35,6 +35,14 @@
|
|||||||
<item name="android:windowTranslucentNavigation">true</item>
|
<item name="android:windowTranslucentNavigation">true</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- Translucent variant with edge-to-edge transparent system bars (no status bar scrim) -->
|
||||||
|
<style name="AppTheme.Translucent.Transparent">
|
||||||
|
<item name="android:windowTranslucentStatus">false</item>
|
||||||
|
<item name="android:windowTranslucentNavigation">false</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
<style name="AppTheme.BottomSheetStyle" parent="Widget.Material3.BottomSheet" />
|
<style name="AppTheme.BottomSheetStyle" parent="Widget.Material3.BottomSheet" />
|
||||||
|
|
||||||
<style name="Chip.Filter" parent="@style/Widget.Material3.Chip.Filter" />
|
<style name="Chip.Filter" parent="@style/Widget.Material3.Chip.Filter" />
|
||||||
|
|||||||
Reference in New Issue
Block a user