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>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SHOW_APP_INFO" />
|
<action android:name="android.intent.action.SHOW_APP_INFO" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- Confirmation gate for external app-listing deep links (market:// & play.google.com) -->
|
||||||
|
<activity
|
||||||
|
android:name=".DeepLinkConfirmActivity"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/AppTheme.Translucent.Transparent">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
@@ -121,7 +131,6 @@
|
|||||||
<data android:scheme="https" />
|
<data android:scheme="https" />
|
||||||
<data android:host="market.android.com" />
|
<data android:host="market.android.com" />
|
||||||
<data android:host="play.google.com" />
|
<data android:host="play.google.com" />
|
||||||
<data android:host="play.google.com" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ val isHuawei: Boolean
|
|||||||
Build.HARDWARE.lowercase(Locale.getDefault()).contains("kirin") ||
|
Build.HARDWARE.lowercase(Locale.getDefault()).contains("kirin") ||
|
||||||
Build.HARDWARE.lowercase(Locale.getDefault()).contains("hi3")
|
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")
|
@get:SuppressLint("PrivateApi")
|
||||||
val isMiuiOptimizationDisabled: Boolean
|
val isMiuiOptimizationDisabled: Boolean
|
||||||
get() {
|
get() {
|
||||||
|
|||||||
@@ -85,8 +85,13 @@ class AuroraApp : Application(), Configuration.Provider, SingletonImageLoader.Fa
|
|||||||
val themeStyle = Preferences.getInteger(this, Preferences.PREFERENCE_THEME_STYLE)
|
val themeStyle = Preferences.getInteger(this, Preferences.PREFERENCE_THEME_STYLE)
|
||||||
setAppTheme(themeStyle)
|
setAppTheme(themeStyle)
|
||||||
|
|
||||||
// Apply dynamic colors to activities
|
// Apply dynamic colors to activities, unless disabled (opt-out, off by default on One UI)
|
||||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
val dynamicColors = Preferences.getBoolean(
|
||||||
|
this,
|
||||||
|
Preferences.PREFERENCE_DYNAMIC_COLORS,
|
||||||
|
Preferences.dynamicColorsDefault
|
||||||
|
)
|
||||||
|
if (dynamicColors) DynamicColors.applyToActivitiesIfAvailable(this)
|
||||||
|
|
||||||
// Required for Shizuku installer
|
// Required for Shizuku installer
|
||||||
if (isPAndAbove) HiddenApiBypass.addHiddenApiExemptions("I", "L")
|
if (isPAndAbove) HiddenApiBypass.addHiddenApiExemptions("I", "L")
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
package com.aurora.store
|
package com.aurora.store
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
@@ -152,22 +151,11 @@ class ComposeActivity : FragmentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveStartDestination(): Screen {
|
private fun resolveStartDestination(): Screen {
|
||||||
// Parcel-based navigation (e.g. from NotificationUtil)
|
// Parcel-based navigation (e.g. from NotificationUtil or DeepLinkConfirmActivity, which
|
||||||
|
// owns the external ACTION_VIEW market:// and play.google.com deep links)
|
||||||
IntentCompat.getParcelableExtra(intent, Screen.PARCEL_KEY, Screen::class.java)
|
IntentCompat.getParcelableExtra(intent, Screen.PARCEL_KEY, Screen::class.java)
|
||||||
?.let { return it }
|
?.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
|
// SEND / SHOW_APP_INFO — getPackageName() handles both
|
||||||
intent.getPackageName()?.let { return Screen.AppDetails(it) }
|
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.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
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.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -30,49 +34,94 @@ import coil3.compose.AsyncImage
|
|||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import coil3.request.crossfade
|
import coil3.request.crossfade
|
||||||
import com.aurora.gplayapi.data.models.App
|
import com.aurora.gplayapi.data.models.App
|
||||||
|
import com.aurora.store.AuroraApp
|
||||||
import com.aurora.store.R
|
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.AppPreviewProvider
|
||||||
import com.aurora.store.compose.preview.ThemePreviewProvider
|
import com.aurora.store.compose.preview.ThemePreviewProvider
|
||||||
import com.aurora.store.compose.theme.colorGreen
|
import com.aurora.store.compose.theme.colorGreen
|
||||||
import com.aurora.store.compose.theme.colorRed
|
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.data.room.favourite.Favourite
|
||||||
import com.aurora.store.util.PackageUtil
|
import com.aurora.store.util.PackageUtil
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FavouriteListItem(
|
fun FavouriteListItem(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
favourite: Favourite,
|
favourite: Favourite,
|
||||||
|
download: Download? = null,
|
||||||
onClick: () -> Unit = {},
|
onClick: () -> Unit = {},
|
||||||
onClear: () -> Unit = {}
|
onClear: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
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 ->
|
RemovableListItem(onRemove = onClear) { triggerRemove ->
|
||||||
AuroraListItem(
|
AuroraListItem(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
headline = favourite.displayName,
|
headline = favourite.displayName,
|
||||||
supporting = favourite.packageName,
|
supporting = favourite.packageName,
|
||||||
tertiary = DateUtils.formatDateTime(
|
tertiary = statusText,
|
||||||
context,
|
|
||||||
favourite.added,
|
|
||||||
DateUtils.FORMAT_SHOW_DATE
|
|
||||||
),
|
|
||||||
headlineStyle = MaterialTheme.typography.bodyMedium,
|
headlineStyle = MaterialTheme.typography.bodyMedium,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
leading = {
|
leading = {
|
||||||
AsyncImage(
|
if (inProgress) {
|
||||||
model = ImageRequest.Builder(context)
|
AnimatedAppIcon(
|
||||||
.data(favourite.iconURL)
|
modifier = Modifier
|
||||||
.crossfade(true)
|
.requiredSize(dimensionResource(R.dimen.icon_size_medium)),
|
||||||
.build(),
|
iconUrl = favourite.iconURL,
|
||||||
contentDescription = null,
|
inProgress = true,
|
||||||
contentScale = ContentScale.Crop,
|
progress = progress
|
||||||
modifier = Modifier
|
)
|
||||||
.requiredSize(dimensionResource(R.dimen.icon_size_medium))
|
} else {
|
||||||
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
|
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 = {
|
trailing = {
|
||||||
Row(
|
Row(
|
||||||
@@ -81,7 +130,7 @@ fun FavouriteListItem(
|
|||||||
dimensionResource(R.dimen.spacing_xsmall)
|
dimensionResource(R.dimen.spacing_xsmall)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
if (isInstalled) {
|
if (isInstalled && !inProgress) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(R.drawable.ic_check),
|
painter = painterResource(R.drawable.ic_check),
|
||||||
contentDescription = stringResource(R.string.title_installed),
|
contentDescription = stringResource(R.string.title_installed),
|
||||||
@@ -107,3 +156,18 @@ fun FavouriteListItem(
|
|||||||
private fun FavouriteListItemPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
|
private fun FavouriteListItemPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
|
||||||
FavouriteListItem(favourite = Favourite.fromApp(app, Favourite.Mode.MANUAL))
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.dimensionResource
|
import androidx.compose.ui.res.dimensionResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
@@ -47,15 +47,13 @@ fun PageIndicator(modifier: Modifier = Modifier, totalPages: Int, currentPage: I
|
|||||||
Alignment.CenterHorizontally
|
Alignment.CenterHorizontally
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
val selectedColor = MaterialTheme.colorScheme.primary
|
||||||
|
val unselectedColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
repeat(totalPages) { iteration ->
|
repeat(totalPages) { iteration ->
|
||||||
val page = stringResource(R.string.page, iteration)
|
val page = stringResource(R.string.page, iteration)
|
||||||
val isSelected = currentPage == iteration
|
val isSelected = currentPage == iteration
|
||||||
val color by animateColorAsState(
|
val color by animateColorAsState(
|
||||||
targetValue = if (isSelected) {
|
targetValue = if (isSelected) selectedColor else unselectedColor,
|
||||||
Color.DarkGray
|
|
||||||
} else {
|
|
||||||
Color.LightGray
|
|
||||||
},
|
|
||||||
animationSpec = tween()
|
animationSpec = tween()
|
||||||
)
|
)
|
||||||
val size by animateDpAsState(
|
val size by animateDpAsState(
|
||||||
|
|||||||
@@ -43,8 +43,10 @@ fun AppUpdateItem(
|
|||||||
onCancel: () -> Unit = {},
|
onCancel: () -> Unit = {},
|
||||||
onUnignore: (() -> Unit)? = null
|
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 inProgress = download != null && !download.isFinished
|
||||||
val pendingInstall = download?.status == DownloadStatus.COMPLETED
|
val installing = download?.status == DownloadStatus.INSTALLING
|
||||||
val progress = if (download?.status == DownloadStatus.DOWNLOADING) {
|
val progress = if (download?.status == DownloadStatus.DOWNLOADING) {
|
||||||
download.progress.toFloat()
|
download.progress.toFloat()
|
||||||
} else {
|
} else {
|
||||||
@@ -65,7 +67,7 @@ fun AppUpdateItem(
|
|||||||
AnimatedAppIcon(
|
AnimatedAppIcon(
|
||||||
modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)),
|
modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)),
|
||||||
iconUrl = update.iconURL,
|
iconUrl = update.iconURL,
|
||||||
inProgress = inProgress || pendingInstall,
|
inProgress = inProgress,
|
||||||
progress = progress
|
progress = progress
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -100,7 +102,7 @@ fun AppUpdateItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingInstall -> {
|
installing -> {
|
||||||
OutlinedButton(onClick = {}, enabled = false) {
|
OutlinedButton(onClick = {}, enabled = false) {
|
||||||
Text(stringResource(R.string.action_installing))
|
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 ->
|
entry<Screen.PermissionRationale> { screen ->
|
||||||
PermissionRationaleScreen(
|
PermissionRationaleScreen(
|
||||||
requiredPermissions = screen.requiredPermissions
|
requiredPermissions = screen.requiredPermissions
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ sealed class Screen : NavKey, Parcelable {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class DevProfile(val developerId: String) : Screen()
|
data class DevProfile(val developerId: String) : Screen()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PublisherProfile(val publisherId: String) : Screen()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AppDetails(val packageName: String) : Screen()
|
data class AppDetails(val packageName: String) : Screen()
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,101 @@
|
|||||||
|
|
||||||
package com.aurora.store.compose.theme
|
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.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.activity.compose.LocalActivity
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialExpressiveTheme
|
import androidx.compose.material3.MaterialExpressiveTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.darkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.material3.lightColorScheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.res.colorResource
|
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import com.aurora.store.R
|
|
||||||
import com.aurora.store.util.Preferences
|
import com.aurora.store.util.Preferences
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App theme for Aurora Store based on [MaterialTheme]
|
* App theme for Aurora Store based on [MaterialExpressiveTheme]
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AuroraTheme(content: @Composable () -> Unit) {
|
fun AuroraTheme(content: @Composable () -> Unit) {
|
||||||
@@ -40,28 +36,42 @@ fun AuroraTheme(content: @Composable () -> Unit) {
|
|||||||
var themeStyle by remember {
|
var themeStyle by remember {
|
||||||
mutableIntStateOf(Preferences.getInteger(context, Preferences.PREFERENCE_THEME_STYLE))
|
mutableIntStateOf(Preferences.getInteger(context, Preferences.PREFERENCE_THEME_STYLE))
|
||||||
}
|
}
|
||||||
|
var dynamicColor by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
Preferences.getBoolean(
|
||||||
|
context,
|
||||||
|
Preferences.PREFERENCE_DYNAMIC_COLORS,
|
||||||
|
Preferences.dynamicColorsDefault
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
val prefs = Preferences.getPrefs(context)
|
val prefs = Preferences.getPrefs(context)
|
||||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||||
if (key == Preferences.PREFERENCE_THEME_STYLE) {
|
when (key) {
|
||||||
themeStyle = Preferences.getInteger(context, Preferences.PREFERENCE_THEME_STYLE)
|
Preferences.PREFERENCE_THEME_STYLE ->
|
||||||
|
themeStyle = Preferences.getInteger(context, key)
|
||||||
|
|
||||||
|
Preferences.PREFERENCE_DYNAMIC_COLORS ->
|
||||||
|
dynamicColor =
|
||||||
|
Preferences.getBoolean(context, key, Preferences.dynamicColorsDefault)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||||
onDispose { prefs.unregisterOnSharedPreferenceChangeListener(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)
|
dynamicLightColorScheme(context)
|
||||||
} else {
|
} else {
|
||||||
lightColorScheme(primary = colorResource(id = R.color.colorAccent))
|
BrandLightColorScheme
|
||||||
}
|
}
|
||||||
|
|
||||||
val darkScheme = if (isDynamicColorSupported) {
|
val darkScheme = if (useDynamicColor) {
|
||||||
dynamicDarkColorScheme(context)
|
dynamicDarkColorScheme(context)
|
||||||
} else {
|
} else {
|
||||||
darkColorScheme(primary = colorResource(id = R.color.colorAccent))
|
BrandDarkColorScheme
|
||||||
}
|
}
|
||||||
|
|
||||||
val colorScheme = when (themeStyle) {
|
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.
|
* Keep the status/navigation bar icon appearance in sync with 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.
|
*
|
||||||
|
* 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 view = LocalView.current
|
||||||
val activity = LocalActivity.current
|
val activity = LocalActivity.current
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
SideEffect {
|
SideEffect {
|
||||||
val currentActivity = activity ?: return@SideEffect
|
val currentActivity = activity ?: return@SideEffect
|
||||||
val window = currentActivity.window
|
|
||||||
|
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
|
||||||
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
|
||||||
|
|
||||||
WindowCompat
|
WindowCompat
|
||||||
.getInsetsController(window, view)
|
.getInsetsController(currentActivity.window, view)
|
||||||
.apply {
|
.apply {
|
||||||
isAppearanceLightStatusBars = !darkTheme
|
isAppearanceLightStatusBars = !darkTheme
|
||||||
isAppearanceLightNavigationBars = !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
|
package com.aurora.store.compose.ui.details
|
||||||
|
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.fadeIn
|
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.Screenshots
|
||||||
import com.aurora.store.compose.ui.details.composable.Tags
|
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.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.AppDetailsMenu
|
||||||
import com.aurora.store.compose.ui.details.menu.MenuItem
|
import com.aurora.store.compose.ui.details.menu.MenuItem
|
||||||
import com.aurora.store.compose.ui.details.navigation.ExtraScreen
|
import com.aurora.store.compose.ui.details.navigation.ExtraScreen
|
||||||
@@ -120,6 +122,7 @@ fun AppDetailsScreen(
|
|||||||
val app by viewModel.app.collectAsStateWithLifecycle()
|
val app by viewModel.app.collectAsStateWithLifecycle()
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val featuredReviews by viewModel.featuredReviews.collectAsStateWithLifecycle()
|
val featuredReviews by viewModel.featuredReviews.collectAsStateWithLifecycle()
|
||||||
|
val userReview by viewModel.userReview.collectAsStateWithLifecycle()
|
||||||
val favorite by viewModel.favourite.collectAsStateWithLifecycle()
|
val favorite by viewModel.favourite.collectAsStateWithLifecycle()
|
||||||
val exodusReport by viewModel.exodusReport.collectAsStateWithLifecycle()
|
val exodusReport by viewModel.exodusReport.collectAsStateWithLifecycle()
|
||||||
val dataSafetyReport by viewModel.dataSafetyReport.collectAsStateWithLifecycle()
|
val dataSafetyReport by viewModel.dataSafetyReport.collectAsStateWithLifecycle()
|
||||||
@@ -129,12 +132,22 @@ fun AppDetailsScreen(
|
|||||||
|
|
||||||
LaunchedEffect(key1 = packageName) { viewModel.fetchAppDetails(packageName) }
|
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 ->
|
app?.let { loadedApp ->
|
||||||
installError?.let { err ->
|
installError?.let { err ->
|
||||||
InstallErrorSheet(
|
InstallErrorSheet(
|
||||||
app = loadedApp,
|
app = loadedApp,
|
||||||
error = err.error,
|
error = err.error,
|
||||||
extra = err.extra,
|
extra = err.extra,
|
||||||
|
isAnonymous = viewModel.authProvider.isAnonymous,
|
||||||
|
onBuy = { openPlayStore(context, loadedApp.packageName) },
|
||||||
onDismiss = viewModel::dismissInstallError
|
onDismiss = viewModel::dismissInstallError
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -161,6 +174,7 @@ fun AppDetailsScreen(
|
|||||||
ScreenContentApp(
|
ScreenContentApp(
|
||||||
app = loadedApp,
|
app = loadedApp,
|
||||||
featuredReviews = featuredReviews,
|
featuredReviews = featuredReviews,
|
||||||
|
userReview = userReview,
|
||||||
suggestionsBundle = suggestionsBundle,
|
suggestionsBundle = suggestionsBundle,
|
||||||
isFavorite = favorite,
|
isFavorite = favorite,
|
||||||
isAnonymous = viewModel.authProvider.isAnonymous,
|
isAnonymous = viewModel.authProvider.isAnonymous,
|
||||||
@@ -186,6 +200,14 @@ fun AppDetailsScreen(
|
|||||||
onTestingSubscriptionChange = { subscribe ->
|
onTestingSubscriptionChange = { subscribe ->
|
||||||
viewModel.updateTestingProgramStatus(packageName, 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,
|
forceSinglePane = forceSinglePane,
|
||||||
onForceRestart = {
|
onForceRestart = {
|
||||||
val intent = Intent(context, ComposeActivity::class.java)
|
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 {
|
private fun stateKey(state: AppState, app: App?): String = when {
|
||||||
state is AppState.Error -> "error"
|
state is AppState.Error -> "error"
|
||||||
state is AppState.Loading || app == null -> "loading"
|
state is AppState.Loading || app == null -> "loading"
|
||||||
@@ -241,6 +278,7 @@ private fun ScreenContentError(message: String? = null, onRetry: (() -> Unit)? =
|
|||||||
private fun ScreenContentApp(
|
private fun ScreenContentApp(
|
||||||
app: App,
|
app: App,
|
||||||
featuredReviews: List<Review> = emptyList(),
|
featuredReviews: List<Review> = emptyList(),
|
||||||
|
userReview: Review? = null,
|
||||||
suggestionsBundle: StreamBundle? = StreamBundle.EMPTY,
|
suggestionsBundle: StreamBundle? = StreamBundle.EMPTY,
|
||||||
isFavorite: Boolean = false,
|
isFavorite: Boolean = false,
|
||||||
isAnonymous: Boolean = true,
|
isAnonymous: Boolean = true,
|
||||||
@@ -256,11 +294,18 @@ private fun ScreenContentApp(
|
|||||||
onUninstall: () -> Unit = {},
|
onUninstall: () -> Unit = {},
|
||||||
onOpen: () -> Unit = {},
|
onOpen: () -> Unit = {},
|
||||||
onTestingSubscriptionChange: (subscribe: Boolean) -> Unit = {},
|
onTestingSubscriptionChange: (subscribe: Boolean) -> Unit = {},
|
||||||
|
onSubmitReview: (rating: Int, title: String, comment: String) -> Unit = { _, _, _ -> },
|
||||||
|
onDeleteReview: () -> Unit = {},
|
||||||
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2(),
|
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2(),
|
||||||
forceSinglePane: Boolean = false,
|
forceSinglePane: Boolean = false,
|
||||||
onForceRestart: () -> Unit = {}
|
onForceRestart: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
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)
|
var scaffoldDirective = calculatePaneScaffoldDirective(windowAdaptiveInfo)
|
||||||
|
|
||||||
if (forceSinglePane) {
|
if (forceSinglePane) {
|
||||||
@@ -317,7 +362,11 @@ private fun ScreenContentApp(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SetupMenu() {
|
fun SetupMenu() {
|
||||||
AppDetailsMenu(isFavorite = isFavorite, state = state) { menuItem ->
|
AppDetailsMenu(
|
||||||
|
isFavorite = isFavorite,
|
||||||
|
state = state,
|
||||||
|
canManualDownload = canAcquire
|
||||||
|
) { menuItem ->
|
||||||
when (menuItem) {
|
when (menuItem) {
|
||||||
MenuItem.FAVORITE -> onFavorite()
|
MenuItem.FAVORITE -> onFavorite()
|
||||||
|
|
||||||
@@ -333,20 +382,7 @@ private fun ScreenContentApp(
|
|||||||
ShortcutManagerUtil.requestPinShortcut(context, app.packageName)
|
ShortcutManagerUtil.requestPinShortcut(context, app.packageName)
|
||||||
}
|
}
|
||||||
|
|
||||||
MenuItem.PLAY_STORE -> {
|
MenuItem.PLAY_STORE -> openPlayStore(context, app.packageName)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -413,6 +449,8 @@ private fun ScreenContentApp(
|
|||||||
Actions(
|
Actions(
|
||||||
primaryActionDisplayName = primaryActionName,
|
primaryActionDisplayName = primaryActionName,
|
||||||
secondaryActionDisplayName = stringResource(R.string.title_manual_download),
|
secondaryActionDisplayName = stringResource(R.string.title_manual_download),
|
||||||
|
isPrimaryActionEnabled = canAcquire,
|
||||||
|
isSecondaryActionEnabled = canAcquire,
|
||||||
onPrimaryAction = ::onInstall,
|
onPrimaryAction = ::onInstall,
|
||||||
onSecondaryAction = { showExtraPane(ExtraScreen.ManualDownload) }
|
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 {
|
item {
|
||||||
if (!isAnonymous && app.testingProgram?.isAvailable == true) {
|
if (!isAnonymous && app.testingProgram?.isAvailable == true) {
|
||||||
Testing(
|
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,
|
state: AppState = AppState.Unavailable,
|
||||||
isFavorite: Boolean = false,
|
isFavorite: Boolean = false,
|
||||||
isExpanded: Boolean = false,
|
isExpanded: Boolean = false,
|
||||||
|
canManualDownload: Boolean = true,
|
||||||
onMenuItemClicked: (menuItem: MenuItem) -> Unit = {}
|
onMenuItemClicked: (menuItem: MenuItem) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -80,7 +81,7 @@ fun AppDetailsMenu(
|
|||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(text = stringResource(R.string.title_manual_download)) },
|
text = { Text(text = stringResource(R.string.title_manual_download)) },
|
||||||
onClick = { onClick(MenuItem.MANUAL_DOWNLOAD) },
|
onClick = { onClick(MenuItem.MANUAL_DOWNLOAD) },
|
||||||
enabled = !state.inProgress()
|
enabled = canManualDownload && !state.inProgress()
|
||||||
)
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(text = stringResource(R.string.action_info)) },
|
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.material3.adaptive.currentWindowAdaptiveInfoV2
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.Modifier
|
||||||
import androidx.compose.ui.res.dimensionResource
|
import androidx.compose.ui.res.dimensionResource
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
@@ -30,14 +32,19 @@ import androidx.paging.compose.collectAsLazyPagingItems
|
|||||||
import androidx.paging.compose.itemKey
|
import androidx.paging.compose.itemKey
|
||||||
import com.aurora.extensions.adaptiveNavigationIcon
|
import com.aurora.extensions.adaptiveNavigationIcon
|
||||||
import com.aurora.gplayapi.data.models.App
|
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.R
|
||||||
import com.aurora.store.compose.composable.ContainedLoadingIndicator
|
import com.aurora.store.compose.composable.ContainedLoadingIndicator
|
||||||
import com.aurora.store.compose.composable.Placeholder
|
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.TopAppBar
|
||||||
import com.aurora.store.compose.composable.app.LargeAppListItem
|
import com.aurora.store.compose.composable.app.LargeAppListItem
|
||||||
import com.aurora.store.compose.navigation.Destination
|
import com.aurora.store.compose.navigation.Destination
|
||||||
import com.aurora.store.compose.preview.AppPreviewProvider
|
import com.aurora.store.compose.preview.AppPreviewProvider
|
||||||
import com.aurora.store.compose.preview.ThemePreviewProvider
|
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.details.DevProfileViewModel
|
||||||
import com.aurora.store.viewmodel.search.SearchViewModel
|
import com.aurora.store.viewmodel.search.SearchViewModel
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
@@ -53,7 +60,61 @@ fun DevProfileScreen(
|
|||||||
onNavigateTo: (Destination) -> Unit,
|
onNavigateTo: (Destination) -> Unit,
|
||||||
viewModel: DevProfileViewModel = hiltViewModel()
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -25,11 +29,13 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.dimensionResource
|
import androidx.compose.ui.res.dimensionResource
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.paging.LoadState
|
import androidx.paging.LoadState
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
import androidx.paging.compose.LazyPagingItems
|
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.FavouriteListItem
|
||||||
import com.aurora.store.compose.composable.Placeholder
|
import com.aurora.store.compose.composable.Placeholder
|
||||||
import com.aurora.store.compose.composable.ScrollHint
|
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.composable.TopAppBar
|
||||||
import com.aurora.store.compose.navigation.Destination
|
import com.aurora.store.compose.navigation.Destination
|
||||||
import com.aurora.store.compose.preview.FavouritePreviewProvider
|
import com.aurora.store.compose.preview.FavouritePreviewProvider
|
||||||
import com.aurora.store.compose.preview.ThemePreviewProvider
|
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.FavouriteMenu
|
||||||
import com.aurora.store.compose.ui.favourite.menu.MenuItem
|
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.data.room.favourite.Favourite
|
||||||
import com.aurora.store.viewmodel.all.FavouriteViewModel
|
import com.aurora.store.viewmodel.all.FavouriteViewModel
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
@@ -62,6 +71,24 @@ fun FavouriteScreen(
|
|||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val favourites = viewModel.favourites.collectAsLazyPagingItems()
|
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(
|
val documentOpenLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.OpenDocument(),
|
contract = ActivityResultContracts.OpenDocument(),
|
||||||
@@ -89,7 +116,11 @@ fun FavouriteScreen(
|
|||||||
|
|
||||||
ScreenContent(
|
ScreenContent(
|
||||||
favourites = favourites,
|
favourites = favourites,
|
||||||
|
downloads = downloadMap,
|
||||||
|
isEnqueuing = isEnqueuing,
|
||||||
|
showInstallAll = hasInstallableFavourites,
|
||||||
onNavigateTo = onNavigateTo,
|
onNavigateTo = onNavigateTo,
|
||||||
|
onInstallAll = { viewModel.installAll() },
|
||||||
onRemoveFavourite = { packageName -> viewModel.removeFavourite(packageName) },
|
onRemoveFavourite = { packageName -> viewModel.removeFavourite(packageName) },
|
||||||
onImportFavourites = {
|
onImportFavourites = {
|
||||||
documentOpenLauncher.launch(arrayOf(JSON_MIME_TYPE))
|
documentOpenLauncher.launch(arrayOf(JSON_MIME_TYPE))
|
||||||
@@ -105,7 +136,11 @@ fun FavouriteScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ScreenContent(
|
private fun ScreenContent(
|
||||||
favourites: LazyPagingItems<Favourite> = emptyPagingItems(),
|
favourites: LazyPagingItems<Favourite> = emptyPagingItems(),
|
||||||
|
downloads: Map<String, Download> = emptyMap(),
|
||||||
|
isEnqueuing: Boolean = false,
|
||||||
|
showInstallAll: Boolean = true,
|
||||||
onNavigateTo: (Destination) -> Unit = {},
|
onNavigateTo: (Destination) -> Unit = {},
|
||||||
|
onInstallAll: () -> Unit = {},
|
||||||
onRemoveFavourite: (packageName: String) -> Unit = {},
|
onRemoveFavourite: (packageName: String) -> Unit = {},
|
||||||
onImportFavourites: () -> Unit = {},
|
onImportFavourites: () -> Unit = {},
|
||||||
onExportFavourites: () -> 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.
|
* Save the initial loading state to make sure we don't replay the loading animation again.
|
||||||
*/
|
*/
|
||||||
var initialLoad by rememberSaveable { mutableStateOf(true) }
|
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
|
@Composable
|
||||||
fun SetupMenu() {
|
fun SetupMenu() {
|
||||||
@@ -162,6 +209,29 @@ private fun ScreenContent(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState
|
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(
|
items(
|
||||||
count = favourites.itemCount,
|
count = favourites.itemCount,
|
||||||
key = favourites.itemKey { it.packageName }
|
key = favourites.itemKey { it.packageName }
|
||||||
@@ -170,6 +240,7 @@ private fun ScreenContent(
|
|||||||
FavouriteListItem(
|
FavouriteListItem(
|
||||||
modifier = Modifier.animateItem(),
|
modifier = Modifier.animateItem(),
|
||||||
favourite = favourite,
|
favourite = favourite,
|
||||||
|
download = downloads[favourite.packageName],
|
||||||
onClick = {
|
onClick = {
|
||||||
onNavigateTo(
|
onNavigateTo(
|
||||||
Destination.AppDetails(favourite.packageName)
|
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.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import com.aurora.extensions.isSAndAbove
|
||||||
import com.aurora.extensions.isTAndAbove
|
import com.aurora.extensions.isTAndAbove
|
||||||
import com.aurora.extensions.setAppTheme
|
import com.aurora.extensions.setAppTheme
|
||||||
import com.aurora.store.R
|
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.compose.ui.preferences.network.SingleChoiceDialog
|
||||||
import com.aurora.store.util.Preferences
|
import com.aurora.store.util.Preferences
|
||||||
import com.aurora.store.util.Preferences.PREFERENCE_DEFAULT_SELECTED_TAB
|
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_FOR_YOU
|
||||||
import com.aurora.store.util.Preferences.PREFERENCE_THEME_STYLE
|
import com.aurora.store.util.Preferences.PREFERENCE_THEME_STYLE
|
||||||
import com.aurora.store.util.save
|
import com.aurora.store.util.save
|
||||||
@@ -62,6 +64,15 @@ private fun ScreenContent() {
|
|||||||
var forYou by remember {
|
var forYou by remember {
|
||||||
mutableStateOf(Preferences.getBoolean(context, PREFERENCE_FOR_YOU, true))
|
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 showThemeDialog by remember { mutableStateOf(false) }
|
||||||
var showTabDialog by remember { mutableStateOf(false) }
|
var showTabDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -132,6 +143,29 @@ private fun ScreenContent() {
|
|||||||
supportingContent = { Text(themeEntries.getOrElse(themeStyle) { "" }) }
|
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 { HorizontalDivider() }
|
||||||
item {
|
item {
|
||||||
ListItem(headlineContent = { Text(stringResource(R.string.pref_ui_layout)) })
|
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.AppLockAuthenticator
|
||||||
import com.aurora.store.util.Preferences
|
import com.aurora.store.util.Preferences
|
||||||
import com.aurora.store.util.Preferences.PREFERENCE_APP_LOCK_ENABLED
|
import com.aurora.store.util.Preferences.PREFERENCE_APP_LOCK_ENABLED
|
||||||
|
import com.aurora.store.util.Preferences.PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK
|
||||||
import com.aurora.store.util.save
|
import com.aurora.store.util.save
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -43,6 +44,11 @@ private fun ScreenContent() {
|
|||||||
var appLockEnabled by remember {
|
var appLockEnabled by remember {
|
||||||
mutableStateOf(Preferences.getBoolean(context, PREFERENCE_APP_LOCK_ENABLED, false))
|
mutableStateOf(Preferences.getBoolean(context, PREFERENCE_APP_LOCK_ENABLED, false))
|
||||||
}
|
}
|
||||||
|
var confirmDeepLink by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
Preferences.getBoolean(context, PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK, true)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun setAppLock(enabled: Boolean) {
|
fun setAppLock(enabled: Boolean) {
|
||||||
// Refuse to enable the lock when the device has no biometric or screen-lock to fall back on
|
// Refuse to enable the lock when the device has no biometric or screen-lock to fall back on
|
||||||
@@ -54,6 +60,11 @@ private fun ScreenContent() {
|
|||||||
context.save(PREFERENCE_APP_LOCK_ENABLED, enabled)
|
context.save(PREFERENCE_APP_LOCK_ENABLED, enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setConfirmDeepLink(enabled: Boolean) {
|
||||||
|
confirmDeepLink = enabled
|
||||||
|
context.save(PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = { TopAppBar(title = stringResource(R.string.title_security)) }
|
topBar = { TopAppBar(title = stringResource(R.string.title_security)) }
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
@@ -75,6 +86,20 @@ private fun ScreenContent() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { setConfirmDeepLink(!confirmDeepLink) },
|
||||||
|
headlineContent = { Text(stringResource(R.string.confirm_deeplink_title)) },
|
||||||
|
supportingContent = { Text(stringResource(R.string.confirm_deeplink_summary)) },
|
||||||
|
trailingContent = {
|
||||||
|
Switch(
|
||||||
|
checked = confirmDeepLink,
|
||||||
|
onCheckedChange = { setConfirmDeepLink(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.aurora.store.compose.ui.sheets
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.dimensionResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import com.aurora.store.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play Store-style bottom sheet shown before opening an app/developer listing from an external
|
||||||
|
* deep link. Requires an explicit tap so ads cannot silently launch Aurora into a listing.
|
||||||
|
*
|
||||||
|
* @param targetLabel Package name or developer id the link points to
|
||||||
|
* @param sourceLabel Human-readable name of the app that fired the link, or null if unknown
|
||||||
|
* @param onOpen Invoked when the user confirms opening the listing
|
||||||
|
* @param onDismiss Invoked when the user cancels or dismisses the sheet
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun DeepLinkConfirmSheet(
|
||||||
|
targetLabel: String,
|
||||||
|
sourceLabel: String?,
|
||||||
|
onOpen: () -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(
|
||||||
|
horizontal = dimensionResource(R.dimen.spacing_medium),
|
||||||
|
vertical = dimensionResource(R.dimen.spacing_xsmall)
|
||||||
|
),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(
|
||||||
|
dimensionResource(R.dimen.spacing_medium)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_logo_alt),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium))
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.confirm_deeplink_sheet_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(vertical = dimensionResource(R.dimen.spacing_xsmall))
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
horizontal = dimensionResource(R.dimen.spacing_medium),
|
||||||
|
vertical = dimensionResource(R.dimen.spacing_xsmall)
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(
|
||||||
|
dimensionResource(R.dimen.spacing_xsmall)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.confirm_deeplink_sheet_message),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = targetLabel,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
if (!sourceLabel.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.confirm_deeplink_sheet_source, sourceLabel),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(
|
||||||
|
horizontal = dimensionResource(R.dimen.spacing_xsmall),
|
||||||
|
vertical = dimensionResource(R.dimen.spacing_xsmall)
|
||||||
|
),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(text = stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
TextButton(onClick = onOpen) {
|
||||||
|
Text(text = stringResource(R.string.action_open))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.navigationBarsPadding())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.requiredSize
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -41,9 +42,20 @@ import com.aurora.store.R
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@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
|
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(
|
ModalBottomSheet(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
@@ -53,7 +65,7 @@ fun InstallErrorSheet(app: App, error: String?, extra: String?, onDismiss: () ->
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
) {
|
) {
|
||||||
Header(app = app)
|
Header(app = app, showBuy = canBuy, onBuy = onBuy)
|
||||||
|
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
modifier = Modifier.padding(
|
modifier = Modifier.padding(
|
||||||
@@ -101,7 +113,7 @@ fun InstallErrorSheet(app: App, error: String?, extra: String?, onDismiss: () ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Header(app: App) {
|
private fun Header(app: App, showBuy: Boolean = false, onBuy: () -> Unit = {}) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -137,5 +149,14 @@ private fun Header(app: App) {
|
|||||||
overflow = TextOverflow.Ellipsis
|
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() {
|
fun init() {
|
||||||
AuroraApp.scope.launch {
|
AuroraApp.scope.launch {
|
||||||
cancelFailedDownloads(downloadDao.downloads().firstOrNull() ?: emptyList())
|
val downloads = downloadDao.downloads().firstOrNull() ?: emptyList()
|
||||||
|
cancelFailedDownloads(downloads)
|
||||||
|
finalizeStaleSelfUpdate(downloads)
|
||||||
}.invokeOnCompletion {
|
}.invokeOnCompletion {
|
||||||
observeDownloads()
|
observeDownloads()
|
||||||
observeInstalls()
|
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
|
* Advances a download row through the installer phase so its history reflects whether the
|
||||||
* app actually installed, not just that the bytes finished downloading:
|
* 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.Update
|
||||||
import com.aurora.store.data.room.update.UpdateDao
|
import com.aurora.store.data.room.update.UpdateDao
|
||||||
import com.aurora.store.data.work.UpdateWorker
|
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
|
||||||
import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS_BATTERY
|
import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS_BATTERY
|
||||||
import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS_IDLE
|
import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS_IDLE
|
||||||
@@ -253,13 +254,23 @@ class UpdateHelper @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun deleteInvalidUpdates() {
|
private suspend fun deleteInvalidUpdates() {
|
||||||
updateDao.updates().firstOrNull()?.forEach { update ->
|
updateDao.updates().firstOrNull()?.forEach { update ->
|
||||||
// Nightly builds share a static version code, so the generic "up to date"
|
if (update.isSelfUpdate(context)) {
|
||||||
// check would wrongly drop a freshly found self-update. The worker owns the
|
// Self-updates need a flavor-aware staleness check. Release/preload bump the
|
||||||
// self-update lifecycle (re-checked by build timestamp); cleanup happens via
|
// version code, so the normal up-to-date check works. Nightly reuses a static
|
||||||
// the install event instead.
|
// version code (isUpToDate is always true there), so fall back to the
|
||||||
if (update.isSelfUpdate(context) && BuildType.CURRENT == BuildType.NIGHTLY) {
|
// 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
|
return@forEach
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!update.isInstalled(context) || update.isUpToDate(context)) {
|
if (!update.isInstalled(context) || update.isUpToDate(context)) {
|
||||||
deleteUpdate(update.packageName)
|
deleteUpdate(update.packageName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ data class SelfUpdate(
|
|||||||
val versionCodeRaw: String = "0",
|
val versionCodeRaw: String = "0",
|
||||||
@SerialName("download_url")
|
@SerialName("download_url")
|
||||||
val downloadUrl: String = "",
|
val downloadUrl: String = "",
|
||||||
|
@SerialName("icon_url")
|
||||||
|
val iconUrl: String = "",
|
||||||
|
@SerialName("sha1")
|
||||||
|
val sha1: String = "",
|
||||||
|
@SerialName("sha256")
|
||||||
|
val sha256: String = "",
|
||||||
val changelog: String = "",
|
val changelog: String = "",
|
||||||
@SerialName("size")
|
@SerialName("size")
|
||||||
val sizeRaw: String = "0",
|
val sizeRaw: String = "0",
|
||||||
@@ -61,12 +67,14 @@ data class SelfUpdate(
|
|||||||
updatedOn = updatedOn,
|
updatedOn = updatedOn,
|
||||||
displayName = context.getString(R.string.app_name),
|
displayName = context.getString(R.string.app_name),
|
||||||
developerName = "Rahul Kumar Patel",
|
developerName = "Rahul Kumar Patel",
|
||||||
iconArtwork = Artwork(url = ICON_URL),
|
iconArtwork = Artwork(url = iconUrl),
|
||||||
fileList = mutableListOf(
|
fileList = mutableListOf(
|
||||||
PlayFile(
|
PlayFile(
|
||||||
name = "${context.packageName}.apk",
|
name = "${context.packageName}.apk",
|
||||||
url = downloadUrl,
|
url = downloadUrl,
|
||||||
size = size
|
size = size,
|
||||||
|
sha1 = sha1,
|
||||||
|
sha256 = sha256
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
isFree = true,
|
isFree = true,
|
||||||
@@ -78,11 +86,4 @@ data class SelfUpdate(
|
|||||||
EncodedCertificateSet(certificateSet = it, sha256 = String())
|
EncodedCertificateSet(certificateSet = it, sha256 = String())
|
||||||
}.toMutableList()
|
}.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.download.DownloadDao
|
||||||
import com.aurora.store.data.room.favourite.Favourite
|
import com.aurora.store.data.room.favourite.Favourite
|
||||||
import com.aurora.store.data.room.favourite.FavouriteDao
|
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.IgnoredUpdate
|
||||||
import com.aurora.store.data.room.update.IgnoredUpdateDao
|
import com.aurora.store.data.room.update.IgnoredUpdateDao
|
||||||
import com.aurora.store.data.room.update.Update
|
import com.aurora.store.data.room.update.Update
|
||||||
import com.aurora.store.data.room.update.UpdateDao
|
import com.aurora.store.data.room.update.UpdateDao
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [Download::class, Favourite::class, Update::class, IgnoredUpdate::class],
|
entities = [
|
||||||
version = 8,
|
Download::class,
|
||||||
|
Favourite::class,
|
||||||
|
Update::class,
|
||||||
|
IgnoredUpdate::class,
|
||||||
|
LocalReview::class
|
||||||
|
],
|
||||||
|
version = 9,
|
||||||
exportSchema = true
|
exportSchema = true
|
||||||
)
|
)
|
||||||
@TypeConverters(DownloadConverter::class)
|
@TypeConverters(DownloadConverter::class)
|
||||||
@@ -24,4 +32,5 @@ abstract class AuroraDatabase : RoomDatabase() {
|
|||||||
abstract fun favouriteDao(): FavouriteDao
|
abstract fun favouriteDao(): FavouriteDao
|
||||||
abstract fun updateDao(): UpdateDao
|
abstract fun updateDao(): UpdateDao
|
||||||
abstract fun ignoredUpdateDao(): IgnoredUpdateDao
|
abstract fun ignoredUpdateDao(): IgnoredUpdateDao
|
||||||
|
abstract fun reviewDao(): ReviewDao
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ object MigrationHelper {
|
|||||||
override fun migrate(db: SupportSQLiteDatabase) = migrateFrom7To8(db)
|
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 const val TAG = "MigrationHelper"
|
||||||
|
|
||||||
private fun migrateFrom1To2(database: SupportSQLiteDatabase) {
|
private fun migrateFrom1To2(database: SupportSQLiteDatabase) {
|
||||||
@@ -159,4 +163,34 @@ object MigrationHelper {
|
|||||||
database.endTransaction()
|
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_5_6
|
||||||
import com.aurora.store.data.room.MigrationHelper.MIGRATION_6_7
|
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_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.DownloadConverter
|
||||||
import com.aurora.store.data.room.download.DownloadDao
|
import com.aurora.store.data.room.download.DownloadDao
|
||||||
import com.aurora.store.data.room.favourite.FavouriteDao
|
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.IgnoredUpdateDao
|
||||||
import com.aurora.store.data.room.update.UpdateDao
|
import com.aurora.store.data.room.update.UpdateDao
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
@@ -40,7 +42,8 @@ object RoomModule {
|
|||||||
MIGRATION_4_5,
|
MIGRATION_4_5,
|
||||||
MIGRATION_5_6,
|
MIGRATION_5_6,
|
||||||
MIGRATION_6_7,
|
MIGRATION_6_7,
|
||||||
MIGRATION_7_8
|
MIGRATION_7_8,
|
||||||
|
MIGRATION_8_9
|
||||||
)
|
)
|
||||||
.addTypeConverter(downloadConverter)
|
.addTypeConverter(downloadConverter)
|
||||||
.build()
|
.build()
|
||||||
@@ -59,4 +62,7 @@ object RoomModule {
|
|||||||
@Provides
|
@Provides
|
||||||
fun providesIgnoredUpdateDao(auroraDatabase: AuroraDatabase): IgnoredUpdateDao =
|
fun providesIgnoredUpdateDao(auroraDatabase: AuroraDatabase): IgnoredUpdateDao =
|
||||||
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
|
// Fetch required data for download
|
||||||
try {
|
try {
|
||||||
download = downloadDao.getDownload(inputData.getString(DownloadHelper.PACKAGE_NAME)!!)
|
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 response = (httpClient as HttpClient).call(download.iconURL).body
|
||||||
val bitmap = BitmapFactory.decodeStream(
|
val bitmap = BitmapFactory.decodeStream(
|
||||||
withContext(Dispatchers.IO) { response.byteStream() }
|
withContext(Dispatchers.IO) { response.byteStream() }
|
||||||
)
|
)
|
||||||
icon = bitmap.scale(96, 96)
|
icon = bitmap?.scale(96, 96)
|
||||||
} catch (exception: Exception) {
|
} 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
|
// Set work/service to foreground on < Android 12.0
|
||||||
|
|||||||
@@ -24,15 +24,24 @@ import android.content.SharedPreferences
|
|||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
|
import com.aurora.extensions.isOneUI
|
||||||
import com.aurora.store.BuildConfig
|
import com.aurora.store.BuildConfig
|
||||||
|
|
||||||
object Preferences {
|
object Preferences {
|
||||||
|
|
||||||
const val PREFERENCE_DEFAULT = "PREFERENCE_DEFAULT"
|
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_AUTH_DATA = "PREFERENCE_AUTH_DATA"
|
||||||
const val PREFERENCE_INSTALLER_ID = "PREFERENCE_INSTALLER_ID"
|
const val PREFERENCE_INSTALLER_ID = "PREFERENCE_INSTALLER_ID"
|
||||||
const val PREFERENCE_THEME_STYLE = "PREFERENCE_THEME_STYLE"
|
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_FOR_YOU = "PREFERENCE_FOR_YOU"
|
||||||
const val PREFERENCE_DEFAULT_SELECTED_TAB = "PREFERENCE_DEFAULT_SELECTED_TAB"
|
const val PREFERENCE_DEFAULT_SELECTED_TAB = "PREFERENCE_DEFAULT_SELECTED_TAB"
|
||||||
const val PREFERENCE_INTRO = "PREFERENCE_INTRO"
|
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_ENABLED = "PREFERENCE_APP_LOCK_ENABLED"
|
||||||
const val PREFERENCE_APP_LOCK_TIMEOUT = "PREFERENCE_APP_LOCK_TIMEOUT"
|
const val PREFERENCE_APP_LOCK_TIMEOUT = "PREFERENCE_APP_LOCK_TIMEOUT"
|
||||||
|
|
||||||
|
const val PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK = "PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK"
|
||||||
|
|
||||||
private var prefs: SharedPreferences? = null
|
private var prefs: SharedPreferences? = null
|
||||||
|
|
||||||
fun getPrefs(context: Context): SharedPreferences = when (BuildConfig.FLAVOR) {
|
fun getPrefs(context: Context): SharedPreferences = when (BuildConfig.FLAVOR) {
|
||||||
|
|||||||
@@ -14,20 +14,35 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
import androidx.paging.cachedIn
|
import androidx.paging.cachedIn
|
||||||
import com.aurora.extensions.TAG
|
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.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.Favourite
|
||||||
import com.aurora.store.data.room.favourite.FavouriteDao
|
import com.aurora.store.data.room.favourite.FavouriteDao
|
||||||
import com.aurora.store.data.room.favourite.ImportExport
|
import com.aurora.store.data.room.favourite.ImportExport
|
||||||
|
import com.aurora.store.util.PackageUtil
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
@@ -35,12 +50,40 @@ import kotlinx.serialization.json.Json
|
|||||||
class FavouriteViewModel @Inject constructor(
|
class FavouriteViewModel @Inject constructor(
|
||||||
private val favouriteDao: FavouriteDao,
|
private val favouriteDao: FavouriteDao,
|
||||||
private val json: Json,
|
private val json: Json,
|
||||||
|
private val appDetailsHelper: AppDetailsHelper,
|
||||||
|
private val downloadHelper: DownloadHelper,
|
||||||
|
private val authProvider: AuthProvider,
|
||||||
@ApplicationContext private val context: Context
|
@ApplicationContext private val context: Context
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _favourites = MutableStateFlow<PagingData<Favourite>>(PagingData.empty())
|
private val _favourites = MutableStateFlow<PagingData<Favourite>>(PagingData.empty())
|
||||||
val favourites = _favourites.asStateFlow()
|
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 {
|
init {
|
||||||
getPagedFavourites()
|
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() {
|
private fun getPagedFavourites() {
|
||||||
pager { favouriteDao.pagedFavourites() }.flow
|
pager { favouriteDao.pagedFavourites() }.flow
|
||||||
.distinctUntilChanged()
|
.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.download.Download
|
||||||
import com.aurora.store.data.room.favourite.Favourite
|
import com.aurora.store.data.room.favourite.Favourite
|
||||||
import com.aurora.store.data.room.favourite.FavouriteDao
|
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.CertUtil
|
||||||
import com.aurora.store.util.PackageUtil
|
import com.aurora.store.util.PackageUtil
|
||||||
import com.aurora.store.util.Preferences
|
import com.aurora.store.util.Preferences
|
||||||
@@ -48,14 +51,19 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -71,6 +79,7 @@ class AppDetailsViewModel @Inject constructor(
|
|||||||
private val webDataSafetyHelper: WebDataSafetyHelper,
|
private val webDataSafetyHelper: WebDataSafetyHelper,
|
||||||
private val downloadHelper: DownloadHelper,
|
private val downloadHelper: DownloadHelper,
|
||||||
private val favouriteDao: FavouriteDao,
|
private val favouriteDao: FavouriteDao,
|
||||||
|
private val reviewDao: ReviewDao,
|
||||||
private val httpClient: IHttpClient,
|
private val httpClient: IHttpClient,
|
||||||
private val json: Json
|
private val json: Json
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -89,8 +98,19 @@ class AppDetailsViewModel @Inject constructor(
|
|||||||
private val _featuredReviews = MutableStateFlow<List<Review>>(emptyList())
|
private val _featuredReviews = MutableStateFlow<List<Review>>(emptyList())
|
||||||
val featuredReviews = _featuredReviews.asStateFlow()
|
val featuredReviews = _featuredReviews.asStateFlow()
|
||||||
|
|
||||||
private val _userReview = MutableStateFlow<Review?>(null)
|
// The user's own review for the loaded app, backed by Room so it is shown instantly (even
|
||||||
val userReview = _userReview.asStateFlow()
|
// 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)
|
private val _dataSafetyReport = MutableStateFlow<DataSafetyReport?>(null)
|
||||||
val dataSafetyReport = _dataSafetyReport.asStateFlow()
|
val dataSafetyReport = _dataSafetyReport.asStateFlow()
|
||||||
@@ -117,6 +137,10 @@ class AppDetailsViewModel @Inject constructor(
|
|||||||
list.find { d -> d.packageName == a.packageName }
|
list.find { d -> d.packageName == a.packageName }
|
||||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
}.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
|
private val isInstalled: Boolean
|
||||||
get() = PackageUtil.isInstalled(context, app.value!!.packageName)
|
get() = PackageUtil.isInstalled(context, app.value!!.packageName)
|
||||||
|
|
||||||
@@ -200,6 +224,11 @@ class AppDetailsViewModel @Inject constructor(
|
|||||||
|
|
||||||
fetchFavourite(packageName)
|
fetchFavourite(packageName)
|
||||||
fetchFeaturedReviews(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)
|
fetchDataSafetyReport(packageName)
|
||||||
fetchSuggestions()
|
fetchSuggestions()
|
||||||
fetchExodusPrivacyReport(packageName)
|
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) {
|
fun fetchUserAppReview(app: App) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val email = accountEmail
|
||||||
|
if (email.isBlank()) return@launch
|
||||||
try {
|
try {
|
||||||
val isTesting = app.testingProgram?.isSubscribed ?: false
|
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) {
|
} catch (exception: Exception) {
|
||||||
Log.e(TAG, "Failed to fetch user review", 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) {
|
fun postAppReview(packageName: String, review: Review, isBeta: Boolean) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val email = accountEmail
|
||||||
try {
|
try {
|
||||||
_userReview.value = reviewsHelper.addOrEditReview(
|
val posted = reviewsHelper.addOrEditReview(
|
||||||
packageName,
|
packageName,
|
||||||
review.title,
|
review.title,
|
||||||
review.comment,
|
review.comment,
|
||||||
review.rating,
|
review.rating,
|
||||||
isBeta
|
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) {
|
} catch (exception: Exception) {
|
||||||
Log.e(TAG, "Failed to post review", 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">
|
<style name="AppTheme" parent="Theme.Material3.DynamicColors.Dark.NoActionBar">
|
||||||
<item name="chipStyle">@style/Chip.Filter</item>
|
<item name="chipStyle">@style/Chip.Filter</item>
|
||||||
<item name="android:statusBarColor">@android:color/transparent</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>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
<string name="action_back">"Back"</string>
|
<string name="action_back">"Back"</string>
|
||||||
<string name="action_blacklist">"Blacklist"</string>
|
<string name="action_blacklist">"Blacklist"</string>
|
||||||
<string name="action_blacklist_add">"Add to 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_cancel">"Cancel"</string>
|
||||||
<string name="action_clear">"Clear"</string>
|
<string name="action_clear">"Clear"</string>
|
||||||
<string name="action_close">"Close"</string>
|
<string name="action_close">"Close"</string>
|
||||||
@@ -76,6 +77,7 @@
|
|||||||
<string name="action_granted">"Granted"</string>
|
<string name="action_granted">"Granted"</string>
|
||||||
<string name="action_ignore">"Ignore"</string>
|
<string name="action_ignore">"Ignore"</string>
|
||||||
<string name="action_install">"Install"</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_install_microG">"Install microG Bundle"</string>
|
||||||
<string name="action_installations">"Installations"</string>
|
<string name="action_installations">"Installations"</string>
|
||||||
<string name="action_installing">"Installing"</string>
|
<string name="action_installing">"Installing"</string>
|
||||||
@@ -139,6 +141,12 @@
|
|||||||
<string name="details_privacy">"Privacy"</string>
|
<string name="details_privacy">"Privacy"</string>
|
||||||
<string name="details_ratings">"Rating and reviews"</string>
|
<string name="details_ratings">"Rating and reviews"</string>
|
||||||
<string name="details_ratings_title_hint">"Review title"</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="details_think_this_app">"What do you think about this app?"</string>
|
||||||
<string name="download_cancel_all">"Cancel all"</string>
|
<string name="download_cancel_all">"Cancel all"</string>
|
||||||
<string name="download_canceled">"Cancelled"</string>
|
<string name="download_canceled">"Cancelled"</string>
|
||||||
@@ -189,6 +197,11 @@
|
|||||||
<string name="app_lock_unlock">Unlock</string>
|
<string name="app_lock_unlock">Unlock</string>
|
||||||
<string name="app_lock_prompt_title">Unlock Aurora Store</string>
|
<string name="app_lock_prompt_title">Unlock Aurora Store</string>
|
||||||
<string name="app_lock_prompt_subtitle">Enter phone screen lock pattern, PIN, password or fingerprint</string>
|
<string name="app_lock_prompt_subtitle">Enter phone screen lock pattern, PIN, password or fingerprint</string>
|
||||||
|
<string name="confirm_deeplink_title">Confirm external links</string>
|
||||||
|
<string name="confirm_deeplink_summary">Ask before opening an app listing from a link, preventing ads from launching Aurora Store without your consent.</string>
|
||||||
|
<string name="confirm_deeplink_sheet_title">Continue to Aurora Store?</string>
|
||||||
|
<string name="confirm_deeplink_sheet_message">An external app wants to open Aurora Store</string>
|
||||||
|
<string name="confirm_deeplink_sheet_source">Opened from %1$s</string>
|
||||||
<string name="installer_service_available">"Aurora Services is available and ready to install."</string>
|
<string name="installer_service_available">"Aurora Services is available and ready to install."</string>
|
||||||
<string name="installer_service_unavailable">Install Aurora Services 1.0.9 or above, or change the installer.</string>
|
<string name="installer_service_unavailable">Install Aurora Services 1.0.9 or above, or change the installer.</string>
|
||||||
<string name="installer_service_misconfigured">Set up Aurora Services and grant all permissions first.</string>
|
<string name="installer_service_misconfigured">Set up Aurora Services and grant all permissions first.</string>
|
||||||
@@ -295,6 +308,9 @@
|
|||||||
<string name="title_installation">"Installation"</string>
|
<string name="title_installation">"Installation"</string>
|
||||||
<string name="title_installed">"Installed"</string>
|
<string name="title_installed">"Installed"</string>
|
||||||
<string name="title_installer">"App installer"</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_language">"Language"</string>
|
||||||
<string name="title_library">"Library"</string>
|
<string name="title_library">"Library"</string>
|
||||||
<string name="title_no_network">"No network"</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_system">Follow system</string>
|
||||||
<string name="pref_theme_style_light">Light</string>
|
<string name="pref_theme_style_light">Light</string>
|
||||||
<string name="pref_theme_style_dark">Dark</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">Layout</string>
|
||||||
<string name="pref_ui_layout_tab">Select default tab</string>
|
<string name="pref_ui_layout_tab">Select default tab</string>
|
||||||
<string name="app_links_title">App links</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_export_failed">Failed to export favourites!</string>
|
||||||
<string name="toast_fav_import_success">Favourites imported!</string>
|
<string name="toast_fav_import_success">Favourites imported!</string>
|
||||||
<string name="toast_fav_export_success">Favourites exported!</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-->
|
<!-- BlacklistFragment-->
|
||||||
<string name="toast_black_import_failed">Failed to import blacklist!</string>
|
<string name="toast_black_import_failed">Failed to import blacklist!</string>
|
||||||
@@ -585,6 +606,10 @@
|
|||||||
<item quantity="one">Permission required</item>
|
<item quantity="one">Permission required</item>
|
||||||
<item quantity="other">Permissions required</item>
|
<item quantity="other">Permissions required</item>
|
||||||
</plurals>
|
</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">
|
<plurals name="notification_installed_summary">
|
||||||
<item quantity="one">%d app installed</item>
|
<item quantity="one">%d app installed</item>
|
||||||
<item quantity="other">%d apps installed</item>
|
<item quantity="other">%d apps installed</item>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
<item name="chipStyle">@style/Chip.Filter</item>
|
<item name="chipStyle">@style/Chip.Filter</item>
|
||||||
<item name="bottomSheetStyle">@style/AppTheme.BottomSheetStyle</item>
|
<item name="bottomSheetStyle">@style/AppTheme.BottomSheetStyle</item>
|
||||||
<item name="android:statusBarColor">@android:color/transparent</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>
|
||||||
|
|
||||||
<style name="AppTheme.Translucent" parent="Theme.Material3.DynamicColors.DayNight.NoActionBar">
|
<style name="AppTheme.Translucent" parent="Theme.Material3.DynamicColors.DayNight.NoActionBar">
|
||||||
@@ -35,6 +37,14 @@
|
|||||||
<item name="android:windowTranslucentNavigation">true</item>
|
<item name="android:windowTranslucentNavigation">true</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- Translucent variant with edge-to-edge transparent system bars (no status bar scrim) -->
|
||||||
|
<style name="AppTheme.Translucent.Transparent">
|
||||||
|
<item name="android:windowTranslucentStatus">false</item>
|
||||||
|
<item name="android:windowTranslucentNavigation">false</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
<style name="AppTheme.BottomSheetStyle" parent="Widget.Material3.BottomSheet" />
|
<style name="AppTheme.BottomSheetStyle" parent="Widget.Material3.BottomSheet" />
|
||||||
|
|
||||||
<style name="Chip.Filter" parent="@style/Widget.Material3.Chip.Filter" />
|
<style name="Chip.Filter" parent="@style/Widget.Material3.Chip.Filter" />
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ composeBom = "2026.05.01"
|
|||||||
composeMaterial = "1.5.0-alpha20"
|
composeMaterial = "1.5.0-alpha20"
|
||||||
core = "1.18.0"
|
core = "1.18.0"
|
||||||
espresso = "3.7.0"
|
espresso = "3.7.0"
|
||||||
gplayapi = "3.6.2"
|
gplayapi = "3.6.3"
|
||||||
hiddenapibypass = "6.1"
|
hiddenapibypass = "6.1"
|
||||||
hilt = "2.59.2"
|
hilt = "2.59.2"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user