Merge branch 'dev' into 'master'
dev Closes #1494 and #1499 See merge request AuroraOSS/AuroraStore!581
This commit is contained in:
391
app/schemas/com.aurora.store.data.room.AuroraDatabase/9.json
Normal file
391
app/schemas/com.aurora.store.data.room.AuroraDatabase/9.json
Normal file
@@ -0,0 +1,391 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 9,
|
||||
"identityHash": "61af7af3b2ff3f720722a2dca3c6d616",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "download",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `versionCode` INTEGER NOT NULL, `offerType` INTEGER NOT NULL, `isInstalled` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `size` INTEGER NOT NULL, `id` INTEGER NOT NULL, `downloadStatus` TEXT NOT NULL, `progress` INTEGER NOT NULL, `speed` INTEGER NOT NULL, `timeRemaining` INTEGER NOT NULL, `totalFiles` INTEGER NOT NULL, `downloadedFiles` INTEGER NOT NULL, `fileList` TEXT NOT NULL, `sharedLibs` TEXT NOT NULL, `targetSdk` INTEGER NOT NULL, `downloadedAt` INTEGER NOT NULL, `requiresGMS` INTEGER NOT NULL, PRIMARY KEY(`packageName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "packageName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "versionCode",
|
||||
"columnName": "versionCode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "offerType",
|
||||
"columnName": "offerType",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isInstalled",
|
||||
"columnName": "isInstalled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "iconURL",
|
||||
"columnName": "iconURL",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "size",
|
||||
"columnName": "size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "status",
|
||||
"columnName": "downloadStatus",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "progress",
|
||||
"columnName": "progress",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "speed",
|
||||
"columnName": "speed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timeRemaining",
|
||||
"columnName": "timeRemaining",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "totalFiles",
|
||||
"columnName": "totalFiles",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "downloadedFiles",
|
||||
"columnName": "downloadedFiles",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fileList",
|
||||
"columnName": "fileList",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sharedLibs",
|
||||
"columnName": "sharedLibs",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "targetSdk",
|
||||
"columnName": "targetSdk",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "downloadedAt",
|
||||
"columnName": "downloadedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "requiresGMS",
|
||||
"columnName": "requiresGMS",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"packageName"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "favourite",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `added` INTEGER NOT NULL, `mode` TEXT NOT NULL, PRIMARY KEY(`packageName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "packageName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "iconURL",
|
||||
"columnName": "iconURL",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "added",
|
||||
"columnName": "added",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mode",
|
||||
"columnName": "mode",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"packageName"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "update",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `versionCode` INTEGER NOT NULL, `versionName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `changelog` TEXT NOT NULL, `id` INTEGER NOT NULL, `developerName` TEXT NOT NULL, `size` INTEGER NOT NULL, `updatedOn` TEXT NOT NULL, `hasValidCert` INTEGER NOT NULL, `offerType` INTEGER NOT NULL, `fileList` TEXT NOT NULL, `sharedLibs` TEXT NOT NULL, `targetSdk` INTEGER NOT NULL, `isIncompatible` INTEGER NOT NULL, PRIMARY KEY(`packageName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "packageName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "versionCode",
|
||||
"columnName": "versionCode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "versionName",
|
||||
"columnName": "versionName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "iconURL",
|
||||
"columnName": "iconURL",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "changelog",
|
||||
"columnName": "changelog",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "developerName",
|
||||
"columnName": "developerName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "size",
|
||||
"columnName": "size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedOn",
|
||||
"columnName": "updatedOn",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasValidCert",
|
||||
"columnName": "hasValidCert",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "offerType",
|
||||
"columnName": "offerType",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fileList",
|
||||
"columnName": "fileList",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sharedLibs",
|
||||
"columnName": "sharedLibs",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "targetSdk",
|
||||
"columnName": "targetSdk",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isIncompatible",
|
||||
"columnName": "isIncompatible",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"packageName"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "ignored_update",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `ignoredVersionCode` INTEGER, PRIMARY KEY(`packageName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "packageName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ignoredVersionCode",
|
||||
"columnName": "ignoredVersionCode",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"packageName"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "review",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `accountEmail` TEXT NOT NULL, `title` TEXT NOT NULL, `comment` TEXT NOT NULL, `rating` INTEGER NOT NULL, `commentId` TEXT NOT NULL, `userName` TEXT NOT NULL, `userPhotoUrl` TEXT NOT NULL, `appVersion` TEXT NOT NULL, `timeStamp` INTEGER NOT NULL, `synced` INTEGER NOT NULL, PRIMARY KEY(`packageName`, `accountEmail`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "packageName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountEmail",
|
||||
"columnName": "accountEmail",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "comment",
|
||||
"columnName": "comment",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "rating",
|
||||
"columnName": "rating",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "commentId",
|
||||
"columnName": "commentId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userName",
|
||||
"columnName": "userName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userPhotoUrl",
|
||||
"columnName": "userPhotoUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "appVersion",
|
||||
"columnName": "appVersion",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timeStamp",
|
||||
"columnName": "timeStamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "synced",
|
||||
"columnName": "synced",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"packageName",
|
||||
"accountEmail"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '61af7af3b2ff3f720722a2dca3c6d616')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,16 @@
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SHOW_APP_INFO" />
|
||||
</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:taskAffinity=""
|
||||
android:theme="@style/AppTheme.Translucent.Transparent">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
@@ -121,7 +131,6 @@
|
||||
<data android:scheme="https" />
|
||||
<data android:host="market.android.com" />
|
||||
<data android:host="play.google.com" />
|
||||
<data android:host="play.google.com" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
||||
@@ -68,6 +68,10 @@ val isHuawei: Boolean
|
||||
Build.HARDWARE.lowercase(Locale.getDefault()).contains("kirin") ||
|
||||
Build.HARDWARE.lowercase(Locale.getDefault()).contains("hi3")
|
||||
|
||||
val isOneUI: Boolean
|
||||
get() = !getSystemProperty("ro.build.version.oneui").isNullOrBlank() ||
|
||||
Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
|
||||
@get:SuppressLint("PrivateApi")
|
||||
val isMiuiOptimizationDisabled: Boolean
|
||||
get() {
|
||||
|
||||
@@ -85,8 +85,13 @@ class AuroraApp : Application(), Configuration.Provider, SingletonImageLoader.Fa
|
||||
val themeStyle = Preferences.getInteger(this, Preferences.PREFERENCE_THEME_STYLE)
|
||||
setAppTheme(themeStyle)
|
||||
|
||||
// Apply dynamic colors to activities
|
||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||
// Apply dynamic colors to activities, unless disabled (opt-out, off by default on One UI)
|
||||
val dynamicColors = Preferences.getBoolean(
|
||||
this,
|
||||
Preferences.PREFERENCE_DYNAMIC_COLORS,
|
||||
Preferences.dynamicColorsDefault
|
||||
)
|
||||
if (dynamicColors) DynamicColors.applyToActivitiesIfAvailable(this)
|
||||
|
||||
// Required for Shizuku installer
|
||||
if (isPAndAbove) HiddenApiBypass.addHiddenApiExemptions("I", "L")
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
package com.aurora.store
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -152,22 +151,11 @@ class ComposeActivity : FragmentActivity() {
|
||||
}
|
||||
|
||||
private fun resolveStartDestination(): Screen {
|
||||
// Parcel-based navigation (e.g. from NotificationUtil)
|
||||
// Parcel-based navigation (e.g. from NotificationUtil or DeepLinkConfirmActivity, which
|
||||
// owns the external ACTION_VIEW market:// and play.google.com deep links)
|
||||
IntentCompat.getParcelableExtra(intent, Screen.PARCEL_KEY, Screen::class.java)
|
||||
?.let { return it }
|
||||
|
||||
// Deep links via ACTION_VIEW
|
||||
if (intent.action == Intent.ACTION_VIEW) {
|
||||
val data = intent.data
|
||||
val path = data?.path.orEmpty()
|
||||
val id = data?.getQueryParameter("id")
|
||||
return when {
|
||||
id != null && path.contains("/apps/dev") -> Screen.DevProfile(id)
|
||||
id != null -> Screen.AppDetails(id)
|
||||
else -> defaultStart()
|
||||
}
|
||||
}
|
||||
|
||||
// SEND / SHOW_APP_INFO — getPackageName() handles both
|
||||
intent.getPackageName()?.let { return Screen.AppDetails(it) }
|
||||
|
||||
|
||||
112
app/src/main/java/com/aurora/store/DeepLinkConfirmActivity.kt
Normal file
112
app/src/main/java/com/aurora/store/DeepLinkConfirmActivity.kt
Normal file
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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 id. The action keyword ("details", "dev" or "developer") is the last path segment
|
||||
* for play.google.com links and the host for market:// links. Both "dev" and "developer" links
|
||||
* may carry either a numeric developer id (curated developer stream) or a developer name
|
||||
* (publisher search), so the id is parsed to decide which one to open.
|
||||
*/
|
||||
private fun resolveDeepLink(): Screen? {
|
||||
if (intent.action != Intent.ACTION_VIEW) return null
|
||||
|
||||
val data = intent.data ?: return null
|
||||
val id = data.getQueryParameter("id") ?: return null
|
||||
return when (data.lastPathSegment ?: data.host) {
|
||||
"dev", "developer" -> when {
|
||||
id.toLongOrNull() != null -> Screen.DevProfile(id)
|
||||
else -> Screen.PublisherProfile(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
|
||||
is Screen.PublisherProfile -> publisherId
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,11 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.draw.clip
|
||||
@@ -30,49 +34,94 @@ import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import com.aurora.gplayapi.data.models.App
|
||||
import com.aurora.store.AuroraApp
|
||||
import com.aurora.store.R
|
||||
import com.aurora.store.compose.composable.app.AnimatedAppIcon
|
||||
import com.aurora.store.compose.preview.AppPreviewProvider
|
||||
import com.aurora.store.compose.preview.ThemePreviewProvider
|
||||
import com.aurora.store.compose.theme.colorGreen
|
||||
import com.aurora.store.compose.theme.colorRed
|
||||
import com.aurora.store.data.event.InstallerEvent
|
||||
import com.aurora.store.data.model.DownloadStatus
|
||||
import com.aurora.store.data.room.download.Download
|
||||
import com.aurora.store.data.room.favourite.Favourite
|
||||
import com.aurora.store.util.PackageUtil
|
||||
import kotlinx.coroutines.flow.filter
|
||||
|
||||
@Composable
|
||||
fun FavouriteListItem(
|
||||
modifier: Modifier = Modifier,
|
||||
favourite: Favourite,
|
||||
download: Download? = null,
|
||||
onClick: () -> Unit = {},
|
||||
onClear: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isInstalled = remember(favourite.packageName) {
|
||||
PackageUtil.isInstalled(context, favourite.packageName)
|
||||
|
||||
// Seed from a one-shot check, then keep it live so the installed tick reflects the app
|
||||
// being installed or removed without leaving the screen.
|
||||
var isInstalled by remember(favourite.packageName) {
|
||||
mutableStateOf(PackageUtil.isInstalled(context, favourite.packageName))
|
||||
}
|
||||
LaunchedEffect(favourite.packageName) {
|
||||
AuroraApp.events.installerEvent
|
||||
.filter { it.packageName == favourite.packageName }
|
||||
.collect { event ->
|
||||
when (event) {
|
||||
is InstallerEvent.Installed -> isInstalled = true
|
||||
is InstallerEvent.Uninstalled -> isInstalled = false
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show download/install progress on the row; only non-finished statuses count as in
|
||||
// progress, so a downloaded-but-not-installed (COMPLETED) app stays in its idle state.
|
||||
val inProgress = download != null && !download.isFinished
|
||||
val progress = if (download?.status == DownloadStatus.DOWNLOADING) {
|
||||
download.progress.toFloat()
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
val statusText = when {
|
||||
download?.status == DownloadStatus.DOWNLOADING ->
|
||||
"${stringResource(R.string.status_downloading)} • ${download.progress}%"
|
||||
|
||||
inProgress && download != null -> stringResource(download.status.localized)
|
||||
else -> DateUtils.formatDateTime(context, favourite.added, DateUtils.FORMAT_SHOW_DATE)
|
||||
}
|
||||
|
||||
RemovableListItem(onRemove = onClear) { triggerRemove ->
|
||||
AuroraListItem(
|
||||
modifier = modifier,
|
||||
headline = favourite.displayName,
|
||||
supporting = favourite.packageName,
|
||||
tertiary = DateUtils.formatDateTime(
|
||||
context,
|
||||
favourite.added,
|
||||
DateUtils.FORMAT_SHOW_DATE
|
||||
),
|
||||
tertiary = statusText,
|
||||
headlineStyle = MaterialTheme.typography.bodyMedium,
|
||||
onClick = onClick,
|
||||
leading = {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(favourite.iconURL)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.requiredSize(dimensionResource(R.dimen.icon_size_medium))
|
||||
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
|
||||
)
|
||||
if (inProgress) {
|
||||
AnimatedAppIcon(
|
||||
modifier = Modifier
|
||||
.requiredSize(dimensionResource(R.dimen.icon_size_medium)),
|
||||
iconUrl = favourite.iconURL,
|
||||
inProgress = true,
|
||||
progress = progress
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(favourite.iconURL)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.requiredSize(dimensionResource(R.dimen.icon_size_medium))
|
||||
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
|
||||
)
|
||||
}
|
||||
},
|
||||
trailing = {
|
||||
Row(
|
||||
@@ -81,7 +130,7 @@ fun FavouriteListItem(
|
||||
dimensionResource(R.dimen.spacing_xsmall)
|
||||
)
|
||||
) {
|
||||
if (isInstalled) {
|
||||
if (isInstalled && !inProgress) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_check),
|
||||
contentDescription = stringResource(R.string.title_installed),
|
||||
@@ -107,3 +156,18 @@ fun FavouriteListItem(
|
||||
private fun FavouriteListItemPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
|
||||
FavouriteListItem(favourite = Favourite.fromApp(app, Favourite.Mode.MANUAL))
|
||||
}
|
||||
|
||||
@PreviewWrapper(ThemePreviewProvider::class)
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun FavouriteListItemDownloadingPreview(
|
||||
@PreviewParameter(AppPreviewProvider::class) app: App
|
||||
) {
|
||||
FavouriteListItem(
|
||||
favourite = Favourite.fromApp(app, Favourite.Mode.MANUAL),
|
||||
download = Download.fromApp(app).copy(
|
||||
status = DownloadStatus.DOWNLOADING,
|
||||
progress = 45
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,12 +15,12 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
@@ -47,15 +47,13 @@ fun PageIndicator(modifier: Modifier = Modifier, totalPages: Int, currentPage: I
|
||||
Alignment.CenterHorizontally
|
||||
)
|
||||
) {
|
||||
val selectedColor = MaterialTheme.colorScheme.primary
|
||||
val unselectedColor = MaterialTheme.colorScheme.outlineVariant
|
||||
repeat(totalPages) { iteration ->
|
||||
val page = stringResource(R.string.page, iteration)
|
||||
val isSelected = currentPage == iteration
|
||||
val color by animateColorAsState(
|
||||
targetValue = if (isSelected) {
|
||||
Color.DarkGray
|
||||
} else {
|
||||
Color.LightGray
|
||||
},
|
||||
targetValue = if (isSelected) selectedColor else unselectedColor,
|
||||
animationSpec = tween()
|
||||
)
|
||||
val size by animateDpAsState(
|
||||
|
||||
@@ -43,8 +43,10 @@ fun AppUpdateItem(
|
||||
onCancel: () -> Unit = {},
|
||||
onUnignore: (() -> Unit)? = null
|
||||
) {
|
||||
// Only the INSTALLING status shows as "Installing"; a downloaded-but-not-installed
|
||||
// (COMPLETED) app falls back to the "Update" action.
|
||||
val inProgress = download != null && !download.isFinished
|
||||
val pendingInstall = download?.status == DownloadStatus.COMPLETED
|
||||
val installing = download?.status == DownloadStatus.INSTALLING
|
||||
val progress = if (download?.status == DownloadStatus.DOWNLOADING) {
|
||||
download.progress.toFloat()
|
||||
} else {
|
||||
@@ -65,7 +67,7 @@ fun AppUpdateItem(
|
||||
AnimatedAppIcon(
|
||||
modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)),
|
||||
iconUrl = update.iconURL,
|
||||
inProgress = inProgress || pendingInstall,
|
||||
inProgress = inProgress,
|
||||
progress = progress
|
||||
)
|
||||
}
|
||||
@@ -100,7 +102,7 @@ fun AppUpdateItem(
|
||||
}
|
||||
}
|
||||
|
||||
pendingInstall -> {
|
||||
installing -> {
|
||||
OutlinedButton(onClick = {}, enabled = false) {
|
||||
Text(stringResource(R.string.action_installing))
|
||||
}
|
||||
|
||||
@@ -216,6 +216,13 @@ fun NavDisplay(startDestination: NavKey) {
|
||||
)
|
||||
}
|
||||
|
||||
entry<Screen.PublisherProfile> { screen ->
|
||||
DevProfileScreen(
|
||||
publisherId = screen.publisherId,
|
||||
onNavigateTo = ::navigate
|
||||
)
|
||||
}
|
||||
|
||||
entry<Screen.PermissionRationale> { screen ->
|
||||
PermissionRationaleScreen(
|
||||
requiredPermissions = screen.requiredPermissions
|
||||
|
||||
@@ -29,6 +29,9 @@ sealed class Screen : NavKey, Parcelable {
|
||||
@Serializable
|
||||
data class DevProfile(val developerId: String) : Screen()
|
||||
|
||||
@Serializable
|
||||
data class PublisherProfile(val publisherId: String) : Screen()
|
||||
|
||||
@Serializable
|
||||
data class AppDetails(val packageName: String) : Screen()
|
||||
|
||||
|
||||
@@ -5,10 +5,101 @@
|
||||
|
||||
package com.aurora.store.compose.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
|
||||
val warningColor = Color(0xFFFF7600) // Amber
|
||||
val successColor = Color(0xFF1B8738) // Green
|
||||
/**
|
||||
* Whether the active [MaterialTheme] is dark.
|
||||
*
|
||||
* Derived from the resolved color scheme rather than [androidx.compose.foundation.isSystemInDarkTheme]
|
||||
* so it stays correct when the user forces a light/dark theme that differs from the system setting.
|
||||
*/
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
private fun isAppInDarkTheme(): Boolean = MaterialTheme.colorScheme.surface.luminance() < 0.5f
|
||||
|
||||
val colorGreen = Color(0xFF4CAF50)
|
||||
val colorRed = Color(0xFFF44336)
|
||||
/**
|
||||
* Amber used to flag warnings/caveats. Lightened in dark theme for adequate contrast.
|
||||
*/
|
||||
val warningColor: Color
|
||||
@Composable @ReadOnlyComposable
|
||||
get() = if (isAppInDarkTheme()) Color(0xFFFFB74D) else Color(0xFFFF7600)
|
||||
|
||||
/**
|
||||
* Green used to flag positive/success states. Lightened in dark theme for adequate contrast.
|
||||
*/
|
||||
val successColor: Color
|
||||
@Composable @ReadOnlyComposable
|
||||
get() = if (isAppInDarkTheme()) Color(0xFF5BD27A) else Color(0xFF1B8738)
|
||||
|
||||
val colorGreen: Color
|
||||
@Composable @ReadOnlyComposable
|
||||
get() = if (isAppInDarkTheme()) Color(0xFF81C784) else Color(0xFF388E3C)
|
||||
|
||||
val colorRed: Color
|
||||
@Composable @ReadOnlyComposable
|
||||
get() = if (isAppInDarkTheme()) Color(0xFFE57373) else Color(0xFFD32F2F)
|
||||
|
||||
/**
|
||||
* Brand color schemes seeded from Aurora's accent (#6C63FF), used on devices that don't support
|
||||
* dynamic color (Android 11 and below) so the full palette stays on-brand instead of falling back
|
||||
* to Material's default purple baseline.
|
||||
*/
|
||||
val BrandLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF6C63FF),
|
||||
onPrimary = Color(0xFFFFFFFF),
|
||||
primaryContainer = Color(0xFFE4DFFF),
|
||||
onPrimaryContainer = Color(0xFF1A0066),
|
||||
secondary = Color(0xFF5C5D72),
|
||||
onSecondary = Color(0xFFFFFFFF),
|
||||
secondaryContainer = Color(0xFFE1E0F9),
|
||||
onSecondaryContainer = Color(0xFF191A2C),
|
||||
tertiary = Color(0xFF78536B),
|
||||
onTertiary = Color(0xFFFFFFFF),
|
||||
tertiaryContainer = Color(0xFFFFD8EE),
|
||||
onTertiaryContainer = Color(0xFF2E1126),
|
||||
background = Color(0xFFFCF8FF),
|
||||
onBackground = Color(0xFF1B1B21),
|
||||
surface = Color(0xFFFCF8FF),
|
||||
onSurface = Color(0xFF1B1B21),
|
||||
surfaceVariant = Color(0xFFE4E1EC),
|
||||
onSurfaceVariant = Color(0xFF47464F),
|
||||
outline = Color(0xFF777680),
|
||||
outlineVariant = Color(0xFFC8C5D0),
|
||||
error = Color(0xFFBA1A1A),
|
||||
onError = Color(0xFFFFFFFF),
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF410002)
|
||||
)
|
||||
|
||||
val BrandDarkColorScheme = darkColorScheme(
|
||||
primary = Color(0xFFC8BFFF),
|
||||
onPrimary = Color(0xFF31149C),
|
||||
primaryContainer = Color(0xFF534BD6),
|
||||
onPrimaryContainer = Color(0xFFE4DFFF),
|
||||
secondary = Color(0xFFC5C4DD),
|
||||
onSecondary = Color(0xFF2E2F42),
|
||||
secondaryContainer = Color(0xFF444559),
|
||||
onSecondaryContainer = Color(0xFFE1E0F9),
|
||||
tertiary = Color(0xFFE8B9D5),
|
||||
onTertiary = Color(0xFF46263B),
|
||||
tertiaryContainer = Color(0xFF5E3C52),
|
||||
onTertiaryContainer = Color(0xFFFFD8EE),
|
||||
background = Color(0xFF131318),
|
||||
onBackground = Color(0xFFE4E1E9),
|
||||
surface = Color(0xFF131318),
|
||||
onSurface = Color(0xFFE4E1E9),
|
||||
surfaceVariant = Color(0xFF47464F),
|
||||
onSurfaceVariant = Color(0xFFC8C5D0),
|
||||
outline = Color(0xFF918F9A),
|
||||
outlineVariant = Color(0xFF47464F),
|
||||
error = Color(0xFFFFB4AB),
|
||||
onError = Color(0xFF690005),
|
||||
errorContainer = Color(0xFF93000A),
|
||||
onErrorContainer = Color(0xFFFFDAD6)
|
||||
)
|
||||
|
||||
@@ -11,27 +11,23 @@ import android.os.Build
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialExpressiveTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.aurora.store.R
|
||||
import com.aurora.store.util.Preferences
|
||||
|
||||
/**
|
||||
* App theme for Aurora Store based on [MaterialTheme]
|
||||
* App theme for Aurora Store based on [MaterialExpressiveTheme]
|
||||
*/
|
||||
@Composable
|
||||
fun AuroraTheme(content: @Composable () -> Unit) {
|
||||
@@ -40,28 +36,42 @@ fun AuroraTheme(content: @Composable () -> Unit) {
|
||||
var themeStyle by remember {
|
||||
mutableIntStateOf(Preferences.getInteger(context, Preferences.PREFERENCE_THEME_STYLE))
|
||||
}
|
||||
var dynamicColor by remember {
|
||||
mutableStateOf(
|
||||
Preferences.getBoolean(
|
||||
context,
|
||||
Preferences.PREFERENCE_DYNAMIC_COLORS,
|
||||
Preferences.dynamicColorsDefault
|
||||
)
|
||||
)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
val prefs = Preferences.getPrefs(context)
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == Preferences.PREFERENCE_THEME_STYLE) {
|
||||
themeStyle = Preferences.getInteger(context, Preferences.PREFERENCE_THEME_STYLE)
|
||||
when (key) {
|
||||
Preferences.PREFERENCE_THEME_STYLE ->
|
||||
themeStyle = Preferences.getInteger(context, key)
|
||||
|
||||
Preferences.PREFERENCE_DYNAMIC_COLORS ->
|
||||
dynamicColor =
|
||||
Preferences.getBoolean(context, key, Preferences.dynamicColorsDefault)
|
||||
}
|
||||
}
|
||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||
onDispose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }
|
||||
}
|
||||
val isDynamicColorSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
val useDynamicColor = dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
|
||||
val lightScheme = if (isDynamicColorSupported) {
|
||||
val lightScheme = if (useDynamicColor) {
|
||||
dynamicLightColorScheme(context)
|
||||
} else {
|
||||
lightColorScheme(primary = colorResource(id = R.color.colorAccent))
|
||||
BrandLightColorScheme
|
||||
}
|
||||
|
||||
val darkScheme = if (isDynamicColorSupported) {
|
||||
val darkScheme = if (useDynamicColor) {
|
||||
dynamicDarkColorScheme(context)
|
||||
} else {
|
||||
darkColorScheme(primary = colorResource(id = R.color.colorAccent))
|
||||
BrandDarkColorScheme
|
||||
}
|
||||
|
||||
val colorScheme = when (themeStyle) {
|
||||
@@ -77,21 +87,22 @@ fun AuroraTheme(content: @Composable () -> Unit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Side effect to update the system bars to be transparent and match the theme's light/dark mode.
|
||||
* This is necessary on OEM devices that don't properly support dynamic theming and may have issues with light/dark status bar icons.
|
||||
* Keep the status/navigation bar icon appearance in sync with the theme's light/dark mode.
|
||||
*
|
||||
* The bars themselves are kept transparent and edge-to-edge by [androidx.activity.enableEdgeToEdge]
|
||||
* (called once in the host activity); we deliberately don't touch `window.statusBarColor` /
|
||||
* `navigationBarColor` here since those setters are deprecated no-ops on Android 15+ and would
|
||||
* otherwise strip the system contrast scrim. Re-applying the appearance is still required because
|
||||
* the user can force a light/dark theme that differs from the system, and that isn't known when
|
||||
* edge-to-edge is first configured.
|
||||
*/
|
||||
val view = LocalView.current
|
||||
val activity = LocalActivity.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val currentActivity = activity ?: return@SideEffect
|
||||
val window = currentActivity.window
|
||||
|
||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||
|
||||
WindowCompat
|
||||
.getInsetsController(window, view)
|
||||
.getInsetsController(currentActivity.window, view)
|
||||
.apply {
|
||||
isAppearanceLightStatusBars = !darkTheme
|
||||
isAppearanceLightNavigationBars = !darkTheme
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.aurora.store.compose.ui.commons
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
||||
import com.aurora.store.R
|
||||
import com.aurora.store.compose.preview.ThemePreviewProvider
|
||||
|
||||
/**
|
||||
* Confirmation dialog for bulk-installing all favourite apps.
|
||||
* @param count Number of favourites that will be processed
|
||||
* @param onConfirm Callback on confirmation
|
||||
* @param onDismiss Callback on dismissal
|
||||
*/
|
||||
@Composable
|
||||
fun InstallFavouritesDialog(
|
||||
count: Int = 0,
|
||||
onConfirm: () -> Unit = {},
|
||||
onDismiss: () -> Unit = {}
|
||||
) {
|
||||
AlertDialog(
|
||||
title = { Text(text = stringResource(R.string.title_install_favourites)) },
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(
|
||||
dimensionResource(R.dimen.spacing_small)
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.install_favourites_summary, count))
|
||||
Text(text = stringResource(R.string.install_favourites_warning))
|
||||
}
|
||||
},
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(text = stringResource(R.string.action_install))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(text = stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewWrapper(ThemePreviewProvider::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun InstallFavouritesDialogPreview() {
|
||||
InstallFavouritesDialog(count = 5)
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
package com.aurora.store.compose.ui.details
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -89,6 +90,7 @@ import com.aurora.store.compose.ui.details.composable.RatingAndReviews
|
||||
import com.aurora.store.compose.ui.details.composable.Screenshots
|
||||
import com.aurora.store.compose.ui.details.composable.Tags
|
||||
import com.aurora.store.compose.ui.details.composable.Testing
|
||||
import com.aurora.store.compose.ui.details.composable.UserReview
|
||||
import com.aurora.store.compose.ui.details.menu.AppDetailsMenu
|
||||
import com.aurora.store.compose.ui.details.menu.MenuItem
|
||||
import com.aurora.store.compose.ui.details.navigation.ExtraScreen
|
||||
@@ -120,6 +122,7 @@ fun AppDetailsScreen(
|
||||
val app by viewModel.app.collectAsStateWithLifecycle()
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val featuredReviews by viewModel.featuredReviews.collectAsStateWithLifecycle()
|
||||
val userReview by viewModel.userReview.collectAsStateWithLifecycle()
|
||||
val favorite by viewModel.favourite.collectAsStateWithLifecycle()
|
||||
val exodusReport by viewModel.exodusReport.collectAsStateWithLifecycle()
|
||||
val dataSafetyReport by viewModel.dataSafetyReport.collectAsStateWithLifecycle()
|
||||
@@ -129,12 +132,22 @@ fun AppDetailsScreen(
|
||||
|
||||
LaunchedEffect(key1 = packageName) { viewModel.fetchAppDetails(packageName) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.reviewPosted.collect { success ->
|
||||
context.toast(
|
||||
if (success) R.string.toast_rated_success else R.string.toast_rated_failed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
app?.let { loadedApp ->
|
||||
installError?.let { err ->
|
||||
InstallErrorSheet(
|
||||
app = loadedApp,
|
||||
error = err.error,
|
||||
extra = err.extra,
|
||||
isAnonymous = viewModel.authProvider.isAnonymous,
|
||||
onBuy = { openPlayStore(context, loadedApp.packageName) },
|
||||
onDismiss = viewModel::dismissInstallError
|
||||
)
|
||||
}
|
||||
@@ -161,6 +174,7 @@ fun AppDetailsScreen(
|
||||
ScreenContentApp(
|
||||
app = loadedApp,
|
||||
featuredReviews = featuredReviews,
|
||||
userReview = userReview,
|
||||
suggestionsBundle = suggestionsBundle,
|
||||
isFavorite = favorite,
|
||||
isAnonymous = viewModel.authProvider.isAnonymous,
|
||||
@@ -186,6 +200,14 @@ fun AppDetailsScreen(
|
||||
onTestingSubscriptionChange = { subscribe ->
|
||||
viewModel.updateTestingProgramStatus(packageName, subscribe)
|
||||
},
|
||||
onSubmitReview = { rating, title, comment ->
|
||||
viewModel.postAppReview(
|
||||
loadedApp.packageName,
|
||||
Review(title = title, comment = comment, rating = rating),
|
||||
loadedApp.testingProgram?.isSubscribed ?: false
|
||||
)
|
||||
},
|
||||
onDeleteReview = { viewModel.deleteAppReview(loadedApp) },
|
||||
forceSinglePane = forceSinglePane,
|
||||
onForceRestart = {
|
||||
val intent = Intent(context, ComposeActivity::class.java)
|
||||
@@ -198,6 +220,21 @@ fun AppDetailsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the given app's Play Store listing, preferring the Play Store app when available and
|
||||
* falling back to whichever activity can handle the web listing (e.g. a browser).
|
||||
*/
|
||||
private fun openPlayStore(context: Context, packageName: String) {
|
||||
val uri = "${Constants.SHARE_URL}$packageName".toUri()
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply { data = uri }
|
||||
|
||||
if (intent.resolveActivity(context.packageManager) != null) {
|
||||
context.startActivity(intent.apply { setPackage(Constants.PACKAGE_NAME_PLAY_STORE) })
|
||||
} else {
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stateKey(state: AppState, app: App?): String = when {
|
||||
state is AppState.Error -> "error"
|
||||
state is AppState.Loading || app == null -> "loading"
|
||||
@@ -241,6 +278,7 @@ private fun ScreenContentError(message: String? = null, onRetry: (() -> Unit)? =
|
||||
private fun ScreenContentApp(
|
||||
app: App,
|
||||
featuredReviews: List<Review> = emptyList(),
|
||||
userReview: Review? = null,
|
||||
suggestionsBundle: StreamBundle? = StreamBundle.EMPTY,
|
||||
isFavorite: Boolean = false,
|
||||
isAnonymous: Boolean = true,
|
||||
@@ -256,11 +294,18 @@ private fun ScreenContentApp(
|
||||
onUninstall: () -> Unit = {},
|
||||
onOpen: () -> Unit = {},
|
||||
onTestingSubscriptionChange: (subscribe: Boolean) -> Unit = {},
|
||||
onSubmitReview: (rating: Int, title: String, comment: String) -> Unit = { _, _, _ -> },
|
||||
onDeleteReview: () -> Unit = {},
|
||||
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2(),
|
||||
forceSinglePane: Boolean = false,
|
||||
onForceRestart: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// Anonymous accounts can't purchase, so a paid app can neither be installed nor manually
|
||||
// downloaded (any version) by them. Free apps are always acquirable.
|
||||
val canAcquire = app.isFree || !isAnonymous
|
||||
|
||||
var scaffoldDirective = calculatePaneScaffoldDirective(windowAdaptiveInfo)
|
||||
|
||||
if (forceSinglePane) {
|
||||
@@ -317,7 +362,11 @@ private fun ScreenContentApp(
|
||||
|
||||
@Composable
|
||||
fun SetupMenu() {
|
||||
AppDetailsMenu(isFavorite = isFavorite, state = state) { menuItem ->
|
||||
AppDetailsMenu(
|
||||
isFavorite = isFavorite,
|
||||
state = state,
|
||||
canManualDownload = canAcquire
|
||||
) { menuItem ->
|
||||
when (menuItem) {
|
||||
MenuItem.FAVORITE -> onFavorite()
|
||||
|
||||
@@ -333,20 +382,7 @@ private fun ScreenContentApp(
|
||||
ShortcutManagerUtil.requestPinShortcut(context, app.packageName)
|
||||
}
|
||||
|
||||
MenuItem.PLAY_STORE -> {
|
||||
val uri = "${Constants.SHARE_URL}${app.packageName}".toUri()
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply { data = uri }
|
||||
|
||||
if (intent.resolveActivity(context.packageManager) != null) {
|
||||
context.startActivity(
|
||||
intent.apply {
|
||||
setPackage(Constants.PACKAGE_NAME_PLAY_STORE)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
MenuItem.PLAY_STORE -> openPlayStore(context, app.packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -413,6 +449,8 @@ private fun ScreenContentApp(
|
||||
Actions(
|
||||
primaryActionDisplayName = primaryActionName,
|
||||
secondaryActionDisplayName = stringResource(R.string.title_manual_download),
|
||||
isPrimaryActionEnabled = canAcquire,
|
||||
isSecondaryActionEnabled = canAcquire,
|
||||
onPrimaryAction = ::onInstall,
|
||||
onSecondaryAction = { showExtraPane(ExtraScreen.ManualDownload) }
|
||||
)
|
||||
@@ -487,6 +525,17 @@ private fun ScreenContentApp(
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
// Reviews can only be submitted by personal accounts for installed apps.
|
||||
if (!isAnonymous && app.isInstalled) {
|
||||
UserReview(
|
||||
review = userReview,
|
||||
onSubmit = onSubmitReview,
|
||||
onDelete = onDeleteReview
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
if (!isAnonymous && app.testingProgram?.isAvailable == true) {
|
||||
Testing(
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.aurora.store.compose.ui.details.composable
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import android.widget.RatingBar
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import com.aurora.gplayapi.data.models.Review
|
||||
import com.aurora.store.R
|
||||
import com.aurora.store.compose.composable.SectionHeader
|
||||
import com.aurora.store.compose.preview.ThemePreviewProvider
|
||||
|
||||
/**
|
||||
* Composable that lets a signed-in user rate and review an app. Once a review exists it shows a
|
||||
* read-only summary with an "Edit your review" action; otherwise (or while editing) it shows the
|
||||
* rating form. Meant to be used as a part of the Column with proper vertical arrangement spacing
|
||||
* in the AppDetailsScreen. Callers are responsible for only showing this to non-anonymous accounts
|
||||
* for installed apps.
|
||||
* @param review The user's existing review, used to pre-fill the form and drive the summary
|
||||
* @param onSubmit Callback invoked with the rating, title and comment when the user posts
|
||||
* @param onDelete Callback invoked when the user confirms deletion of their review
|
||||
*/
|
||||
@Composable
|
||||
fun UserReview(
|
||||
review: Review? = null,
|
||||
onSubmit: (rating: Int, title: String, comment: String) -> Unit = { _, _, _ -> },
|
||||
onDelete: () -> Unit = {}
|
||||
) {
|
||||
// Default to editing only when there is no review yet. Keying on whether a review exists flips
|
||||
// back to the summary once one is posted, while still letting "Edit" toggle within that state.
|
||||
var isEditing by rememberSaveable(review == null) { mutableStateOf(review == null) }
|
||||
|
||||
if (review != null && !isEditing) {
|
||||
ReviewSummary(review = review, onEdit = { isEditing = true }, onDelete = onDelete)
|
||||
} else {
|
||||
ReviewForm(
|
||||
review = review,
|
||||
onSubmit = { rating, title, comment ->
|
||||
onSubmit(rating, title, comment)
|
||||
isEditing = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only view of the user's posted review with actions to edit or delete it.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReviewSummary(review: Review, onEdit: () -> Unit, onDelete: () -> Unit) {
|
||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
if (showDeleteDialog) {
|
||||
AlertDialog(
|
||||
title = { Text(text = stringResource(R.string.details_review_delete_title)) },
|
||||
text = { Text(text = stringResource(R.string.details_review_delete_message)) },
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteDialog = false
|
||||
onDelete()
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(R.string.details_review_delete))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text(text = stringResource(android.R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
SectionHeader(title = stringResource(R.string.details_your_review))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = dimensionResource(R.dimen.spacing_medium)),
|
||||
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small))
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(review.userPhotoUrl)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.requiredSize(dimensionResource(R.dimen.icon_size_small))
|
||||
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = review.userName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(
|
||||
dimensionResource(R.dimen.spacing_xsmall)
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
RatingBar(
|
||||
context,
|
||||
null,
|
||||
android.R.attr.ratingBarStyleIndicator
|
||||
).apply {
|
||||
numStars = 5
|
||||
stepSize = 1F
|
||||
setIsIndicator(true)
|
||||
}
|
||||
},
|
||||
update = { it.rating = review.rating.toFloat() }
|
||||
)
|
||||
if (review.timeStamp > 0L) {
|
||||
Text(text = "·", style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
text = DateUtils.formatDateTime(
|
||||
LocalContext.current,
|
||||
review.timeStamp,
|
||||
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (review.title.isNotBlank()) {
|
||||
Text(text = review.title, style = MaterialTheme.typography.titleSmall)
|
||||
}
|
||||
|
||||
if (review.comment.isNotBlank()) {
|
||||
Text(text = review.comment, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small))
|
||||
) {
|
||||
TextButton(onClick = { showDeleteDialog = true }) {
|
||||
Text(text = stringResource(R.string.details_review_delete))
|
||||
}
|
||||
TextButton(onClick = onEdit) {
|
||||
Text(text = stringResource(R.string.details_review_edit))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Editable form for rating an app and writing an optional review.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReviewForm(
|
||||
review: Review?,
|
||||
onSubmit: (rating: Int, title: String, comment: String) -> Unit
|
||||
) {
|
||||
// Key on the field values rather than the Review instance: Review.equals only compares
|
||||
// commentId, so keying on the instance would miss content changes synced from the Play Store.
|
||||
var rating by rememberSaveable(review?.rating) { mutableIntStateOf(review?.rating ?: 0) }
|
||||
var title by rememberSaveable(review?.title) { mutableStateOf(review?.title.orEmpty()) }
|
||||
var comment by rememberSaveable(review?.comment) { mutableStateOf(review?.comment.orEmpty()) }
|
||||
|
||||
SectionHeader(title = stringResource(R.string.details_think_this_app))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = dimensionResource(R.dimen.spacing_medium)),
|
||||
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small))
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
RatingBar(context, null, android.R.attr.ratingBarStyle).apply {
|
||||
numStars = 5
|
||||
stepSize = 1F
|
||||
setOnRatingBarChangeListener { _, value, fromUser ->
|
||||
if (fromUser) rating = value.toInt()
|
||||
}
|
||||
}
|
||||
},
|
||||
update = { it.rating = rating.toFloat() }
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
placeholder = { Text(text = stringResource(R.string.details_ratings_title_hint)) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(dimensionResource(R.dimen.radius_medium))
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = comment,
|
||||
onValueChange = { comment = it },
|
||||
placeholder = { Text(text = stringResource(R.string.details_review_comment_hint)) },
|
||||
minLines = 3,
|
||||
shape = RoundedCornerShape(dimensionResource(R.dimen.radius_medium))
|
||||
)
|
||||
|
||||
Button(
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
onClick = { onSubmit(rating, title, comment) },
|
||||
enabled = rating > 0
|
||||
) {
|
||||
Text(text = stringResource(R.string.action_post))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewWrapper(ThemePreviewProvider::class)
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun UserReviewFormPreview() {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium))
|
||||
) {
|
||||
UserReview()
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewWrapper(ThemePreviewProvider::class)
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun UserReviewSummaryPreview() {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium))
|
||||
) {
|
||||
UserReview(
|
||||
review = Review(
|
||||
title = "Great app",
|
||||
comment = "Works flawlessly without Google Play Services.",
|
||||
rating = 5
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ fun AppDetailsMenu(
|
||||
state: AppState = AppState.Unavailable,
|
||||
isFavorite: Boolean = false,
|
||||
isExpanded: Boolean = false,
|
||||
canManualDownload: Boolean = true,
|
||||
onMenuItemClicked: (menuItem: MenuItem) -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -80,7 +81,7 @@ fun AppDetailsMenu(
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = stringResource(R.string.title_manual_download)) },
|
||||
onClick = { onClick(MenuItem.MANUAL_DOWNLOAD) },
|
||||
enabled = !state.inProgress()
|
||||
enabled = canManualDownload && !state.inProgress()
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = stringResource(R.string.action_info)) },
|
||||
|
||||
@@ -15,6 +15,8 @@ import androidx.compose.material3.adaptive.WindowAdaptiveInfo
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -30,14 +32,19 @@ import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemKey
|
||||
import com.aurora.extensions.adaptiveNavigationIcon
|
||||
import com.aurora.gplayapi.data.models.App
|
||||
import com.aurora.gplayapi.data.models.StreamBundle
|
||||
import com.aurora.gplayapi.data.models.StreamCluster
|
||||
import com.aurora.gplayapi.data.models.details.DevStream
|
||||
import com.aurora.store.R
|
||||
import com.aurora.store.compose.composable.ContainedLoadingIndicator
|
||||
import com.aurora.store.compose.composable.Placeholder
|
||||
import com.aurora.store.compose.composable.StreamCarousel
|
||||
import com.aurora.store.compose.composable.TopAppBar
|
||||
import com.aurora.store.compose.composable.app.LargeAppListItem
|
||||
import com.aurora.store.compose.navigation.Destination
|
||||
import com.aurora.store.compose.preview.AppPreviewProvider
|
||||
import com.aurora.store.compose.preview.ThemePreviewProvider
|
||||
import com.aurora.store.data.model.ViewState
|
||||
import com.aurora.store.viewmodel.details.DevProfileViewModel
|
||||
import com.aurora.store.viewmodel.search.SearchViewModel
|
||||
import kotlin.random.Random
|
||||
@@ -53,7 +60,61 @@ fun DevProfileScreen(
|
||||
onNavigateTo: (Destination) -> Unit,
|
||||
viewModel: DevProfileViewModel = hiltViewModel()
|
||||
) {
|
||||
// TODO: Implement when migrating logic for current DevProfileFragment
|
||||
val state by viewModel.liveData.observeAsState()
|
||||
|
||||
LaunchedEffect(developerId) { viewModel.getStreamBundle(developerId) }
|
||||
|
||||
val devStream = (state as? ViewState.Success<*>)?.data as? DevStream
|
||||
|
||||
DevStreamContent(
|
||||
title = devStream?.title?.takeIf { it.isNotBlank() } ?: developerId,
|
||||
streamBundle = devStream?.streamBundle,
|
||||
isError = state is ViewState.Error,
|
||||
onRetry = { viewModel.getStreamBundle(developerId) },
|
||||
onClusterScrolled = viewModel::observeCluster,
|
||||
onNavigateTo = onNavigateTo
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DevStreamContent(
|
||||
title: String,
|
||||
streamBundle: StreamBundle?,
|
||||
isError: Boolean,
|
||||
onRetry: () -> Unit = {},
|
||||
onClusterScrolled: (StreamCluster) -> Unit = {},
|
||||
onNavigateTo: (Destination) -> Unit = {},
|
||||
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2()
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = title,
|
||||
navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
if (isError) {
|
||||
Placeholder(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
painter = painterResource(R.drawable.ic_refresh),
|
||||
message = stringResource(R.string.error),
|
||||
actionLabel = stringResource(R.string.action_retry),
|
||||
onAction = onRetry
|
||||
)
|
||||
} else {
|
||||
// A null bundle renders the loading shimmer until the stream resolves
|
||||
StreamCarousel(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxSize(),
|
||||
streamBundle = streamBundle,
|
||||
onHeaderClick = { onNavigateTo(Destination.StreamBrowse(it)) },
|
||||
onAppClick = { onNavigateTo(Destination.AppDetails(it.packageName)) },
|
||||
onClusterScrolled = onClusterScrolled
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,9 +15,13 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -25,11 +29,13 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
@@ -43,12 +49,15 @@ import com.aurora.store.compose.composable.ContainedLoadingIndicator
|
||||
import com.aurora.store.compose.composable.FavouriteListItem
|
||||
import com.aurora.store.compose.composable.Placeholder
|
||||
import com.aurora.store.compose.composable.ScrollHint
|
||||
import com.aurora.store.compose.composable.SectionHeader
|
||||
import com.aurora.store.compose.composable.TopAppBar
|
||||
import com.aurora.store.compose.navigation.Destination
|
||||
import com.aurora.store.compose.preview.FavouritePreviewProvider
|
||||
import com.aurora.store.compose.preview.ThemePreviewProvider
|
||||
import com.aurora.store.compose.ui.commons.InstallFavouritesDialog
|
||||
import com.aurora.store.compose.ui.favourite.menu.FavouriteMenu
|
||||
import com.aurora.store.compose.ui.favourite.menu.MenuItem
|
||||
import com.aurora.store.data.room.download.Download
|
||||
import com.aurora.store.data.room.favourite.Favourite
|
||||
import com.aurora.store.viewmodel.all.FavouriteViewModel
|
||||
import java.util.Calendar
|
||||
@@ -62,6 +71,24 @@ fun FavouriteScreen(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val favourites = viewModel.favourites.collectAsLazyPagingItems()
|
||||
val isEnqueuing by viewModel.isEnqueuing.collectAsStateWithLifecycle()
|
||||
val hasInstallableFavourites by viewModel.hasInstallableFavourites
|
||||
.collectAsStateWithLifecycle()
|
||||
val downloads by viewModel.downloadsList.collectAsStateWithLifecycle()
|
||||
val downloadMap = remember(downloads) { downloads.associateBy { it.packageName } }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.enqueueResult.collect { count ->
|
||||
when {
|
||||
count > 0 -> context.toast(
|
||||
context.getString(R.string.toast_fav_install_enqueued, count)
|
||||
)
|
||||
|
||||
count == 0 -> context.toast(R.string.toast_fav_install_none)
|
||||
else -> context.toast(R.string.toast_fav_install_failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val documentOpenLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
@@ -89,7 +116,11 @@ fun FavouriteScreen(
|
||||
|
||||
ScreenContent(
|
||||
favourites = favourites,
|
||||
downloads = downloadMap,
|
||||
isEnqueuing = isEnqueuing,
|
||||
showInstallAll = hasInstallableFavourites,
|
||||
onNavigateTo = onNavigateTo,
|
||||
onInstallAll = { viewModel.installAll() },
|
||||
onRemoveFavourite = { packageName -> viewModel.removeFavourite(packageName) },
|
||||
onImportFavourites = {
|
||||
documentOpenLauncher.launch(arrayOf(JSON_MIME_TYPE))
|
||||
@@ -105,7 +136,11 @@ fun FavouriteScreen(
|
||||
@Composable
|
||||
private fun ScreenContent(
|
||||
favourites: LazyPagingItems<Favourite> = emptyPagingItems(),
|
||||
downloads: Map<String, Download> = emptyMap(),
|
||||
isEnqueuing: Boolean = false,
|
||||
showInstallAll: Boolean = true,
|
||||
onNavigateTo: (Destination) -> Unit = {},
|
||||
onInstallAll: () -> Unit = {},
|
||||
onRemoveFavourite: (packageName: String) -> Unit = {},
|
||||
onImportFavourites: () -> Unit = {},
|
||||
onExportFavourites: () -> Unit = {}
|
||||
@@ -117,6 +152,18 @@ private fun ScreenContent(
|
||||
* Save the initial loading state to make sure we don't replay the loading animation again.
|
||||
*/
|
||||
var initialLoad by rememberSaveable { mutableStateOf(true) }
|
||||
var showInstallDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showInstallDialog) {
|
||||
InstallFavouritesDialog(
|
||||
count = favourites.itemCount,
|
||||
onConfirm = {
|
||||
showInstallDialog = false
|
||||
onInstallAll()
|
||||
},
|
||||
onDismiss = { showInstallDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SetupMenu() {
|
||||
@@ -162,6 +209,29 @@ private fun ScreenContent(
|
||||
LazyColumn(
|
||||
state = listState
|
||||
) {
|
||||
item(key = "header") {
|
||||
SectionHeader(
|
||||
title = pluralStringResource(
|
||||
R.plurals.favourites_count,
|
||||
favourites.itemCount,
|
||||
favourites.itemCount
|
||||
),
|
||||
trailing = if (showInstallAll) {
|
||||
{
|
||||
TextButton(
|
||||
onClick = { showInstallDialog = true },
|
||||
enabled = !isEnqueuing
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.action_install_all)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
items(
|
||||
count = favourites.itemCount,
|
||||
key = favourites.itemKey { it.packageName }
|
||||
@@ -170,6 +240,7 @@ private fun ScreenContent(
|
||||
FavouriteListItem(
|
||||
modifier = Modifier.animateItem(),
|
||||
favourite = favourite,
|
||||
download = downloads[favourite.packageName],
|
||||
onClick = {
|
||||
onNavigateTo(
|
||||
Destination.AppDetails(favourite.packageName)
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
||||
import androidx.core.net.toUri
|
||||
import com.aurora.extensions.isSAndAbove
|
||||
import com.aurora.extensions.isTAndAbove
|
||||
import com.aurora.extensions.setAppTheme
|
||||
import com.aurora.store.R
|
||||
@@ -38,6 +39,7 @@ import com.aurora.store.compose.preview.ThemePreviewProvider
|
||||
import com.aurora.store.compose.ui.preferences.network.SingleChoiceDialog
|
||||
import com.aurora.store.util.Preferences
|
||||
import com.aurora.store.util.Preferences.PREFERENCE_DEFAULT_SELECTED_TAB
|
||||
import com.aurora.store.util.Preferences.PREFERENCE_DYNAMIC_COLORS
|
||||
import com.aurora.store.util.Preferences.PREFERENCE_FOR_YOU
|
||||
import com.aurora.store.util.Preferences.PREFERENCE_THEME_STYLE
|
||||
import com.aurora.store.util.save
|
||||
@@ -62,6 +64,15 @@ private fun ScreenContent() {
|
||||
var forYou by remember {
|
||||
mutableStateOf(Preferences.getBoolean(context, PREFERENCE_FOR_YOU, true))
|
||||
}
|
||||
var dynamicColors by remember {
|
||||
mutableStateOf(
|
||||
Preferences.getBoolean(
|
||||
context,
|
||||
PREFERENCE_DYNAMIC_COLORS,
|
||||
Preferences.dynamicColorsDefault
|
||||
)
|
||||
)
|
||||
}
|
||||
var showThemeDialog by remember { mutableStateOf(false) }
|
||||
var showTabDialog by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -132,6 +143,29 @@ private fun ScreenContent() {
|
||||
supportingContent = { Text(themeEntries.getOrElse(themeStyle) { "" }) }
|
||||
)
|
||||
}
|
||||
if (isSAndAbove) {
|
||||
item {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
dynamicColors = !dynamicColors
|
||||
context.save(PREFERENCE_DYNAMIC_COLORS, dynamicColors)
|
||||
},
|
||||
headlineContent = { Text(stringResource(R.string.pref_ui_dynamic_color)) },
|
||||
supportingContent = {
|
||||
Text(stringResource(R.string.pref_ui_dynamic_color_desc))
|
||||
},
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = dynamicColors,
|
||||
onCheckedChange = { checked ->
|
||||
dynamicColors = checked
|
||||
context.save(PREFERENCE_DYNAMIC_COLORS, checked)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
item {
|
||||
ListItem(headlineContent = { Text(stringResource(R.string.pref_ui_layout)) })
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -41,9 +42,20 @@ import com.aurora.store.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun InstallErrorSheet(app: App, error: String?, extra: String?, onDismiss: () -> Unit) {
|
||||
fun InstallErrorSheet(
|
||||
app: App,
|
||||
error: String?,
|
||||
extra: String?,
|
||||
isAnonymous: Boolean = true,
|
||||
onBuy: () -> Unit = {},
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// A paid app that failed to install for a signed-in (non-anonymous) account is almost
|
||||
// always one that hasn't been purchased yet; offer a shortcut to buy it on the Play Store.
|
||||
val canBuy = !app.isFree && !isAnonymous
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
@@ -53,7 +65,7 @@ fun InstallErrorSheet(app: App, error: String?, extra: String?, onDismiss: () ->
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Header(app = app)
|
||||
Header(app = app, showBuy = canBuy, onBuy = onBuy)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(
|
||||
@@ -101,7 +113,7 @@ fun InstallErrorSheet(app: App, error: String?, extra: String?, onDismiss: () ->
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(app: App) {
|
||||
private fun Header(app: App, showBuy: Boolean = false, onBuy: () -> Unit = {}) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -137,5 +149,14 @@ private fun Header(app: App) {
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
if (showBuy) {
|
||||
Button(onClick = onBuy) {
|
||||
Text(
|
||||
text = stringResource(R.string.action_buy, app.price),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,13 +73,31 @@ class DownloadHelper @Inject constructor(
|
||||
*/
|
||||
fun init() {
|
||||
AuroraApp.scope.launch {
|
||||
cancelFailedDownloads(downloadDao.downloads().firstOrNull() ?: emptyList())
|
||||
val downloads = downloadDao.downloads().firstOrNull() ?: emptyList()
|
||||
cancelFailedDownloads(downloads)
|
||||
finalizeStaleSelfUpdate(downloads)
|
||||
}.invokeOnCompletion {
|
||||
observeDownloads()
|
||||
observeInstalls()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalizes a self-update download left dangling in [DownloadStatus.INSTALLING]. Replacing
|
||||
* the app's own APK kills the process before the installer's "installed" event can advance
|
||||
* the row, so on the next launch it would otherwise show as installing forever. Reaching
|
||||
* INSTALLING means the install was already committed, so mark it installed; if it actually
|
||||
* failed, the periodic update check re-offers and re-enqueues it.
|
||||
*/
|
||||
private suspend fun finalizeStaleSelfUpdate(downloads: List<Download>) {
|
||||
downloads.firstOrNull {
|
||||
it.packageName == context.packageName && it.status == DownloadStatus.INSTALLING
|
||||
}?.let {
|
||||
Log.i(TAG, "Finalizing stale self-update install for ${it.packageName}")
|
||||
downloadDao.updateStatus(it.packageName, DownloadStatus.INSTALLED)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances a download row through the installer phase so its history reflects whether the
|
||||
* app actually installed, not just that the bytes finished downloading:
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.aurora.store.data.room.update.IgnoredUpdateDao
|
||||
import com.aurora.store.data.room.update.Update
|
||||
import com.aurora.store.data.room.update.UpdateDao
|
||||
import com.aurora.store.data.work.UpdateWorker
|
||||
import com.aurora.store.util.PackageUtil
|
||||
import com.aurora.store.util.Preferences
|
||||
import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS_BATTERY
|
||||
import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS_IDLE
|
||||
@@ -253,13 +254,23 @@ class UpdateHelper @Inject constructor(
|
||||
|
||||
private suspend fun deleteInvalidUpdates() {
|
||||
updateDao.updates().firstOrNull()?.forEach { update ->
|
||||
// Nightly builds share a static version code, so the generic "up to date"
|
||||
// check would wrongly drop a freshly found self-update. The worker owns the
|
||||
// self-update lifecycle (re-checked by build timestamp); cleanup happens via
|
||||
// the install event instead.
|
||||
if (update.isSelfUpdate(context) && BuildType.CURRENT == BuildType.NIGHTLY) {
|
||||
if (update.isSelfUpdate(context)) {
|
||||
// Self-updates need a flavor-aware staleness check. Release/preload bump the
|
||||
// version code, so the normal up-to-date check works. Nightly reuses a static
|
||||
// version code (isUpToDate is always true there), so fall back to the
|
||||
// commit-tagged version name. Without this the row lingers until the next
|
||||
// update check, since the install event isn't delivered when the app replaces
|
||||
// itself.
|
||||
val alreadyInstalled = if (BuildType.CURRENT == BuildType.NIGHTLY) {
|
||||
PackageUtil.getInstalledVersionName(context, update.packageName) ==
|
||||
update.versionName
|
||||
} else {
|
||||
update.isUpToDate(context)
|
||||
}
|
||||
if (alreadyInstalled) deleteUpdate(update.packageName)
|
||||
return@forEach
|
||||
}
|
||||
|
||||
if (!update.isInstalled(context) || update.isUpToDate(context)) {
|
||||
deleteUpdate(update.packageName)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ data class SelfUpdate(
|
||||
val versionCodeRaw: String = "0",
|
||||
@SerialName("download_url")
|
||||
val downloadUrl: String = "",
|
||||
@SerialName("icon_url")
|
||||
val iconUrl: String = "",
|
||||
@SerialName("sha1")
|
||||
val sha1: String = "",
|
||||
@SerialName("sha256")
|
||||
val sha256: String = "",
|
||||
val changelog: String = "",
|
||||
@SerialName("size")
|
||||
val sizeRaw: String = "0",
|
||||
@@ -61,12 +67,14 @@ data class SelfUpdate(
|
||||
updatedOn = updatedOn,
|
||||
displayName = context.getString(R.string.app_name),
|
||||
developerName = "Rahul Kumar Patel",
|
||||
iconArtwork = Artwork(url = ICON_URL),
|
||||
iconArtwork = Artwork(url = iconUrl),
|
||||
fileList = mutableListOf(
|
||||
PlayFile(
|
||||
name = "${context.packageName}.apk",
|
||||
url = downloadUrl,
|
||||
size = size
|
||||
size = size,
|
||||
sha1 = sha1,
|
||||
sha256 = sha256
|
||||
)
|
||||
),
|
||||
isFree = true,
|
||||
@@ -78,11 +86,4 @@ data class SelfUpdate(
|
||||
EncodedCertificateSet(certificateSet = it, sha256 = String())
|
||||
}.toMutableList()
|
||||
)
|
||||
|
||||
companion object {
|
||||
// Kept in sync with the fastlane metadata on the project.
|
||||
private const val ICON_URL =
|
||||
"https://gitlab.com/AuroraOSS/AuroraStore/-/raw/master/" +
|
||||
"fastlane/metadata/android/en-US/images/icon.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,22 @@ import com.aurora.store.data.room.download.DownloadConverter
|
||||
import com.aurora.store.data.room.download.DownloadDao
|
||||
import com.aurora.store.data.room.favourite.Favourite
|
||||
import com.aurora.store.data.room.favourite.FavouriteDao
|
||||
import com.aurora.store.data.room.review.LocalReview
|
||||
import com.aurora.store.data.room.review.ReviewDao
|
||||
import com.aurora.store.data.room.update.IgnoredUpdate
|
||||
import com.aurora.store.data.room.update.IgnoredUpdateDao
|
||||
import com.aurora.store.data.room.update.Update
|
||||
import com.aurora.store.data.room.update.UpdateDao
|
||||
|
||||
@Database(
|
||||
entities = [Download::class, Favourite::class, Update::class, IgnoredUpdate::class],
|
||||
version = 8,
|
||||
entities = [
|
||||
Download::class,
|
||||
Favourite::class,
|
||||
Update::class,
|
||||
IgnoredUpdate::class,
|
||||
LocalReview::class
|
||||
],
|
||||
version = 9,
|
||||
exportSchema = true
|
||||
)
|
||||
@TypeConverters(DownloadConverter::class)
|
||||
@@ -24,4 +32,5 @@ abstract class AuroraDatabase : RoomDatabase() {
|
||||
abstract fun favouriteDao(): FavouriteDao
|
||||
abstract fun updateDao(): UpdateDao
|
||||
abstract fun ignoredUpdateDao(): IgnoredUpdateDao
|
||||
abstract fun reviewDao(): ReviewDao
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ object MigrationHelper {
|
||||
override fun migrate(db: SupportSQLiteDatabase) = migrateFrom7To8(db)
|
||||
}
|
||||
|
||||
val MIGRATION_8_9 = object : Migration(8, 9) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) = migrateFrom8To9(db)
|
||||
}
|
||||
|
||||
private const val TAG = "MigrationHelper"
|
||||
|
||||
private fun migrateFrom1To2(database: SupportSQLiteDatabase) {
|
||||
@@ -159,4 +163,34 @@ object MigrationHelper {
|
||||
database.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add review table for caching the user's own submitted reviews locally so they can be shown
|
||||
* immediately while Google takes time to publish them. Scoped by account e-mail.
|
||||
*/
|
||||
private fun migrateFrom8To9(database: SupportSQLiteDatabase) {
|
||||
database.beginTransaction()
|
||||
try {
|
||||
database.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `review` (" +
|
||||
"`packageName` TEXT NOT NULL, " +
|
||||
"`accountEmail` TEXT NOT NULL, " +
|
||||
"`title` TEXT NOT NULL, " +
|
||||
"`comment` TEXT NOT NULL, " +
|
||||
"`rating` INTEGER NOT NULL, " +
|
||||
"`commentId` TEXT NOT NULL, " +
|
||||
"`userName` TEXT NOT NULL, " +
|
||||
"`userPhotoUrl` TEXT NOT NULL, " +
|
||||
"`appVersion` TEXT NOT NULL, " +
|
||||
"`timeStamp` INTEGER NOT NULL, " +
|
||||
"`synced` INTEGER NOT NULL, " +
|
||||
"PRIMARY KEY(`packageName`, `accountEmail`))"
|
||||
)
|
||||
database.setTransactionSuccessful()
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Failed while migrating from database version 8 to 9", exception)
|
||||
} finally {
|
||||
database.endTransaction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ import com.aurora.store.data.room.MigrationHelper.MIGRATION_4_5
|
||||
import com.aurora.store.data.room.MigrationHelper.MIGRATION_5_6
|
||||
import com.aurora.store.data.room.MigrationHelper.MIGRATION_6_7
|
||||
import com.aurora.store.data.room.MigrationHelper.MIGRATION_7_8
|
||||
import com.aurora.store.data.room.MigrationHelper.MIGRATION_8_9
|
||||
import com.aurora.store.data.room.download.DownloadConverter
|
||||
import com.aurora.store.data.room.download.DownloadDao
|
||||
import com.aurora.store.data.room.favourite.FavouriteDao
|
||||
import com.aurora.store.data.room.review.ReviewDao
|
||||
import com.aurora.store.data.room.update.IgnoredUpdateDao
|
||||
import com.aurora.store.data.room.update.UpdateDao
|
||||
import dagger.Module
|
||||
@@ -40,7 +42,8 @@ object RoomModule {
|
||||
MIGRATION_4_5,
|
||||
MIGRATION_5_6,
|
||||
MIGRATION_6_7,
|
||||
MIGRATION_7_8
|
||||
MIGRATION_7_8,
|
||||
MIGRATION_8_9
|
||||
)
|
||||
.addTypeConverter(downloadConverter)
|
||||
.build()
|
||||
@@ -59,4 +62,7 @@ object RoomModule {
|
||||
@Provides
|
||||
fun providesIgnoredUpdateDao(auroraDatabase: AuroraDatabase): IgnoredUpdateDao =
|
||||
auroraDatabase.ignoredUpdateDao()
|
||||
|
||||
@Provides
|
||||
fun providesReviewDao(auroraDatabase: AuroraDatabase): ReviewDao = auroraDatabase.reviewDao()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.aurora.store.data.room.review
|
||||
|
||||
import androidx.room.Entity
|
||||
import com.aurora.gplayapi.data.models.Review
|
||||
|
||||
/**
|
||||
* A user-authored review cached locally so it can be shown immediately after submission,
|
||||
* even while Google takes time to publish it. It is kept in sync with the Play API: a review
|
||||
* is [synced] once the API has confirmed it exists, after which a subsequent disappearance from
|
||||
* the API is treated as a deletion on Play Store and the local copy is dropped.
|
||||
*
|
||||
* Scoped by [accountEmail] so a previous account's reviews never leak into a different session.
|
||||
*/
|
||||
@Entity(tableName = "review", primaryKeys = ["packageName", "accountEmail"])
|
||||
data class LocalReview(
|
||||
val packageName: String,
|
||||
val accountEmail: String,
|
||||
val title: String,
|
||||
val comment: String,
|
||||
val rating: Int,
|
||||
val commentId: String = String(),
|
||||
val userName: String = String(),
|
||||
val userPhotoUrl: String = String(),
|
||||
val appVersion: String = String(),
|
||||
val timeStamp: Long = 0L,
|
||||
val synced: Boolean = false
|
||||
) {
|
||||
|
||||
companion object {
|
||||
fun fromReview(
|
||||
review: Review,
|
||||
packageName: String,
|
||||
accountEmail: String,
|
||||
synced: Boolean
|
||||
): LocalReview = LocalReview(
|
||||
packageName = packageName,
|
||||
accountEmail = accountEmail,
|
||||
title = review.title,
|
||||
comment = review.comment,
|
||||
rating = review.rating,
|
||||
commentId = review.commentId,
|
||||
userName = review.userName,
|
||||
userPhotoUrl = review.userPhotoUrl,
|
||||
appVersion = review.appVersion,
|
||||
timeStamp = review.timeStamp,
|
||||
synced = synced
|
||||
)
|
||||
|
||||
fun LocalReview.toReview(): Review = Review(
|
||||
title = title,
|
||||
comment = comment,
|
||||
commentId = commentId,
|
||||
userName = userName,
|
||||
userPhotoUrl = userPhotoUrl,
|
||||
appVersion = appVersion,
|
||||
rating = rating,
|
||||
timeStamp = timeStamp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.aurora.store.data.room.review
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface ReviewDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(review: LocalReview)
|
||||
|
||||
@Query("SELECT * FROM review WHERE packageName = :packageName AND accountEmail = :accountEmail")
|
||||
fun review(packageName: String, accountEmail: String): Flow<LocalReview?>
|
||||
|
||||
@Query("SELECT * FROM review WHERE packageName = :packageName AND accountEmail = :accountEmail")
|
||||
suspend fun get(packageName: String, accountEmail: String): LocalReview?
|
||||
|
||||
@Query("DELETE FROM review WHERE packageName = :packageName AND accountEmail = :accountEmail")
|
||||
suspend fun delete(packageName: String, accountEmail: String)
|
||||
|
||||
@Query("DELETE FROM review")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
@@ -130,14 +130,21 @@ class DownloadWorker @AssistedInject constructor(
|
||||
// Fetch required data for download
|
||||
try {
|
||||
download = downloadDao.getDownload(inputData.getString(DownloadHelper.PACKAGE_NAME)!!)
|
||||
} catch (exception: Exception) {
|
||||
return onFailure(exception)
|
||||
}
|
||||
|
||||
// The icon only decorates the progress notification. Fetching it is best-effort: a
|
||||
// failure (unreachable/pinned host, non-HTTP url, undecodable image) must never abort
|
||||
// the download itself.
|
||||
try {
|
||||
val response = (httpClient as HttpClient).call(download.iconURL).body
|
||||
val bitmap = BitmapFactory.decodeStream(
|
||||
withContext(Dispatchers.IO) { response.byteStream() }
|
||||
)
|
||||
icon = bitmap.scale(96, 96)
|
||||
icon = bitmap?.scale(96, 96)
|
||||
} catch (exception: Exception) {
|
||||
return onFailure(exception)
|
||||
Log.w(TAG, "Failed to fetch icon for ${download.packageName}", exception)
|
||||
}
|
||||
|
||||
// Set work/service to foreground on < Android 12.0
|
||||
|
||||
@@ -24,15 +24,24 @@ import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.aurora.extensions.isOneUI
|
||||
import com.aurora.store.BuildConfig
|
||||
|
||||
object Preferences {
|
||||
|
||||
const val PREFERENCE_DEFAULT = "PREFERENCE_DEFAULT"
|
||||
|
||||
/**
|
||||
* Default for [PREFERENCE_DYNAMIC_COLORS]. Dynamic color is opt-out everywhere except One UI,
|
||||
* where Samsung's palette extraction tends to look off, so it defaults off and users can opt in.
|
||||
*/
|
||||
val dynamicColorsDefault: Boolean
|
||||
get() = !isOneUI
|
||||
|
||||
const val PREFERENCE_AUTH_DATA = "PREFERENCE_AUTH_DATA"
|
||||
const val PREFERENCE_INSTALLER_ID = "PREFERENCE_INSTALLER_ID"
|
||||
const val PREFERENCE_THEME_STYLE = "PREFERENCE_THEME_STYLE"
|
||||
const val PREFERENCE_DYNAMIC_COLORS = "PREFERENCE_DYNAMIC_COLORS"
|
||||
const val PREFERENCE_FOR_YOU = "PREFERENCE_FOR_YOU"
|
||||
const val PREFERENCE_DEFAULT_SELECTED_TAB = "PREFERENCE_DEFAULT_SELECTED_TAB"
|
||||
const val PREFERENCE_INTRO = "PREFERENCE_INTRO"
|
||||
@@ -81,6 +90,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) {
|
||||
|
||||
@@ -14,20 +14,35 @@ import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import com.aurora.extensions.TAG
|
||||
import com.aurora.gplayapi.helpers.AppDetailsHelper
|
||||
import com.aurora.store.AuroraApp
|
||||
import com.aurora.store.data.event.InstallerEvent
|
||||
import com.aurora.store.data.helper.DownloadHelper
|
||||
import com.aurora.store.data.paging.GenericPagingSource.Companion.pager
|
||||
import com.aurora.store.data.providers.AuthProvider
|
||||
import com.aurora.store.data.room.favourite.Favourite
|
||||
import com.aurora.store.data.room.favourite.FavouriteDao
|
||||
import com.aurora.store.data.room.favourite.ImportExport
|
||||
import com.aurora.store.util.PackageUtil
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@@ -35,12 +50,40 @@ import kotlinx.serialization.json.Json
|
||||
class FavouriteViewModel @Inject constructor(
|
||||
private val favouriteDao: FavouriteDao,
|
||||
private val json: Json,
|
||||
private val appDetailsHelper: AppDetailsHelper,
|
||||
private val downloadHelper: DownloadHelper,
|
||||
private val authProvider: AuthProvider,
|
||||
@ApplicationContext private val context: Context
|
||||
) : ViewModel() {
|
||||
|
||||
private val _favourites = MutableStateFlow<PagingData<Favourite>>(PagingData.empty())
|
||||
val favourites = _favourites.asStateFlow()
|
||||
|
||||
val downloadsList get() = downloadHelper.downloadsList
|
||||
|
||||
private val _isEnqueuing = MutableStateFlow(false)
|
||||
val isEnqueuing = _isEnqueuing.asStateFlow()
|
||||
|
||||
// Emits the number of favourites actually enqueued for install (0 when nothing was
|
||||
// applicable, -1 when fetching details failed) so the screen can show accurate feedback.
|
||||
private val _enqueueResult = MutableSharedFlow<Int>()
|
||||
val enqueueResult = _enqueueResult.asSharedFlow()
|
||||
|
||||
// Whether at least one favourite is still not installed, recomputed whenever the favourites
|
||||
// list changes or any app is installed/removed. Drives visibility of the "Install all"
|
||||
// action so it hides once every favourite is already installed. Defaults to true to avoid
|
||||
// briefly hiding the action before the first check completes.
|
||||
val hasInstallableFavourites = combine(
|
||||
favouriteDao.favourites(),
|
||||
AuroraApp.events.installerEvent
|
||||
.filter { it is InstallerEvent.Installed || it is InstallerEvent.Uninstalled }
|
||||
.map { }
|
||||
.onStart { emit(Unit) }
|
||||
) { favourites, _ ->
|
||||
favourites.any { !PackageUtil.isInstalled(context, it.packageName) }
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
|
||||
|
||||
init {
|
||||
getPagedFavourites()
|
||||
}
|
||||
@@ -86,6 +129,34 @@ class FavouriteViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches details for all favourites and enqueues the installable ones for download &
|
||||
* install. Already installed (and up-to-date) apps are skipped, as are paid apps that an
|
||||
* anonymous account can't acquire. The actual count enqueued is emitted via [enqueueResult].
|
||||
*/
|
||||
fun installAll() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_isEnqueuing.value = true
|
||||
try {
|
||||
val packageNames = favouriteDao.favourites().first().map { it.packageName }
|
||||
val installable = appDetailsHelper.getAppByPackageName(packageNames).filter { app ->
|
||||
val needsInstall = !PackageUtil.isInstalled(context, app.packageName) ||
|
||||
PackageUtil.isUpdatable(context, app.packageName, app.versionCode)
|
||||
val acquirable = app.isFree || !authProvider.isAnonymous
|
||||
needsInstall && acquirable
|
||||
}
|
||||
|
||||
installable.forEach { downloadHelper.enqueueApp(it) }
|
||||
_enqueueResult.emit(installable.size)
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Failed to enqueue favourites for install", exception)
|
||||
_enqueueResult.emit(-1)
|
||||
} finally {
|
||||
_isEnqueuing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPagedFavourites() {
|
||||
pager { favouriteDao.pagedFavourites() }.flow
|
||||
.distinctUntilChanged()
|
||||
|
||||
@@ -40,6 +40,9 @@ import com.aurora.store.data.providers.AuthProvider
|
||||
import com.aurora.store.data.room.download.Download
|
||||
import com.aurora.store.data.room.favourite.Favourite
|
||||
import com.aurora.store.data.room.favourite.FavouriteDao
|
||||
import com.aurora.store.data.room.review.LocalReview
|
||||
import com.aurora.store.data.room.review.LocalReview.Companion.toReview
|
||||
import com.aurora.store.data.room.review.ReviewDao
|
||||
import com.aurora.store.util.CertUtil
|
||||
import com.aurora.store.util.PackageUtil
|
||||
import com.aurora.store.util.Preferences
|
||||
@@ -48,14 +51,19 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -71,6 +79,7 @@ class AppDetailsViewModel @Inject constructor(
|
||||
private val webDataSafetyHelper: WebDataSafetyHelper,
|
||||
private val downloadHelper: DownloadHelper,
|
||||
private val favouriteDao: FavouriteDao,
|
||||
private val reviewDao: ReviewDao,
|
||||
private val httpClient: IHttpClient,
|
||||
private val json: Json
|
||||
) : ViewModel() {
|
||||
@@ -89,8 +98,19 @@ class AppDetailsViewModel @Inject constructor(
|
||||
private val _featuredReviews = MutableStateFlow<List<Review>>(emptyList())
|
||||
val featuredReviews = _featuredReviews.asStateFlow()
|
||||
|
||||
private val _userReview = MutableStateFlow<Review?>(null)
|
||||
val userReview = _userReview.asStateFlow()
|
||||
// The user's own review for the loaded app, backed by Room so it is shown instantly (even
|
||||
// offline) and survives restarts while Google takes time to publish it. Scoped by account.
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val userReview: StateFlow<Review?> = app
|
||||
.filterNotNull()
|
||||
.flatMapLatest { loadedApp ->
|
||||
reviewDao.review(loadedApp.packageName, accountEmail).map { it?.toReview() }
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
// One-shot signal carrying whether the latest review submission succeeded
|
||||
private val _reviewPosted = MutableSharedFlow<Boolean>(extraBufferCapacity = 1)
|
||||
val reviewPosted = _reviewPosted.asSharedFlow()
|
||||
|
||||
private val _dataSafetyReport = MutableStateFlow<DataSafetyReport?>(null)
|
||||
val dataSafetyReport = _dataSafetyReport.asStateFlow()
|
||||
@@ -117,6 +137,10 @@ class AppDetailsViewModel @Inject constructor(
|
||||
list.find { d -> d.packageName == a.packageName }
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
// E-mail of the signed-in account; blank for anonymous sessions. Used to scope cached reviews.
|
||||
private val accountEmail: String
|
||||
get() = authProvider.authData?.email.orEmpty()
|
||||
|
||||
private val isInstalled: Boolean
|
||||
get() = PackageUtil.isInstalled(context, app.value!!.packageName)
|
||||
|
||||
@@ -200,6 +224,11 @@ class AppDetailsViewModel @Inject constructor(
|
||||
|
||||
fetchFavourite(packageName)
|
||||
fetchFeaturedReviews(packageName)
|
||||
// Reviews can only be submitted for installed apps with a personal account, so the
|
||||
// user's existing review is only relevant (and fetchable) in that case.
|
||||
if (!authProvider.isAnonymous && app.value!!.isInstalled) {
|
||||
fetchUserAppReview(app.value!!)
|
||||
}
|
||||
fetchDataSafetyReport(packageName)
|
||||
fetchSuggestions()
|
||||
fetchExodusPrivacyReport(packageName)
|
||||
@@ -218,11 +247,32 @@ class AppDetailsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles the locally cached review with the authoritative Play API:
|
||||
* - If Play returns a review, it is mirrored locally and marked synced (this also picks up
|
||||
* edits made directly on the Play Store).
|
||||
* - If Play returns nothing but we had previously confirmed a review, it was deleted on the
|
||||
* Play Store, so the local copy is dropped.
|
||||
* - If Play returns nothing for a review that was never confirmed, it is still being published,
|
||||
* so the local (pending) copy is kept so the user keeps seeing what they submitted.
|
||||
*/
|
||||
fun fetchUserAppReview(app: App) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val email = accountEmail
|
||||
if (email.isBlank()) return@launch
|
||||
try {
|
||||
val isTesting = app.testingProgram?.isSubscribed ?: false
|
||||
_userReview.value = reviewsHelper.getUserReview(app.packageName, isTesting)
|
||||
val apiReview = reviewsHelper.getUserReview(app.packageName, isTesting)
|
||||
if (apiReview != null) {
|
||||
reviewDao.upsert(
|
||||
LocalReview.fromReview(apiReview, app.packageName, email, synced = true)
|
||||
)
|
||||
} else {
|
||||
val cached = reviewDao.get(app.packageName, email)
|
||||
if (cached != null && cached.synced) {
|
||||
reviewDao.delete(app.packageName, email)
|
||||
}
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Failed to fetch user review", exception)
|
||||
}
|
||||
@@ -231,16 +281,42 @@ class AppDetailsViewModel @Inject constructor(
|
||||
|
||||
fun postAppReview(packageName: String, review: Review, isBeta: Boolean) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val email = accountEmail
|
||||
try {
|
||||
_userReview.value = reviewsHelper.addOrEditReview(
|
||||
val posted = reviewsHelper.addOrEditReview(
|
||||
packageName,
|
||||
review.title,
|
||||
review.comment,
|
||||
review.rating,
|
||||
isBeta
|
||||
)
|
||||
if (posted != null) {
|
||||
// Cache as pending (not yet synced): Play accepted it but getUserReview may
|
||||
// not return it until publishing finishes. Marking it synced prematurely would
|
||||
// let the next reconcile mistake the publishing delay for a deletion.
|
||||
reviewDao.upsert(
|
||||
LocalReview.fromReview(posted, packageName, email, synced = false)
|
||||
)
|
||||
}
|
||||
_reviewPosted.tryEmit(posted != null)
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Failed to post review", exception)
|
||||
_reviewPosted.tryEmit(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAppReview(app: App) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val email = accountEmail
|
||||
try {
|
||||
val isTesting = app.testingProgram?.isSubscribed ?: false
|
||||
reviewsHelper.deleteReview(app.packageName, isTesting)
|
||||
// Only drop the local copy once Play has accepted the deletion, so a failed
|
||||
// network call leaves the review visible rather than silently disappearing.
|
||||
if (email.isNotBlank()) reviewDao.delete(app.packageName, email)
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Failed to delete review", exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
<style name="AppTheme" parent="Theme.Material3.DynamicColors.Dark.NoActionBar">
|
||||
<item name="chipStyle">@style/Chip.Filter</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<!-- Brand accent for devices without dynamic color (Android 11 and below) -->
|
||||
<item name="colorPrimary">@color/colorAccent</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
<string name="action_back">"Back"</string>
|
||||
<string name="action_blacklist">"Blacklist"</string>
|
||||
<string name="action_blacklist_add">"Add to blacklist"</string>
|
||||
<string name="action_buy">"Buy @ %1$s"</string>
|
||||
<string name="action_cancel">"Cancel"</string>
|
||||
<string name="action_clear">"Clear"</string>
|
||||
<string name="action_close">"Close"</string>
|
||||
@@ -76,6 +77,7 @@
|
||||
<string name="action_granted">"Granted"</string>
|
||||
<string name="action_ignore">"Ignore"</string>
|
||||
<string name="action_install">"Install"</string>
|
||||
<string name="action_install_all">"Install all"</string>
|
||||
<string name="action_install_microG">"Install microG Bundle"</string>
|
||||
<string name="action_installations">"Installations"</string>
|
||||
<string name="action_installing">"Installing"</string>
|
||||
@@ -139,6 +141,12 @@
|
||||
<string name="details_privacy">"Privacy"</string>
|
||||
<string name="details_ratings">"Rating and reviews"</string>
|
||||
<string name="details_ratings_title_hint">"Review title"</string>
|
||||
<string name="details_review_comment_hint">"Describe your experience (optional)"</string>
|
||||
<string name="details_review_edit">"Edit your review"</string>
|
||||
<string name="details_review_delete">"Delete"</string>
|
||||
<string name="details_review_delete_title">"Delete review?"</string>
|
||||
<string name="details_review_delete_message">"Your rating and review will be removed from Google Play."</string>
|
||||
<string name="details_your_review">"Your review"</string>
|
||||
<string name="details_think_this_app">"What do you think about this app?"</string>
|
||||
<string name="download_cancel_all">"Cancel all"</string>
|
||||
<string name="download_canceled">"Cancelled"</string>
|
||||
@@ -189,6 +197,11 @@
|
||||
<string name="app_lock_unlock">Unlock</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="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_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>
|
||||
@@ -295,6 +308,9 @@
|
||||
<string name="title_installation">"Installation"</string>
|
||||
<string name="title_installed">"Installed"</string>
|
||||
<string name="title_installer">"App installer"</string>
|
||||
<string name="title_install_favourites">"Install favourites?"</string>
|
||||
<string name="install_favourites_summary">"This will download and install %1$d apps."</string>
|
||||
<string name="install_favourites_warning">"You may see a separate installation prompt for each app."</string>
|
||||
<string name="title_language">"Language"</string>
|
||||
<string name="title_library">"Library"</string>
|
||||
<string name="title_no_network">"No network"</string>
|
||||
@@ -398,6 +414,8 @@
|
||||
<string name="pref_theme_style_system">Follow system</string>
|
||||
<string name="pref_theme_style_light">Light</string>
|
||||
<string name="pref_theme_style_dark">Dark</string>
|
||||
<string name="pref_ui_dynamic_color">Dynamic colors</string>
|
||||
<string name="pref_ui_dynamic_color_desc">Use colors from your wallpaper. Turn off for Aurora\'s brand palette.</string>
|
||||
<string name="pref_ui_layout">Layout</string>
|
||||
<string name="pref_ui_layout_tab">Select default tab</string>
|
||||
<string name="app_links_title">App links</string>
|
||||
@@ -507,6 +525,9 @@
|
||||
<string name="toast_fav_export_failed">Failed to export favourites!</string>
|
||||
<string name="toast_fav_import_success">Favourites imported!</string>
|
||||
<string name="toast_fav_export_success">Favourites exported!</string>
|
||||
<string name="toast_fav_install_enqueued">"Installing %1$d apps"</string>
|
||||
<string name="toast_fav_install_none">"Nothing to install"</string>
|
||||
<string name="toast_fav_install_failed">"Failed to fetch app details"</string>
|
||||
|
||||
<!-- BlacklistFragment-->
|
||||
<string name="toast_black_import_failed">Failed to import blacklist!</string>
|
||||
@@ -585,6 +606,10 @@
|
||||
<item quantity="one">Permission required</item>
|
||||
<item quantity="other">Permissions required</item>
|
||||
</plurals>
|
||||
<plurals name="favourites_count">
|
||||
<item quantity="one">%1$d favourite</item>
|
||||
<item quantity="other">%1$d favourites</item>
|
||||
</plurals>
|
||||
<plurals name="notification_installed_summary">
|
||||
<item quantity="one">%d app installed</item>
|
||||
<item quantity="other">%d apps installed</item>
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
<item name="chipStyle">@style/Chip.Filter</item>
|
||||
<item name="bottomSheetStyle">@style/AppTheme.BottomSheetStyle</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<!-- Brand accent for devices without dynamic color (Android 11 and below) -->
|
||||
<item name="colorPrimary">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.Translucent" parent="Theme.Material3.DynamicColors.DayNight.NoActionBar">
|
||||
@@ -35,6 +37,14 @@
|
||||
<item name="android:windowTranslucentNavigation">true</item>
|
||||
</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="Chip.Filter" parent="@style/Widget.Material3.Chip.Filter" />
|
||||
|
||||
@@ -18,7 +18,7 @@ composeBom = "2026.05.01"
|
||||
composeMaterial = "1.5.0-alpha20"
|
||||
core = "1.18.0"
|
||||
espresso = "3.7.0"
|
||||
gplayapi = "3.6.2"
|
||||
gplayapi = "3.6.3"
|
||||
hiddenapibypass = "6.1"
|
||||
hilt = "2.59.2"
|
||||
junit = "4.13.2"
|
||||
|
||||
Reference in New Issue
Block a user