diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 116c7a1b..0adca8d4 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -18,7 +18,7 @@ jobs: uses: actions/setup-java@v3 with: distribution: 'zulu' - java-version: 17 + java-version: 21 - name: Required files run: echo $GOOGLE_SERVICES_JSON > app/google-services.json diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5dca45df..7a9e3848 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,12 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { + alias(libs.plugins.android.application) alias(libs.plugins.kotlin) + alias(libs.plugins.kotlinx.serialization) alias(libs.plugins.ksp) + alias(libs.plugins.baselineprofile) alias(libs.plugins.compose.compiler) alias(libs.plugins.room) alias(libs.plugins.ktlint.gradle) @@ -9,15 +14,21 @@ plugins { id("com.google.android.gms.oss-licenses-plugin") } +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_11 + } +} + android { compileSdk = 36 defaultConfig { applicationId = "com.anod.appwatcher" - minSdk = 27 + minSdk = 31 targetSdk = 36 - versionCode = 16900 - versionName = "1.6.9" + versionCode = 17000 + versionName = "1.7.0" } buildFeatures { @@ -44,13 +55,13 @@ android { isMinifyEnabled = false applicationIdSuffix = ".debug" versionNameSuffix = "-debug" - proguardFile(getDefaultProguardFile("proguard-android.txt")) + proguardFile(getDefaultProguardFile("proguard-android-optimize.txt")) proguardFile("proguard-project.txt") } getByName("release") { isMinifyEnabled = true isShrinkResources = true - proguardFile(getDefaultProguardFile("proguard-android.txt")) + proguardFile(getDefaultProguardFile("proguard-android-optimize.txt")) proguardFile("proguard-project.pro") signingConfig = signingConfigs.getByName("release") } @@ -68,10 +79,6 @@ android { targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { - jvmTarget = "11" - } - packaging { resources { excludes += "META-INF/DEPENDENCIES" @@ -96,20 +103,22 @@ dependencies { ktlintRuleset(libs.ktlint.compose) // AndroidX implementation(libs.androidx.appcompat) // AppCompatActivity + implementation(libs.androidx.activity) implementation(libs.androidx.palette) implementation(libs.work.runtime) implementation(libs.work.runtime.ktx) implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.splashscreen) - implementation(libs.androidx.compose.material.icons.core) - implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.navigation3.adaptive) implementation(libs.paging.common) implementation(libs.paging.compose.android) + implementation(libs.kotlinx.serialization.json) + // Compose - implementation(libs.androidx.activity.compose) implementation(libs.runtime.tracing) implementation(libs.ktor.client.core) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 99e7761f..6c16233c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,7 +22,7 @@ - + @@ -35,7 +35,6 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/java/com/anod/appwatcher/AppWatcherActivity.kt b/app/src/main/java/com/anod/appwatcher/AppWatcherActivity.kt index b5b0ec9f..c99bf065 100644 --- a/app/src/main/java/com/anod/appwatcher/AppWatcherActivity.kt +++ b/app/src/main/java/com/anod/appwatcher/AppWatcherActivity.kt @@ -2,25 +2,319 @@ package com.anod.appwatcher import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Bundle -import com.anod.appwatcher.watchlist.MainActivity -import com.anod.appwatcher.watchlist.WatchListStateViewModel +import androidx.activity.compose.setContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy +import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.core.net.toUri +import androidx.core.os.bundleOf +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entry +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import com.anod.appwatcher.compose.AppTheme +import com.anod.appwatcher.compose.BaseComposeActivity +import com.anod.appwatcher.database.entities.Tag +import com.anod.appwatcher.history.HistoryListScreenScene +import com.anod.appwatcher.installed.InstalledListScreenScene +import com.anod.appwatcher.navigation.SceneNavKey +import com.anod.appwatcher.preferences.SettingsScreenScene +import com.anod.appwatcher.search.SearchResultsScreenScene +import com.anod.appwatcher.search.toViewState +import com.anod.appwatcher.sync.SchedulesHistoryScreenScene +import com.anod.appwatcher.tags.TagWatchListScreenScene +import com.anod.appwatcher.userLog.UserLogScreenScene +import com.anod.appwatcher.utils.prefs +import com.anod.appwatcher.watchlist.DetailContent +import com.anod.appwatcher.watchlist.EmptyBoxSmile +import com.anod.appwatcher.watchlist.MainScreenScene +import com.anod.appwatcher.wishlist.WishListScreenScene +import info.anodsplace.framework.app.addMultiWindowFlags +import org.koin.core.component.KoinComponent -class AppWatcherActivity : MainActivity() { +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +class AppWatcherActivity : BaseComposeActivity(), KoinComponent { override fun onCreate(savedInstanceState: Bundle?) { setTheme(R.style.AppTheme_Main) super.onCreate(savedInstanceState) + + val elements = createInitialBackstack() + setContent { + + val backStack = rememberNavBackStack(*elements) + val listDetailStrategy = rememberListDetailSceneStrategy() + AppTheme( + theme = prefs.theme, + transparentSystemUi = true + ) { + NavDisplay( + backStack = backStack, + onBack = { keysToRemove -> repeat(keysToRemove) { backStack.removeLastOrNull() } }, + sceneStrategy = listDetailStrategy, + entryProvider = provideNavEntries(backStack), + transitionSpec = { + // Slide in from right when navigating forward + slideInHorizontally( + initialOffsetX = { it }, + animationSpec = tween(350) + ) togetherWith slideOutHorizontally( + targetOffsetX = { -it }, + animationSpec = tween(350) + ) + }, + popTransitionSpec = { + // Slide in from left when navigating back + slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = tween(350) + ) togetherWith slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(350) + ) + }, + predictivePopTransitionSpec = { + // Slide in from left when navigating back + slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = tween(350) + ) togetherWith slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(350) + ) + } + ) + } + } + } + + private fun createInitialBackstack(): Array { + val extras = intent?.extras ?: bundleOf() + if (extras.containsKey(EXTRA_SEARCH_KEYWORD)) { + intent!!.extras!!.remove(EXTRA_SEARCH_KEYWORD) + return arrayOf(SceneNavKey.Search( + keyword = intent?.getStringExtra(EXTRA_SEARCH_KEYWORD) ?: "", + focus = intent?.getBooleanExtra(EXTRA_SEARCH_FOCUS, false) ?: false, + initiateSearch = intent?.getBooleanExtra(EXTRA_SEARCH_EXACT, false) ?: false, + isPackageSearch = intent?.getBooleanExtra(EXTRA_SEARCH_PACKAGE, false) ?: false, + isShareSource = intent?.getBooleanExtra(EXTRA_SEARCH_SHARE, false) ?: false, + )) + } + if (extras.containsKey(EXTRA_LIST_TAG_ID)) { + val extraTagId = extras.getInt(EXTRA_LIST_TAG_ID) + val extraTagColor = extras.getInt(EXTRA_LIST_TAG_COLOR, Tag.DEFAULT_COLOR) + // intent!!.extras!!.remove(EXTRA_DETAILS_TAG_ID) + return arrayOf(SceneNavKey.TagWatchList( + tag = Tag( + id = extraTagId, + name = "", + color = extraTagColor + ) + )) + } + var elements = arrayOf(SceneNavKey.Main) + if (extras.containsKey(EXTRA_OPEN_RECENTLY_INSTALLED)) { + intent!!.extras!!.remove(EXTRA_OPEN_RECENTLY_INSTALLED) + elements += SceneNavKey.Installed(importMode = false) + } else if (extras.containsKey(EXTRA_LIST_TAG_ID)) { + val extraTagId = extras.getInt(EXTRA_LIST_TAG_ID) + intent!!.extras!!.remove(EXTRA_LIST_TAG_ID) + elements += SceneNavKey.TagWatchList( + tag = Tag( + id = extraTagId, + name = "", + color = extras.getInt(EXTRA_LIST_TAG_COLOR) + ) + ) + } else if (extras.containsKey(EXTRA_GDRIVE_SIGNIN)) { + intent!!.extras!!.remove(EXTRA_GDRIVE_SIGNIN) + elements += SceneNavKey.Settings + } + return elements + } + + private fun provideNavEntries(backStack: NavBackStack): (NavKey) -> NavEntry = entryProvider { + entry( + metadata = ListDetailSceneStrategy.listPane( + sceneKey = SceneNavKey.Main, + detailPlaceholder = { + EmptyBoxSmile() + } + ) + ) { + val wideLayout by foldableDevice.layout.collectAsState() + MainScreenScene( + prefs = prefs, + wideLayout = wideLayout, + navigateBack = { backStack.removeLastOrNull() }, + navigateTo = { backStack.add(it) } + ) + } + entry( + metadata = ListDetailSceneStrategy.detailPane(sceneKey = SceneNavKey.AppDetails) + ) { key -> + DetailContent( + app = key.selectedApp, + onDismissRequest = { backStack.removeLastOrNull() }, + ) + } + entry( + metadata = ListDetailSceneStrategy.listPane( + sceneKey = SceneNavKey.Search, + detailPlaceholder = { + EmptyBoxSmile() + } + ) + ) { key -> + val wideLayout by foldableDevice.layout.collectAsState() + SearchResultsScreenScene( + initialState = key.toViewState(wideLayout), + navigateBack = { backStack.removeLastOrNull() }, + ) + } + entry( + metadata = ListDetailSceneStrategy.extraPane(sceneKey = SceneNavKey.Settings) + ) { + SettingsScreenScene( + navigateBack = { backStack.removeLastOrNull() }, + navigateTo = { backStack.add(it) } + ) + } + entry( + metadata = ListDetailSceneStrategy.extraPane(sceneKey = SceneNavKey.PurchaseHistory) + ) { + val wideLayout by foldableDevice.layout.collectAsState() + HistoryListScreenScene( + wideLayout = wideLayout, + navigateBack = { backStack.removeLastOrNull() } + ) + } + entry( + metadata = ListDetailSceneStrategy.extraPane(sceneKey = SceneNavKey.UserLog) + ) { + UserLogScreenScene( + navigateBack = { backStack.removeLastOrNull() } + ) + } + entry( + metadata = ListDetailSceneStrategy.extraPane(sceneKey = SceneNavKey.RefreshHistory) + ) { + SchedulesHistoryScreenScene( + navigateBack = { backStack.removeLastOrNull() } + ) + } + entry( + metadata = ListDetailSceneStrategy.listPane( + sceneKey = SceneNavKey.TagWatchList, + detailPlaceholder = { + EmptyBoxSmile() + } + ) + ) { key -> + val wideLayout by foldableDevice.layout.collectAsState() + TagWatchListScreenScene( + tag = key.tag, + wideLayout = wideLayout, + navigateBack = { backStack.removeLastOrNull() }, + navigateTo = { backStack.add(it) } + ) + } + entry( + metadata = ListDetailSceneStrategy.listPane( + sceneKey = SceneNavKey.Installed, + detailPlaceholder = { + EmptyBoxSmile() + } + ) + ) { key -> + InstalledListScreenScene( + showAction = key.importMode, + navigateBack = { backStack.removeLastOrNull() }, + ) + } + entry( + metadata = ListDetailSceneStrategy.listPane( + sceneKey = SceneNavKey.WishList, + detailPlaceholder = { + EmptyBoxSmile() + } + ) + ) { + val wideLayout by foldableDevice.layout.collectAsState() + WishListScreenScene( + wideLayout = wideLayout, + navigateBack = { backStack.removeLastOrNull() }, + ) + } } companion object { - fun createTagShortcutIntent(tagId: Int, initialColor: Int, context: Context) = Intent(context, AppWatcherActivity::class.java).apply { + const val EXTRA_SEARCH_KEYWORD = "search.keyword" + const val EXTRA_SEARCH_EXACT = "search.exact" + const val EXTRA_SEARCH_SHARE = "search.share" + const val EXTRA_SEARCH_FOCUS = "search.focus" + const val EXTRA_SEARCH_PACKAGE = "search.package" + + const val EXTRA_LIST_TAG = "extra_tag" + const val EXTRA_LIST_TAG_ID = "tag_id" + const val EXTRA_LIST_TAG_COLOR = "tag_color" + + const val EXTRA_FROM_NOTIFICATION = "list.extra_noti" + const val EXTRA_EXPAND_SEARCH = "list.expand_search" + + const val EXTRA_OPEN_RECENTLY_INSTALLED = "open_recently_installed" + const val EXTRA_GDRIVE_SIGNIN = "gdrive.signin" + + const val ARG_FILTER = "filter" + const val ARG_SORT = "sort" + const val ARG_TAG = "tag" + const val ARG_SHOW_ACTION = "showAction" + + fun tagShortcutIntent(tagId: Int, initialColor: Int, context: Context) = Intent(context, AppWatcherActivity::class.java).apply { action = Intent.ACTION_VIEW flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - data = Uri.parse("com.anod.appwatcher://tags/$tagId?color=$initialColor") - putExtra(WatchListStateViewModel.EXTRA_TAG_ID, tagId) - putExtra(WatchListStateViewModel.EXTRA_TAG_COLOR, initialColor) + data = "com.anod.appwatcher://tags/$tagId?color=$initialColor".toUri() + putExtra(EXTRA_LIST_TAG_ID, tagId) + putExtra(EXTRA_LIST_TAG_COLOR, initialColor) } + + fun searchIntent( + context: Context, + keyword: String, + focus: Boolean, + initiateSearch: Boolean = false + ): Intent = Intent(context, AppWatcherActivity::class.java).apply { + putExtra(EXTRA_SEARCH_KEYWORD, keyword) + putExtra(EXTRA_SEARCH_FOCUS, focus) + putExtra(EXTRA_SEARCH_EXACT, initiateSearch) + } + + fun tagIntent(tag: Tag, context: Context) = Intent(context, AppWatcherActivity::class.java).apply { + putExtra(EXTRA_LIST_TAG, tag) + addMultiWindowFlags(context) + } + + fun gDriveSignInIntent(context: Context) = Intent(context, AppWatcherActivity::class.java).apply { + putExtra(EXTRA_GDRIVE_SIGNIN, true) + addMultiWindowFlags(context) + } + + private fun installedIntent(sortId: Int, showImportAction: Boolean, context: Context): Intent { + return Intent(context, AppWatcherActivity::class.java).apply { + putExtra(ARG_SORT, sortId) + putExtra(ARG_SHOW_ACTION, showImportAction) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/AppWatcherApplication.kt b/app/src/main/java/com/anod/appwatcher/AppWatcherApplication.kt index 4fdaa8f1..d099bc6a 100644 --- a/app/src/main/java/com/anod/appwatcher/AppWatcherApplication.kt +++ b/app/src/main/java/com/anod/appwatcher/AppWatcherApplication.kt @@ -45,7 +45,7 @@ class AppWatcherApplication : Application(), AppLog.Listener, ApplicationInstanc override fun onCreate() { super.onCreate() - if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + if (BuildConfig.DEBUG) { StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder() .detectActivityLeaks() .detectAll() diff --git a/app/src/main/java/com/anod/appwatcher/MarketSearchActivity.kt b/app/src/main/java/com/anod/appwatcher/MarketSearchActivity.kt deleted file mode 100644 index efca4aeb..00000000 --- a/app/src/main/java/com/anod/appwatcher/MarketSearchActivity.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.anod.appwatcher - -import android.content.Context -import android.content.Intent -import com.anod.appwatcher.search.SearchComposeActivity - -class MarketSearchActivity : SearchComposeActivity() { - - companion object { - fun intent( - context: Context, - keyword: String, - focus: Boolean, - initiateSearch: Boolean = false - ): Intent = Intent(context, MarketSearchActivity::class.java).apply { - putExtra(EXTRA_KEYWORD, keyword) - putExtra(EXTRA_FOCUS, focus) - putExtra(EXTRA_EXACT, initiateSearch) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/SettingsActivity.kt b/app/src/main/java/com/anod/appwatcher/SettingsActivity.kt deleted file mode 100644 index c3f9aa53..00000000 --- a/app/src/main/java/com/anod/appwatcher/SettingsActivity.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.anod.appwatcher - -class SettingsActivity : com.anod.appwatcher.preferences.SettingsActivity() \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/ShareRecieverActivity.kt b/app/src/main/java/com/anod/appwatcher/ShareRecieverActivity.kt index 96ac3657..f2f76a7c 100644 --- a/app/src/main/java/com/anod/appwatcher/ShareRecieverActivity.kt +++ b/app/src/main/java/com/anod/appwatcher/ShareRecieverActivity.kt @@ -4,7 +4,6 @@ import android.app.Activity import android.content.Intent import android.net.UrlQuerySanitizer import android.os.Bundle -import com.anod.appwatcher.search.SearchComposeActivity class ShareRecieverActivity : Activity() { @@ -13,15 +12,15 @@ class ShareRecieverActivity : Activity() { val intent = intent val text = intent.getStringExtra(Intent.EXTRA_TEXT) - val searchIntent = Intent(this, MarketSearchActivity::class.java) + val searchIntent = Intent(this, AppWatcherActivity::class.java) var fallback = true if (text != null && text.startsWith(URL_PLAYSTORE)) { val sanitizer = UrlQuerySanitizer(text) val id = sanitizer.getValue("id") if (id != null) { - searchIntent.putExtra(SearchComposeActivity.EXTRA_PACKAGE, true) - searchIntent.putExtra(SearchComposeActivity.EXTRA_KEYWORD, id) - searchIntent.putExtra(SearchComposeActivity.EXTRA_EXACT, true) + searchIntent.putExtra(AppWatcherActivity.EXTRA_SEARCH_PACKAGE, true) + searchIntent.putExtra(AppWatcherActivity.EXTRA_SEARCH_KEYWORD, id) + searchIntent.putExtra(AppWatcherActivity.EXTRA_SEARCH_EXACT, true) fallback = false } } @@ -29,15 +28,15 @@ class ShareRecieverActivity : Activity() { if (fallback) { val title = intent.getStringExtra(Intent.EXTRA_TITLE) if (title != null && title != "") { - searchIntent.putExtra(SearchComposeActivity.EXTRA_KEYWORD, title) + searchIntent.putExtra(AppWatcherActivity.EXTRA_SEARCH_KEYWORD, title) } else if (text != null && text != "") { - searchIntent.putExtra(SearchComposeActivity.EXTRA_KEYWORD, text) + searchIntent.putExtra(AppWatcherActivity.EXTRA_SEARCH_KEYWORD, text) } else { - searchIntent.putExtra(SearchComposeActivity.EXTRA_KEYWORD, "") + searchIntent.putExtra(AppWatcherActivity.EXTRA_SEARCH_KEYWORD, "") } - searchIntent.putExtra(SearchComposeActivity.EXTRA_EXACT, false) + searchIntent.putExtra(AppWatcherActivity.EXTRA_SEARCH_EXACT, false) } - searchIntent.putExtra(SearchComposeActivity.EXTRA_SHARE, true) + searchIntent.putExtra(AppWatcherActivity.EXTRA_SEARCH_SHARE, true) startActivity(searchIntent) finish() } diff --git a/app/src/main/java/com/anod/appwatcher/accounts/AccountSelectionDialog.kt b/app/src/main/java/com/anod/appwatcher/accounts/AccountSelectionDialog.kt deleted file mode 100644 index 678465c9..00000000 --- a/app/src/main/java/com/anod/appwatcher/accounts/AccountSelectionDialog.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.anod.appwatcher.accounts - -import android.accounts.Account -import android.accounts.AccountManager -import android.app.Activity -import android.content.Intent -import androidx.activity.ComponentActivity -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope -import com.anod.appwatcher.preferences.Preferences -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.launch - -sealed interface AccountSelectionResult { - class Success(val account: Account) : AccountSelectionResult - data object Canceled : AccountSelectionResult - class Error(val errorMessage: String) : AccountSelectionResult -} - -/** - * @author alex - * * - * @date 9/17/13 - */ -class AccountSelectionDialog( - private val activity: ComponentActivity, - private val preferences: Preferences -) { - - private val chooseAccount = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - onActivityResult(result.resultCode, result.data) - } - - val accountSelected = MutableSharedFlow() - - fun show() { - val intent = AccountManager.newChooseAccountIntent( - preferences.account?.toAndroidAccount(), - null, - arrayOf(AuthTokenBlocking.ACCOUNT_TYPE), - null, - null, - null, - null) - chooseAccount.launch(intent) - } - - private fun onActivityResult(resultCode: Int, data: Intent?) { - if (resultCode == Activity.RESULT_CANCELED) { - activity.lifecycleScope.launch { - accountSelected.emit(AccountSelectionResult.Canceled) - } - return - } - - if (resultCode == Activity.RESULT_OK && data != null) { - val name = data.extras?.getString(AccountManager.KEY_ACCOUNT_NAME, "") ?: "" - val type = data.extras?.getString(AccountManager.KEY_ACCOUNT_TYPE, "") ?: "" - if (name.isNotBlank() && type.isNotBlank()) { - val account = Account(name, type) - activity.lifecycleScope.launch { - accountSelected.emit(AccountSelectionResult.Success(account)) - } - return - } - } - val errorMessage = data?.extras?.getString(AccountManager.KEY_ERROR_MESSAGE, null) - activity.lifecycleScope.launch { - accountSelected.emit(AccountSelectionResult.Error(errorMessage ?: "Cannot retrieve account")) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/accounts/AccountSelectionRequest.kt b/app/src/main/java/com/anod/appwatcher/accounts/AccountSelectionRequest.kt new file mode 100644 index 00000000..b8cff7f5 --- /dev/null +++ b/app/src/main/java/com/anod/appwatcher/accounts/AccountSelectionRequest.kt @@ -0,0 +1,47 @@ +package com.anod.appwatcher.accounts + +import android.accounts.Account +import android.accounts.AccountManager +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.activity.result.contract.ActivityResultContract + +sealed interface AccountSelectionResult { + class Success(val account: Account) : AccountSelectionResult + data object Canceled : AccountSelectionResult + class Error(val errorMessage: String) : AccountSelectionResult +} + +class AccountSelectionRequest : ActivityResultContract() { + + override fun createIntent(context: Context, input: Account?): Intent { + val intent = AccountManager.newChooseAccountIntent( + input, + null, + arrayOf(AuthTokenBlocking.ACCOUNT_TYPE), + null, + null, + null, + null + ) + return intent + } + + override fun parseResult(resultCode: Int, intent: Intent?): AccountSelectionResult { + if (resultCode == Activity.RESULT_CANCELED) { + return AccountSelectionResult.Canceled + } + + if (resultCode == Activity.RESULT_OK && intent != null) { + val name = intent.extras?.getString(AccountManager.KEY_ACCOUNT_NAME, "") ?: "" + val type = intent.extras?.getString(AccountManager.KEY_ACCOUNT_TYPE, "") ?: "" + if (name.isNotBlank() && type.isNotBlank()) { + val account = Account(name, type) + return AccountSelectionResult.Success(account) + } + } + val errorMessage = intent?.extras?.getString(AccountManager.KEY_ERROR_MESSAGE, null) + return AccountSelectionResult.Error(errorMessage ?: "Cannot retrieve account") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/accounts/AuthTokenBlocking.kt b/app/src/main/java/com/anod/appwatcher/accounts/AuthTokenBlocking.kt index 84462fc6..69c958ef 100644 --- a/app/src/main/java/com/anod/appwatcher/accounts/AuthTokenBlocking.kt +++ b/app/src/main/java/com/anod/appwatcher/accounts/AuthTokenBlocking.kt @@ -7,11 +7,11 @@ import android.accounts.OperationCanceledException import android.content.Intent import info.anodsplace.applog.AppLog import info.anodsplace.context.ApplicationContext -import java.io.IOException -import java.util.concurrent.TimeUnit import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext +import java.io.IOException +import java.util.concurrent.TimeUnit class AuthTokenStartIntent(val intent: Intent) : RuntimeException("getAuthToken finished with intent: $intent") @@ -19,6 +19,7 @@ sealed interface CheckTokenError { class Unknown(val e: Exception) : CheckTokenError class RequiresInteraction(val intent: Intent) : CheckTokenError object NoToken : CheckTokenError + object NoAccount : CheckTokenError } sealed interface CheckTokenResult { @@ -44,7 +45,8 @@ class AuthTokenBlocking(context: ApplicationContext) { private val accountManager: AccountManager = AccountManager.get(context.actual) private var lastUpdated = 0L - suspend fun checkToken(account: Account): CheckTokenResult { + suspend fun checkToken(account: Account?): CheckTokenResult { + val account = account ?: return CheckTokenResult.Error(CheckTokenError.NoAccount) if (isFresh) { return CheckTokenResult.Success(invalidated = false) } diff --git a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSignIn.kt b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSignIn.kt index 457267e7..11117635 100644 --- a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSignIn.kt +++ b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSignIn.kt @@ -1,21 +1,19 @@ package com.anod.appwatcher.backup.gdrive +import android.accounts.Account import android.app.Activity import android.app.PendingIntent import android.content.Context import android.content.Intent import androidx.core.app.NotificationCompat +import com.anod.appwatcher.AppWatcherActivity import com.anod.appwatcher.R -import com.anod.appwatcher.SettingsActivity import com.anod.appwatcher.sync.SyncNotification import com.google.android.gms.auth.api.signin.GoogleSignIn -import com.google.android.gms.auth.api.signin.GoogleSignInAccount -import com.google.android.gms.auth.api.signin.GoogleSignInClient import com.google.android.gms.auth.api.signin.GoogleSignInOptions import com.google.android.gms.auth.api.signin.GoogleSignInStatusCodes import com.google.android.gms.common.api.ApiException import com.google.android.gms.common.api.Scope -import com.google.android.gms.tasks.Task import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential import com.google.api.client.http.HttpRequestInitializer import com.google.api.services.drive.DriveScopes @@ -23,11 +21,12 @@ import info.anodsplace.applog.AppLog import info.anodsplace.context.ApplicationContext import info.anodsplace.notification.NotificationManager import info.anodsplace.playservices.GoogleSignInConnect +import org.koin.java.KoinJavaComponent import java.util.Collections import java.util.concurrent.ExecutionException import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine -import org.koin.java.KoinJavaComponent internal fun createGDriveSignInOptions(): GoogleSignInOptions { return GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) @@ -36,19 +35,15 @@ internal fun createGDriveSignInOptions(): GoogleSignInOptions { .build() } -internal fun createCredentials(context: Context, googleAccount: GoogleSignInAccount): HttpRequestInitializer { +internal fun createCredentials(context: Context, googleAccount: Account?): HttpRequestInitializer { return GoogleAccountCredential .usingOAuth2(context, Collections.singleton(DriveScopes.DRIVE_APPDATA)) - .setSelectedAccount(googleAccount.account) + .setSelectedAccount(googleAccount) } -interface ResultListener { - fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) -} +class GDriveSignIn(private val context: ApplicationContext) { -class GDriveSignIn(private val activity: Activity, private val listener: Listener) : ResultListener { - - private val driveConnect by lazy { GoogleSignInConnect(activity, createGDriveSignInOptions()) } + private val driveConnect by lazy { GoogleSignInConnect(context, createGDriveSignInOptions()) } companion object { const val RESULT_CODE_GDRIVE_SIGN_IN = 123 @@ -65,27 +60,29 @@ class GDriveSignIn(private val activity: Activity, private val listener: Listene val notificationManager = KoinJavaComponent.getKoin().get() notificationManager.notify(SyncNotification.GMS_NOTIFICATION_ID, notification) } - } - interface Listener { - fun onGDriveLoginSuccess(googleSignInAccount: GoogleSignInAccount) - fun onGDriveLoginError(errorCode: Int) + fun getLastSignedInAccount(context: Context): Account? { + return GoogleSignIn.getLastSignedInAccount(context)?.account + } } - fun signIn() { + class GoogleSignInRequestException(val intent: Intent, val resultCode: Int) : Throwable() + class GoogleSignInFailedException(val resultCode: Int) : Throwable() + + suspend fun signIn() = suspendCoroutine { continuation -> driveConnect.connect(object : GoogleSignInConnect.Result { - override fun onSuccess(account: GoogleSignInAccount, client: GoogleSignInClient) { - listener.onGDriveLoginSuccess(account) + override fun onSuccess(account: Account) { + continuation.resume(account) } - override fun onError(errorCode: Int, client: GoogleSignInClient) { - AppLog.e("Silent sign in failed with code $errorCode (${GoogleSignInStatusCodes.getStatusCodeString(errorCode)}). starting signIn intent") - activity.startActivityForResult(client.signInIntent, RESULT_CODE_GDRIVE_SIGN_IN) + override fun onError(errorCode: Int, errorMessage: String, signInIntent: Intent) { + AppLog.e("Silent sign in failed with code $errorCode ($errorMessage). starting signIn intent") + continuation.resumeWithException(GoogleSignInRequestException(signInIntent, RESULT_CODE_GDRIVE_SIGN_IN)) } }) } - suspend fun signOut() = suspendCoroutine { continuation -> + suspend fun signOut() = suspendCoroutine { continuation -> driveConnect.disconnect(object : GoogleSignInConnect.SignOutResult { override fun onResult() { continuation.resume(Unit) @@ -93,45 +90,37 @@ class GDriveSignIn(private val activity: Activity, private val listener: Listene }) } - fun requestEmail(lastSignedAccount: GoogleSignInAccount) { - GoogleSignIn.requestPermissions(activity, RESULT_CODE_GDRIVE_SIGN_IN, lastSignedAccount, Scope("email")) - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + suspend fun onActivityResult(resultCode: Int, data: Intent?) = suspendCoroutine { continuation -> // Result returned from launching the Intent from GoogleSignInClient.getSignInIntent(...); - if (requestCode == RESULT_CODE_GDRIVE_SIGN_IN) { + if (resultCode == Activity.RESULT_OK && data?.extras != null) { // The Task returned from this call is always completed, no need to attach // a listener. - val task = GoogleSignIn.getSignedInAccountFromIntent(data) - handleSignInResult(task) - } else if (requestCode == RESULT_CODE_GDRIVE_EXCEPTION) { + val completedTask = GoogleSignIn.getSignedInAccountFromIntent(data) + try { + val account = completedTask.getResult(ApiException::class.java)!! + // Signed in successfully, show authenticated UI. + continuation.resume(account) + } catch (e: ApiException) { + // The ApiException status code indicates the detailed failure reason. + // Please refer to the GoogleSignInStatusCodes class reference for more information. + AppLog.e(e) + continuation.resumeWithException(GoogleSignInFailedException(e.statusCode)) + } + } else { + continuation.resumeWithException(GoogleSignInFailedException(resultCode)) // Nothing? } } - - private fun handleSignInResult(completedTask: Task) { - try { - val account = completedTask.getResult(ApiException::class.java)!! - - // Signed in successfully, show authenticated UI. - listener.onGDriveLoginSuccess(account) - } catch (e: ApiException) { - // The ApiException status code indicates the detailed failure reason. - // Please refer to the GoogleSignInStatusCodes class reference for more information. - AppLog.e(e) - listener.onGDriveLoginError(e.statusCode) - } - } } class GDriveSilentSignIn(private val context: ApplicationContext) { private val driveConnect by lazy { GoogleSignInConnect(context, createGDriveSignInOptions()) } - fun signInLocked(): GoogleSignInAccount { + fun signInLocked(): Account { val lastSignedInAccount = GoogleSignIn.getLastSignedInAccount(context.actual) - if (lastSignedInAccount != null) { - return lastSignedInAccount + if (lastSignedInAccount?.account != null) { + return lastSignedInAccount.account!! } try { @@ -140,7 +129,7 @@ class GDriveSilentSignIn(private val context: ApplicationContext) { val errorCode = e.statusCode AppLog.e("Silent sign in failed with code $errorCode (${GoogleSignInStatusCodes.getStatusCodeString(errorCode)}). starting signIn intent") if (errorCode == GoogleSignInStatusCodes.SIGN_IN_REQUIRED) { - val settingActivity = Intent(context.actual, SettingsActivity::class.java) + val settingActivity = AppWatcherActivity.gDriveSignInIntent(context.actual) settingActivity.flags = Intent.FLAG_ACTIVITY_NEW_TASK GDriveSignIn.showResolutionNotification( PendingIntent.getActivity(context.actual, 0, settingActivity, PendingIntent.FLAG_IMMUTABLE), context) diff --git a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt index 77c817ce..97647ee0 100644 --- a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt +++ b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt @@ -1,5 +1,6 @@ package com.anod.appwatcher.backup.gdrive +import android.accounts.Account import android.text.format.Formatter import com.anod.appwatcher.backup.DbJsonReader import com.anod.appwatcher.backup.DbJsonWriter @@ -10,21 +11,19 @@ import com.anod.appwatcher.database.TagsTable import com.anod.appwatcher.database.entities.App import com.anod.appwatcher.database.entities.Tag import com.google.android.gms.auth.UserRecoverableAuthException -import com.google.android.gms.auth.api.signin.GoogleSignInAccount import info.anodsplace.applog.AppLog -import info.anodsplace.context.ApplicationContext -import java.io.BufferedReader import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import java.io.BufferedReader /** * @author alex * * * @date 2014-11-15 */ -class GDriveSync(private val googleAccount: GoogleSignInAccount, private val context: info.anodsplace.context.ApplicationContext, private val database: AppsDatabase) { +class GDriveSync(private val googleAccount: Account, private val context: info.anodsplace.context.ApplicationContext, private val database: AppsDatabase) { class SyncError(val error: UserRecoverableAuthException?, cause: Exception) : Exception(cause.message, cause) diff --git a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveUpload.kt b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveUpload.kt index 4ef9c79d..0dcddde7 100644 --- a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveUpload.kt +++ b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveUpload.kt @@ -1,10 +1,10 @@ package com.anod.appwatcher.backup.gdrive +import android.accounts.Account import android.text.format.Formatter import com.anod.appwatcher.backup.DbJsonWriter import com.anod.appwatcher.database.AppTagsTable import com.anod.appwatcher.database.AppsDatabase -import com.google.android.gms.auth.api.signin.GoogleSignInAccount import info.anodsplace.applog.AppLog import info.anodsplace.context.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -16,7 +16,7 @@ import kotlinx.coroutines.withContext * @author Alex Gavrishev * @date 26/06/2017 */ -class GDriveUpload(private val googleAccount: GoogleSignInAccount, private val context: info.anodsplace.context.ApplicationContext, private val database: AppsDatabase) { +class GDriveUpload(private val googleAccount: Account, private val context: ApplicationContext, private val database: AppsDatabase) { @Throws(Exception::class) suspend fun doUploadInBackground() { diff --git a/app/src/main/java/com/anod/appwatcher/backup/gdrive/UploadService.kt b/app/src/main/java/com/anod/appwatcher/backup/gdrive/UploadService.kt index 091f5568..d760046b 100644 --- a/app/src/main/java/com/anod/appwatcher/backup/gdrive/UploadService.kt +++ b/app/src/main/java/com/anod/appwatcher/backup/gdrive/UploadService.kt @@ -11,14 +11,13 @@ import androidx.work.NetworkType import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import androidx.work.WorkerParameters -import com.anod.appwatcher.SettingsActivity +import com.anod.appwatcher.AppWatcherActivity import com.anod.appwatcher.utils.prefs -import com.google.android.gms.auth.api.signin.GoogleSignIn import info.anodsplace.applog.AppLog -import java.util.concurrent.TimeUnit import org.koin.core.component.KoinComponent import org.koin.core.component.get import org.koin.core.parameter.parametersOf +import java.util.concurrent.TimeUnit /** * @author Alex Gavrishev @@ -55,7 +54,7 @@ class UploadService(appContext: Context, params: WorkerParameters) : CoroutineWo AppLog.d("Scheduled call executed. Id: $id") AppLog.d("DriveSync perform upload") - val googleAccount = GoogleSignIn.getLastSignedInAccount(applicationContext) + val googleAccount = GDriveSignIn.getLastSignedInAccount(applicationContext) if (googleAccount == null) { AppLog.e("Account is null") return Result.failure() @@ -67,7 +66,7 @@ class UploadService(appContext: Context, params: WorkerParameters) : CoroutineWo } catch (e: Exception) { AppLog.e("UploadService::doWork - ${e.message}", e) DriveService.extractUserRecoverableException(e)?.let { - val settingActivity = Intent(applicationContext, SettingsActivity::class.java).apply { + val settingActivity = AppWatcherActivity.gDriveSignInIntent(applicationContext).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } GDriveSignIn.showResolutionNotification( diff --git a/app/src/main/java/com/anod/appwatcher/compose/BaseComposeActivity.kt b/app/src/main/java/com/anod/appwatcher/compose/BaseComposeActivity.kt index 2452424d..9dc54fa2 100644 --- a/app/src/main/java/com/anod/appwatcher/compose/BaseComposeActivity.kt +++ b/app/src/main/java/com/anod/appwatcher/compose/BaseComposeActivity.kt @@ -2,6 +2,7 @@ package com.anod.appwatcher.compose import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge import info.anodsplace.framework.app.FoldableDevice abstract class BaseComposeActivity : ComponentActivity() { @@ -9,6 +10,14 @@ abstract class BaseComposeActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { foldableDevice = FoldableDevice.create(this) + setEdgeToEdgeConfig() super.onCreate(savedInstanceState) } +} + +fun ComponentActivity.setEdgeToEdgeConfig() { + enableEdgeToEdge() + // Force the 3-button navigation bar to be transparent + // See: https://developer.android.com/develop/ui/views/layout/edge-to-edge#create-transparent + window.isNavigationBarContrastEnforced = false } \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/compose/MainDetailsScreen.kt b/app/src/main/java/com/anod/appwatcher/compose/MainDetailsScreen.kt deleted file mode 100644 index 68147dd1..00000000 --- a/app/src/main/java/com/anod/appwatcher/compose/MainDetailsScreen.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.anod.appwatcher.compose - -import android.graphics.Rect -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.width -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.tooling.preview.Devices -import androidx.compose.ui.tooling.preview.Preview -import com.anod.appwatcher.watchlist.DetailContent -import info.anodsplace.framework.app.FoldableDeviceLayout - -@Composable -fun MainDetailScreen(wideLayout: FoldableDeviceLayout, main: @Composable () -> Unit, detail: @Composable () -> Unit) { - Row( - modifier = Modifier.fillMaxSize() - ) { - Box(modifier = Modifier - .weight(1f) - .fillMaxHeight()) { - main() - } - val hingeWidth = wideLayout.hinge.width() - if (hingeWidth > 0) { - val widthInDp = with(LocalDensity.current) { hingeWidth.toDp() } - Spacer(modifier = Modifier - .width(widthInDp) - .fillMaxHeight()) - } - Box(modifier = Modifier - .weight(1f) - .fillMaxHeight()) { - detail() - } - } -} - -@Preview(device = Devices.FOLDABLE) -@Composable -private fun MainDetailScreenPreview() { - AppTheme { - MainDetailScreen( - wideLayout = FoldableDeviceLayout(isWideLayout = true, hinge = Rect(0, 0, 80, 0)), - main = { DetailContent(app = null, onDismissRequest = {}, onCommonActivityAction = {}) }, - detail = { DetailContent(app = null, onDismissRequest = {}, onCommonActivityAction = {}) }, - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/compose/SearchTopBar.kt b/app/src/main/java/com/anod/appwatcher/compose/SearchTopBar.kt index e52753da..061ba71f 100644 --- a/app/src/main/java/com/anod/appwatcher/compose/SearchTopBar.kt +++ b/app/src/main/java/com/anod/appwatcher/compose/SearchTopBar.kt @@ -91,7 +91,6 @@ fun SearchTopBar( } else { onNavigation() } - onNavigation() } SearchTopBarEvent.SearchAction -> { showSearchView = true diff --git a/app/src/main/java/com/anod/appwatcher/compose/Theme.kt b/app/src/main/java/com/anod/appwatcher/compose/Theme.kt index 2c521e21..f482fb5b 100644 --- a/app/src/main/java/com/anod/appwatcher/compose/Theme.kt +++ b/app/src/main/java/com/anod/appwatcher/compose/Theme.kt @@ -1,18 +1,14 @@ package com.anod.appwatcher.compose -import android.os.Build import android.view.Window -import androidx.annotation.RequiresApi import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Shapes import androidx.compose.material3.Typography -import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb @@ -32,116 +28,21 @@ import info.anodsplace.framework.app.findWindow private val AppTypography = Typography() val Amber800 = Color(0xFFFF8F00) -private val LightThemeColors = lightColorScheme( - primary = Color(0xFF2196F3), - onPrimary = Color(0xFFffffff), - primaryContainer = Color(0xFFbde9ff), - onPrimaryContainer = Color(0xFF001f2a), - secondary = Color(0xFF005db7), - onSecondary = Color(0xFFffffff), - secondaryContainer = Color(0xFFd6e3ff), - onSecondaryContainer = Color(0xFF001b3d), - tertiary = Color(0xFF2b5bb5), - onTertiary = Color(0xFFffffff), - tertiaryContainer = Color(0xFFd9e2ff), - onTertiaryContainer = Color(0xFF001945), - error = Color(0xFFba1a1a), - onError = Color(0xFFffffff), - errorContainer = Color(0xFFffdad6), - onErrorContainer = Color(0xFF410002), - - background = Color(0xFFfafcff), - onBackground = Color(0xFF001f2a), - - surface = Color(0xFFfafcff), - onSurface = Color(0xFF001f2a), - - surfaceVariant = Color(0xFFdce4e9), - onSurfaceVariant = Color(0xFF40484c), - outline = Color(0xFF70787d) -) - -private val DarkSurface = Color(0xFF263238) -private val DarkThemeColors = darkColorScheme( - primary = Color(0xFF67d3ff), - onPrimary = Color(0xFF003546), - primaryContainer = Color(0xFF004d64), - onPrimaryContainer = Color(0xFFbde9ff), - secondary = Color(0xFF8ccdff), - onSecondary = Color(0xFF00344e), - secondaryContainer = Color(0xFF004b6f), - onSecondaryContainer = Color(0xFFcae6ff), - tertiary = Color(0xFF8ecdff), - onTertiary = Color(0xFFffffff), - tertiaryContainer = Color(0xFFcae6ff), - onTertiaryContainer = Color(0xFF001e30), - error = Color(0xFFffb4ab), - onError = Color(0xFF690005), - errorContainer = Color(0xFF93000a), - onErrorContainer = Color(0xFFffdad6), - - background = DarkSurface, - onBackground = Color(0xFFbde9ff), - - surface = DarkSurface, - onSurface = Color(0xFFffffff), - - surfaceVariant = Color(0xFF40484c), - onSurfaceVariant = Color(0xFFc0c8cd), - outline = Color(0xFF8a9297) -) - -private val BlackThemeColors = darkColorScheme( - primary = Color(0xFF67d3ff), - onPrimary = Color(0xFF003546), - primaryContainer = Color(0xFF004d64), - onPrimaryContainer = Color(0xFFbde9ff), - secondary = Color(0xFF8ccdff), - onSecondary = Color(0xFF00344e), - secondaryContainer = Color(0xFF004b6f), - onSecondaryContainer = Color(0xFFcae6ff), - tertiary = Color(0xFF8ecdff), - onTertiary = Color(0xFFffffff), - tertiaryContainer = Color(0xFFcae6ff), - onTertiaryContainer = Color(0xFF001e30), - error = Color(0xFFffb4ab), - onError = Color(0xFF690005), - errorContainer = Color(0xFF93000a), - onErrorContainer = Color(0xFFffdad6), - - background = Color.Black, - onBackground = Color(0xFFbde9ff), - - surface = Color.Black, - onSurface = Color(0xFFffffff), - - surfaceVariant = Color(0xFF40484c), - onSurfaceVariant = Color(0xFFc0c8cd), - outline = Color(0xFF8a9297) -) - val AppShapes = Shapes( small = RoundedCornerShape(4.dp), medium = RoundedCornerShape(4.dp), large = RoundedCornerShape(8.dp) ) -fun supportsDynamic(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S - -@RequiresApi(Build.VERSION_CODES.S) @Composable -fun darkTheme(theme: Int, supportsDynamic: Boolean): ColorScheme { +fun darkTheme(theme: Int): ColorScheme { return if (theme == Preferences.THEME_BLACK) { - if (supportsDynamic) { - dynamicDarkColorScheme(LocalContext.current).copy( - background = Color.Black, - surface = Color.Black, - ) - } else { - BlackThemeColors - } + dynamicDarkColorScheme(LocalContext.current).copy( + background = Color.Black, + surface = Color.Black, + ) } else { - if (supportsDynamic) dynamicDarkColorScheme(LocalContext.current) else DarkThemeColors + dynamicDarkColorScheme(LocalContext.current) } } @@ -155,11 +56,7 @@ fun AppTheme( transparentSystemUi: Boolean = false, content: @Composable () -> Unit ) { - var colorScheme = if (supportsDynamic()) { - if (darkTheme) darkTheme(theme, supportsDynamic = true) else dynamicLightColorScheme(LocalContext.current) - } else { - if (darkTheme) darkTheme(theme, supportsDynamic = false) else LightThemeColors - } + var colorScheme = if (darkTheme) darkTheme(theme) else dynamicLightColorScheme(LocalContext.current) var statusBarColor = colorScheme.surface var isAppearanceLightStatusBars = !darkTheme diff --git a/app/src/main/java/com/anod/appwatcher/compose/ViewModelStoreOwner.kt b/app/src/main/java/com/anod/appwatcher/compose/ViewModelStoreOwner.kt index df87d526..ad0c3560 100644 --- a/app/src/main/java/com/anod/appwatcher/compose/ViewModelStoreOwner.kt +++ b/app/src/main/java/com/anod/appwatcher/compose/ViewModelStoreOwner.kt @@ -18,7 +18,7 @@ class ComposableViewModelStoreOwner : ViewModelStoreOwner { } @Composable -fun rememberViwModeStoreOwner(): ViewModelStoreOwner { +fun rememberViewModeStoreOwner(): ViewModelStoreOwner { val owner = remember { ComposableViewModelStoreOwner() } DisposableEffect(key1 = owner) { onDispose { diff --git a/app/src/main/java/com/anod/appwatcher/database/AppsDatabase.kt b/app/src/main/java/com/anod/appwatcher/database/AppsDatabase.kt index 5334e0f8..eb015456 100644 --- a/app/src/main/java/com/anod/appwatcher/database/AppsDatabase.kt +++ b/app/src/main/java/com/anod/appwatcher/database/AppsDatabase.kt @@ -61,14 +61,14 @@ abstract class AppsDatabase : RoomDatabase() { val dbName = if (BuildConfig.DEBUG) "app_watcher.db" else "app_watcher" private val MIGRATION_17_18 = object : Migration(17, 18) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("ALTER TABLE ${SchedulesTable.TABLE} ADD COLUMN ${SchedulesTable.Columns.NOTIFIED} INTEGER NOT NULL DEFAULT 0") + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE ${SchedulesTable.TABLE} ADD COLUMN ${SchedulesTable.Columns.NOTIFIED} INTEGER NOT NULL DEFAULT 0") } } private val MIGRATION_16_17 = object : Migration(16, 17) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL( + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( "CREATE TABLE IF NOT EXISTS `${SchedulesTable.TABLE}` (" + "`_id` INTEGER NOT NULL, " + "`start` INTEGER NOT NULL, " + @@ -83,24 +83,24 @@ abstract class AppsDatabase : RoomDatabase() { } private val MIGRATION_15_16 = object : Migration(15, 16) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("ALTER TABLE " + ChangelogTable.TABLE + " ADD COLUMN " + ChangelogTable.Columns.NO_NEW_DETAILS + " INTEGER NOT NULL DEFAULT 0") + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE " + ChangelogTable.TABLE + " ADD COLUMN " + ChangelogTable.Columns.NO_NEW_DETAILS + " INTEGER NOT NULL DEFAULT 0") } } private val MIGRATION_14_15 = object : Migration(14, 15) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("DELETE FROM app_tags WHERE _ID NOT IN ( " + + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DELETE FROM app_tags WHERE _ID NOT IN ( " + "SELECT MAX(_ID) " + "FROM app_tags " + "GROUP BY app_id, tags_id)") - database.execSQL("CREATE UNIQUE INDEX `index_app_tags_app_id_tags_id` ON `app_tags` (`app_id`, `tags_id`)") + db.execSQL("CREATE UNIQUE INDEX `index_app_tags_app_id_tags_id` ON `app_tags` (`app_id`, `tags_id`)") } } private val MIGRATION_9_11 = object : Migration(9, 11) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("CREATE TABLE IF NOT EXISTS `changelog` (" + + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE IF NOT EXISTS `changelog` (" + "`_id` INTEGER NOT NULL, " + "`app_id` TEXT NOT NULL, " + "`code` INTEGER NOT NULL, " + @@ -111,9 +111,9 @@ abstract class AppsDatabase : RoomDatabase() { } private val MIGRATION_11_12 = object : Migration(11, 12) { - override fun migrate(database: SupportSQLiteDatabase) { + override fun migrate(db: SupportSQLiteDatabase) { try { - database.execSQL("ALTER TABLE " + ChangelogTable.TABLE + " ADD COLUMN " + ChangelogTable.Columns.UPLOAD_DATE + " TEXT") + db.execSQL("ALTER TABLE " + ChangelogTable.TABLE + " ADD COLUMN " + ChangelogTable.Columns.UPLOAD_DATE + " TEXT") } catch (e: Exception) { AppLog.e(e) } @@ -121,9 +121,9 @@ abstract class AppsDatabase : RoomDatabase() { } private val MIGRATION_12_13 = object : Migration(12, 13) { - override fun migrate(database: SupportSQLiteDatabase) { + override fun migrate(db: SupportSQLiteDatabase) { try { - database.execSQL("ALTER TABLE " + ChangelogTable.TABLE + " ADD COLUMN " + ChangelogTable.Columns.UPLOAD_DATE + " TEXT") + db.execSQL("ALTER TABLE " + ChangelogTable.TABLE + " ADD COLUMN " + ChangelogTable.Columns.UPLOAD_DATE + " TEXT") } catch (e: Exception) { AppLog.e(e) } @@ -131,21 +131,21 @@ abstract class AppsDatabase : RoomDatabase() { } private val MIGRATION_13_14 = object : Migration(13, 14) { - override fun migrate(database: SupportSQLiteDatabase) { + override fun migrate(db: SupportSQLiteDatabase) { AppLog.e("Migrate db from 13 to 14") - database.execSQL("UPDATE app_list SET update_date = 0 WHERE update_date IS NULL") - database.execSQL("UPDATE app_list SET ver_name = '' WHERE ver_name IS NULL") - database.execSQL("UPDATE app_list SET creator = '' WHERE creator IS NULL") - database.execSQL("UPDATE app_list SET title = app_id WHERE title IS NULL") - database.execSQL("UPDATE app_list SET iconUrl = '' WHERE iconUrl IS NULL") - database.execSQL("UPDATE app_list SET upload_date = '' WHERE upload_date IS NULL") - database.execSQL("UPDATE app_list SET app_type = '' WHERE app_type IS NULL") - database.execSQL("UPDATE app_list SET price_text = '' WHERE price_text IS NULL") - database.execSQL("UPDATE app_list SET price_currency = '' WHERE price_currency IS NULL") - database.execSQL("UPDATE app_list SET price_micros = 0 WHERE price_micros IS NULL") - database.execSQL("UPDATE app_list SET sync_version = 0 WHERE sync_version IS NULL") - database.execSQL("CREATE TABLE IF NOT EXISTS `app_list_temp` " + + db.execSQL("UPDATE app_list SET update_date = 0 WHERE update_date IS NULL") + db.execSQL("UPDATE app_list SET ver_name = '' WHERE ver_name IS NULL") + db.execSQL("UPDATE app_list SET creator = '' WHERE creator IS NULL") + db.execSQL("UPDATE app_list SET title = app_id WHERE title IS NULL") + db.execSQL("UPDATE app_list SET iconUrl = '' WHERE iconUrl IS NULL") + db.execSQL("UPDATE app_list SET upload_date = '' WHERE upload_date IS NULL") + db.execSQL("UPDATE app_list SET app_type = '' WHERE app_type IS NULL") + db.execSQL("UPDATE app_list SET price_text = '' WHERE price_text IS NULL") + db.execSQL("UPDATE app_list SET price_currency = '' WHERE price_currency IS NULL") + db.execSQL("UPDATE app_list SET price_micros = 0 WHERE price_micros IS NULL") + db.execSQL("UPDATE app_list SET sync_version = 0 WHERE sync_version IS NULL") + db.execSQL("CREATE TABLE IF NOT EXISTS `app_list_temp` " + "(`_id` INTEGER NOT NULL, " + "`app_id` TEXT NOT NULL, " + "`package` TEXT NOT NULL, " + @@ -163,7 +163,7 @@ abstract class AppsDatabase : RoomDatabase() { "`price_text` TEXT NOT NULL, " + "`price_currency` TEXT NOT NULL, " + "`price_micros` INTEGER, PRIMARY KEY(`_id`))") - database.execSQL("INSERT INTO app_list_temp (" + + db.execSQL("INSERT INTO app_list_temp (" + "_id, app_id, package, ver_num, ver_name, title, creator," + "iconUrl, status, upload_date, details_url, update_date, app_type," + "sync_version, price_text, price_currency, price_micros) " + @@ -172,11 +172,11 @@ abstract class AppsDatabase : RoomDatabase() { "sync_version, price_text, price_currency, price_micros " + "FROM app_list" ) - database.execSQL("DROP TABLE app_list") - database.execSQL("ALTER TABLE app_list_temp RENAME TO app_list") + db.execSQL("DROP TABLE app_list") + db.execSQL("ALTER TABLE app_list_temp RENAME TO app_list") - database.execSQL("UPDATE changelog SET upload_date = '' WHERE upload_date IS NULL") - database.execSQL( + db.execSQL("UPDATE changelog SET upload_date = '' WHERE upload_date IS NULL") + db.execSQL( "CREATE TABLE IF NOT EXISTS `changelog_temp` (" + "`_id` INTEGER NOT NULL, " + "`app_id` TEXT NOT NULL, " + @@ -185,38 +185,38 @@ abstract class AppsDatabase : RoomDatabase() { "`details` TEXT NOT NULL, " + "`upload_date` TEXT NOT NULL, PRIMARY KEY(`_id`))" ) - database.execSQL( + db.execSQL( "INSERT INTO changelog_temp (_id, app_id, code, name, details, upload_date)" + " SELECT _id, app_id, code, name, details, upload_date FROM changelog" ) - database.execSQL("DROP TABLE changelog") - database.execSQL("ALTER TABLE changelog_temp RENAME TO changelog") - database.execSQL("CREATE UNIQUE INDEX `index_changelog_app_id_code` ON `changelog` (`app_id`, `code`)") + db.execSQL("DROP TABLE changelog") + db.execSQL("ALTER TABLE changelog_temp RENAME TO changelog") + db.execSQL("CREATE UNIQUE INDEX `index_changelog_app_id_code` ON `changelog` (`app_id`, `code`)") - database.execSQL( + db.execSQL( "CREATE TABLE IF NOT EXISTS `app_tags_temp` (" + "`_id` INTEGER NOT NULL, " + "`app_id` TEXT NOT NULL, " + "`tags_id` INTEGER NOT NULL, PRIMARY KEY(`_id`))" ) - database.execSQL("INSERT INTO app_tags_temp (_id, app_id, tags_id) SELECT _id, app_id, tags_id FROM app_tags") - database.execSQL("DROP TABLE app_tags") - database.execSQL("ALTER TABLE app_tags_temp RENAME TO app_tags") + db.execSQL("INSERT INTO app_tags_temp (_id, app_id, tags_id) SELECT _id, app_id, tags_id FROM app_tags") + db.execSQL("DROP TABLE app_tags") + db.execSQL("ALTER TABLE app_tags_temp RENAME TO app_tags") - database.execSQL( + db.execSQL( "CREATE TABLE IF NOT EXISTS `tags_temp` (" + "`_id` INTEGER NOT NULL, " + "`name` TEXT NOT NULL, " + "`color` INTEGER NOT NULL, PRIMARY KEY(`_id`))" ) - database.execSQL("INSERT INTO tags_temp (_id, name, color) SELECT _id, name, color FROM tags") - database.execSQL("DROP TABLE tags") - database.execSQL("ALTER TABLE tags_temp RENAME TO tags") + db.execSQL("INSERT INTO tags_temp (_id, name, color) SELECT _id, name, color FROM tags") + db.execSQL("DROP TABLE tags") + db.execSQL("ALTER TABLE tags_temp RENAME TO tags") } } private val MIGRATION_18_19 = object : Migration(18, 19) { - override fun migrate(database: SupportSQLiteDatabase) { + override fun migrate(db: SupportSQLiteDatabase) { AppLog.d("Migrated db from 18 to 19") } } diff --git a/app/src/main/java/com/anod/appwatcher/database/entities/App.kt b/app/src/main/java/com/anod/appwatcher/database/entities/App.kt index 68013592..a4dc1116 100644 --- a/app/src/main/java/com/anod/appwatcher/database/entities/App.kt +++ b/app/src/main/java/com/anod/appwatcher/database/entities/App.kt @@ -18,6 +18,7 @@ import info.anodsplace.framework.content.InstalledPackageApp import info.anodsplace.framework.content.getAppTitle import info.anodsplace.framework.content.getLaunchComponent import info.anodsplace.framework.content.getPackageInfoOrNull +import kotlinx.serialization.Serializable import java.text.DateFormat import java.util.Date @@ -34,6 +35,7 @@ fun PackageManager.packageToApp(rowId: Int, packageName: String): App { } @Entity(tableName = AppListTable.TABLE) +@Serializable data class App( @PrimaryKey @ColumnInfo(name = BaseColumns._ID) val rowId: Int, diff --git a/app/src/main/java/com/anod/appwatcher/database/entities/Price.kt b/app/src/main/java/com/anod/appwatcher/database/entities/Price.kt index 2d8999d4..1470600e 100644 --- a/app/src/main/java/com/anod/appwatcher/database/entities/Price.kt +++ b/app/src/main/java/com/anod/appwatcher/database/entities/Price.kt @@ -4,12 +4,14 @@ import android.os.Parcelable import androidx.room.ColumnInfo import com.anod.appwatcher.database.AppListTable import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable /** * @author Alex Gavrishev * @date 25/05/2018 */ @Parcelize +@Serializable data class Price( @ColumnInfo(name = AppListTable.Columns.PRICE_TEXT) val text: String, diff --git a/app/src/main/java/com/anod/appwatcher/database/entities/Tag.kt b/app/src/main/java/com/anod/appwatcher/database/entities/Tag.kt index 6138c12f..fb04f323 100644 --- a/app/src/main/java/com/anod/appwatcher/database/entities/Tag.kt +++ b/app/src/main/java/com/anod/appwatcher/database/entities/Tag.kt @@ -10,6 +10,7 @@ import androidx.room.PrimaryKey import com.anod.appwatcher.database.TagsTable import info.anodsplace.ktx.hashCodeOf import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable /** * @author Alex Gavrishev @@ -17,6 +18,7 @@ import kotlinx.parcelize.Parcelize */ @Entity(tableName = TagsTable.TABLE) @Parcelize +@Serializable data class Tag( @PrimaryKey @ColumnInfo(name = BaseColumns._ID) @@ -24,7 +26,7 @@ data class Tag( @ColumnInfo(name = TagsTable.Columns.NAME) val name: String, @ColumnInfo(name = TagsTable.Columns.COLOR) - @ColorInt + @param:ColorInt val color: Int ) : Parcelable { diff --git a/app/src/main/java/com/anod/appwatcher/details/DetailsPanel.kt b/app/src/main/java/com/anod/appwatcher/details/DetailsPanel.kt index 25a67d40..4b92b660 100644 --- a/app/src/main/java/com/anod/appwatcher/details/DetailsPanel.kt +++ b/app/src/main/java/com/anod/appwatcher/details/DetailsPanel.kt @@ -103,7 +103,7 @@ import com.anod.appwatcher.compose.TagIcon import com.anod.appwatcher.compose.TranslateIcon import com.anod.appwatcher.compose.UninstallIcon import com.anod.appwatcher.compose.WatchedIcon -import com.anod.appwatcher.compose.rememberViwModeStoreOwner +import com.anod.appwatcher.compose.rememberViewModeStoreOwner import com.anod.appwatcher.database.entities.App import com.anod.appwatcher.database.entities.AppChange import com.anod.appwatcher.database.entities.Price @@ -113,12 +113,13 @@ import com.anod.appwatcher.utils.StoreIntent import info.anodsplace.applog.AppLog import info.anodsplace.compose.placeholder import info.anodsplace.compose.toAnnotatedString -import info.anodsplace.framework.content.CommonActivityAction +import info.anodsplace.framework.content.showToast +import info.anodsplace.framework.content.startActivity import info.anodsplace.framework.text.Html -import java.text.DateFormat -import java.util.Date import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf +import java.text.DateFormat +import java.util.Date private val iconSizeBig = 64.dp private val iconSizeSmall = 32.dp @@ -126,8 +127,8 @@ private val iconSizeSmall = 32.dp private val dateFormat: DateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) @Composable -fun DetailsPanel(app: App, onDismissRequest: () -> Unit, onCommonActivityAction: (action: CommonActivityAction) -> Unit) { - val storeOwner = rememberViwModeStoreOwner() +fun DetailsPanel(app: App, onDismissRequest: () -> Unit) { + val storeOwner = rememberViewModeStoreOwner() val isSystemInDarkTheme = isSystemInDarkTheme() val viewModel: DetailsViewModel = viewModel( key = "details-${app.appId}-${app.rowId}", @@ -150,14 +151,13 @@ fun DetailsPanel(app: App, onDismissRequest: () -> Unit, onCommonActivityAction: modifier = Modifier.fillMaxSize(), viewActions = viewModel.viewActions, onDismissRequest = onDismissRequest, - onCommonActivityAction = onCommonActivityAction ) } } @Composable -fun DetailsDialog(app: App, onDismissRequest: () -> Unit, onCommonActivityAction: (action: CommonActivityAction) -> Unit) { - val storeOwner = rememberViwModeStoreOwner() +fun DetailsDialog(app: App, onDismissRequest: () -> Unit) { + val storeOwner = rememberViewModeStoreOwner() val isSystemInDarkTheme = isSystemInDarkTheme() val viewModel: DetailsViewModel = viewModel( key = "details-${app.appId}-${app.rowId}", @@ -179,7 +179,6 @@ fun DetailsDialog(app: App, onDismissRequest: () -> Unit, onCommonActivityAction screenState = screenState, viewActions = viewModel.viewActions, onEvent = viewModel::handleEvent, - onCommonActivityAction = { onCommonActivityAction(it) }, onDismissRequest = onDismissRequest, modifier = Modifier.fillMaxHeight(fraction = 0.9f) ) @@ -219,7 +218,6 @@ private fun DetailsScreenContent( onEvent: (DetailsEvent) -> Unit, viewActions: Flow, onDismissRequest: () -> Unit, - onCommonActivityAction: (CommonActivityAction) -> Unit, modifier: Modifier = Modifier, ) { LaunchedEffect(key1 = onEvent) { @@ -344,7 +342,7 @@ private fun DetailsScreenContent( val context = LocalContext.current var showTagList: App? by remember { mutableStateOf(null) } - LaunchedEffect(key1 = onCommonActivityAction, key2 = onDismissRequest) { + LaunchedEffect(onDismissRequest) { viewActions.collect { action -> when (action) { DetailsAction.Dismiss -> { @@ -352,8 +350,8 @@ private fun DetailsScreenContent( } is DetailsAction.Share -> { - onCommonActivityAction( - CommonActivityAction.StartActivity( + context.startActivity( + DetailsAction.StartActivity( intent = createAppChooser( action.app, action.recentChange, @@ -362,8 +360,6 @@ private fun DetailsScreenContent( ) ) } - - is DetailsAction.ActivityAction -> onCommonActivityAction(action.action) is DetailsAction.ShowTagSnackbar -> { val result = snackBarHostState.showSnackbar(TagSnackbar.Visuals(action.appInfo, context)) @@ -371,6 +367,9 @@ private fun DetailsScreenContent( showTagList = action.appInfo } } + + is DetailsAction.ShowToast -> context.showToast(action) + is DetailsAction.StartActivity -> context.startActivity(action) } } } @@ -1041,7 +1040,6 @@ private fun DetailsScreenPreview() { modifier = Modifier, viewActions = flowOf(), onDismissRequest = { }, - onCommonActivityAction = { } ) } } diff --git a/app/src/main/java/com/anod/appwatcher/details/DetailsViewModel.kt b/app/src/main/java/com/anod/appwatcher/details/DetailsViewModel.kt index 575afa57..f51079fd 100644 --- a/app/src/main/java/com/anod/appwatcher/details/DetailsViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/details/DetailsViewModel.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.graphics.drawable.BitmapDrawable -import android.net.Uri import android.text.format.Formatter import androidx.annotation.StringRes import androidx.compose.runtime.Immutable @@ -37,7 +36,7 @@ import finsky.api.DfeApi import finsky.api.Document import finsky.api.toDocument import info.anodsplace.applog.AppLog -import info.anodsplace.framework.content.CommonActivityAction +import info.anodsplace.framework.content.ShowToastActionDefaults import info.anodsplace.framework.content.InstalledApps import info.anodsplace.framework.content.forAppInfo import info.anodsplace.framework.content.forUninstall @@ -53,6 +52,8 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.net.URLEncoder import java.util.Locale +import androidx.core.net.toUri +import info.anodsplace.framework.content.StartActivityAction typealias TagMenuItem = Pair @@ -113,28 +114,18 @@ data class AppVersionInfo( } sealed interface DetailsAction { - class ActivityAction(val action: CommonActivityAction) : DetailsAction + class StartActivity(override val intent: Intent) : DetailsAction, StartActivityAction class ShowTagSnackbar(val appInfo: App) : DetailsAction object Dismiss : DetailsAction class Share(val app: App, val recentChange: AppChange) : DetailsAction + class ShowToast(@param:StringRes override val resId: Int) : ShowToastActionDefaults(resId), DetailsAction } -private fun startActivityAction(intent: Intent, addMultiWindowFlags: Boolean = false): DetailsAction.ActivityAction { - return DetailsAction.ActivityAction( - action = CommonActivityAction.StartActivity( - intent = intent, - addMultiWindowFlags = addMultiWindowFlags - ) - ) -} +private fun startActivityAction(intent: Intent): DetailsAction + = DetailsAction.StartActivity(intent = intent) -private fun showToastAction(@StringRes resId: Int): DetailsAction { - return DetailsAction.ActivityAction( - action = CommonActivityAction.ShowToast( - resId = resId - ) - ) -} +private fun showToastAction(@StringRes resId: Int): DetailsAction + = DetailsAction.ShowToast(resId = resId) sealed interface DetailsEvent { class UpdateTag(val tagId: Int, val checked: Boolean) : DetailsEvent @@ -280,7 +271,6 @@ class DetailsViewModel( emitAction( startActivityAction( intent = Intent().forAppInfo(viewState.appId), - addMultiWindowFlags = true ) ) @@ -291,7 +281,6 @@ class DetailsViewModel( emitAction( startActivityAction( intent = launchIntent, - addMultiWindowFlags = true ) ) } @@ -315,7 +304,6 @@ class DetailsViewModel( DetailsEvent.PlayStore -> emitAction( startActivityAction( intent = Intent().forPlayStore(viewState.appId), - addMultiWindowFlags = true ) ) @@ -325,7 +313,7 @@ class DetailsViewModel( val encoded = URLEncoder.encode(Html.parse(text).toString(), "utf-8") emitAction(startActivityAction((Intent(Intent.ACTION_VIEW).apply { data = - Uri.parse("https://translate.google.com/?sl=auto&tl=$lang&text=$encoded&op=translate") + "https://translate.google.com/?sl=auto&tl=$lang&text=$encoded&op=translate".toUri() }))) } @@ -334,7 +322,7 @@ class DetailsViewModel( action = startActivityAction( Intent( Intent.ACTION_VIEW, - Uri.parse(event.url) + event.url.toUri() ) ) ) @@ -378,7 +366,7 @@ class DetailsViewModel( } } catch (e: AuthTokenStartIntent) { loadError = true - emitAction(startActivityAction(e.intent, addMultiWindowFlags = true)) + emitAction(startActivityAction(e.intent)) } catch (e: Exception) { loadError = true AppLog.e("loadChangelog", e) diff --git a/app/src/main/java/com/anod/appwatcher/history/HistoryListActivity.kt b/app/src/main/java/com/anod/appwatcher/history/HistoryListActivity.kt deleted file mode 100644 index f6018f47..00000000 --- a/app/src/main/java/com/anod/appwatcher/history/HistoryListActivity.kt +++ /dev/null @@ -1,102 +0,0 @@ -package com.anod.appwatcher.history - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.widget.Toast -import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.anod.appwatcher.R -import com.anod.appwatcher.compose.AppTheme -import com.anod.appwatcher.compose.BaseComposeActivity -import com.anod.appwatcher.compose.MainDetailScreen -import com.anod.appwatcher.details.DetailsDialog -import com.anod.appwatcher.utils.prefs -import com.anod.appwatcher.watchlist.DetailContent -import info.anodsplace.framework.content.onCommonActivityAction -import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent - -/** - * @author Alex Gavrishev - * * - * @date 16/12/2016. - */ -class HistoryListActivity : BaseComposeActivity(), KoinComponent { - - private val viewModel: HistoryListViewModel by viewModels(factoryProducer = { - HistoryListViewModel.Factory( - wideLayout = foldableDevice.layout.value, - ) - }) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (!viewModel.authenticated) { - Toast.makeText(this, R.string.choose_an_account, Toast.LENGTH_SHORT).show() - finish() - return - } - - setContent { - AppTheme( - theme = viewModel.prefs.theme - ) { - val screenState by viewModel.viewStates.collectAsState(initial = viewModel.viewState) - if (screenState.wideLayout.isWideLayout) { - MainDetailScreen( - wideLayout = screenState.wideLayout, - main = { - HistoryListScreen( - screenState = screenState, - onEvent = viewModel::handleEvent, - pagingDataFlow = viewModel.pagingData, - viewActions = viewModel.viewActions, - onActivityAction = { onCommonActivityAction(it) } - ) - }, - detail = { - DetailContent( - app = screenState.selectedApp, - onDismissRequest = { viewModel.handleEvent(HistoryListEvent.SelectApp(app = null)) }, - onCommonActivityAction = { onCommonActivityAction(it) } - ) - } - ) - } else { - HistoryListScreen( - screenState = screenState, - onEvent = viewModel::handleEvent, - pagingDataFlow = viewModel.pagingData, - viewActions = viewModel.viewActions, - onActivityAction = { onCommonActivityAction(it) } - ) - if (screenState.selectedApp != null) { - DetailsDialog( - app = screenState.selectedApp!!, - onDismissRequest = { viewModel.handleEvent(HistoryListEvent.SelectApp(app = null)) }, - onCommonActivityAction = { onCommonActivityAction(it) } - ) - } - } - } - } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.CREATED) { - foldableDevice.layout.collect { - viewModel.handleEvent(HistoryListEvent.SetWideLayout(it)) - } - } - } - } - - companion object { - fun intent(context: Context): Intent = Intent(context, HistoryListActivity::class.java) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/history/HistoryListScreen.kt b/app/src/main/java/com/anod/appwatcher/history/HistoryListScreen.kt index a336845b..65438ae9 100644 --- a/app/src/main/java/com/anod/appwatcher/history/HistoryListScreen.kt +++ b/app/src/main/java/com/anod/appwatcher/history/HistoryListScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -30,6 +31,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.paging.LoadState import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems @@ -38,23 +40,42 @@ import androidx.paging.compose.itemKey import com.anod.appwatcher.R import com.anod.appwatcher.compose.SearchTopBar import com.anod.appwatcher.database.entities.App +import com.anod.appwatcher.navigation.SceneNavKey import com.anod.appwatcher.search.ListItem import com.anod.appwatcher.search.MarketAppItem import com.anod.appwatcher.search.RetryButton import com.anod.appwatcher.tags.TagSelectionDialog import com.anod.appwatcher.tags.TagSnackbar import com.anod.appwatcher.utils.AppIconLoader -import info.anodsplace.framework.content.CommonActivityAction +import info.anodsplace.framework.app.FoldableDeviceLayout import kotlinx.coroutines.flow.Flow import org.koin.java.KoinJavaComponent +@Composable +fun HistoryListScreenScene(wideLayout: FoldableDeviceLayout, navigateBack: () -> Unit) { + val viewModel: HistoryListViewModel = viewModel( + factory = HistoryListViewModel.Factory( + wideLayout = wideLayout, + ), + key = SceneNavKey.Main.toString() + ) + val screenState by viewModel.viewStates.collectAsState(initial = viewModel.viewState) + HistoryListScreen( + screenState = screenState, + onEvent = viewModel::handleEvent, + pagingDataFlow = viewModel.pagingData, + viewActions = viewModel.viewActions, + navigateBack = navigateBack + ) +} + @Composable fun HistoryListScreen( screenState: HistoryListState, pagingDataFlow: Flow>, onEvent: (HistoryListEvent) -> Unit, viewActions: Flow, - onActivityAction: (CommonActivityAction) -> Unit, + navigateBack: () -> Unit = {}, appIconLoader: AppIconLoader = KoinJavaComponent.getKoin().get(), ) { val context = LocalContext.current @@ -114,7 +135,7 @@ fun HistoryListScreen( } var showTagList: App? by remember { mutableStateOf(null) } - LaunchedEffect(key1 = viewActions, key2 = onActivityAction) { + LaunchedEffect(key1 = viewActions) { viewActions.collect { action -> when (action) { is HistoryListAction.ShowTagSnackbar -> { @@ -123,7 +144,8 @@ fun HistoryListScreen( showTagList = action.info } } - is HistoryListAction.ActivityAction -> onActivityAction(action.action) + + HistoryListAction.OnBackPress -> navigateBack() } } } diff --git a/app/src/main/java/com/anod/appwatcher/history/HistoryListViewModel.kt b/app/src/main/java/com/anod/appwatcher/history/HistoryListViewModel.kt index 95a38356..4094758d 100644 --- a/app/src/main/java/com/anod/appwatcher/history/HistoryListViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/history/HistoryListViewModel.kt @@ -12,17 +12,19 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.filter +import com.anod.appwatcher.accounts.AuthTokenBlocking +import com.anod.appwatcher.accounts.toAndroidAccount import com.anod.appwatcher.database.AppsDatabase import com.anod.appwatcher.database.entities.App import com.anod.appwatcher.database.observePackages import com.anod.appwatcher.search.ListItem import com.anod.appwatcher.search.updateRowId import com.anod.appwatcher.utils.BaseFlowViewModel +import com.anod.appwatcher.utils.prefs import finsky.api.DfeApi import finsky.api.FilterComposite import finsky.api.FilterPredicate import info.anodsplace.framework.app.FoldableDeviceLayout -import info.anodsplace.framework.content.CommonActivityAction import info.anodsplace.framework.content.InstalledApps import info.anodsplace.playstore.AppNameFilter import info.anodsplace.playstore.PaidHistoryFilter @@ -30,6 +32,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -43,8 +46,8 @@ data class HistoryListState( ) sealed interface HistoryListAction { + data object OnBackPress : HistoryListAction class ShowTagSnackbar(val info: App) : HistoryListAction - class ActivityAction(val action: CommonActivityAction) : HistoryListAction } sealed interface HistoryListEvent { @@ -69,12 +72,11 @@ class HistoryListViewModel(wideLayout: FoldableDeviceLayout) : BaseFlowViewModel private val dfeApi: DfeApi by inject() private val packageManager: PackageManager by inject() private val installedApps by lazy { InstalledApps.MemoryCache(InstalledApps.PackageManager(packageManager)) } - val authenticated: Boolean - get() = dfeApi.authenticated + private val authToken: AuthTokenBlocking by inject() init { viewState = HistoryListState( - wideLayout = wideLayout + wideLayout = wideLayout, ) } @@ -102,6 +104,7 @@ class HistoryListViewModel(wideLayout: FoldableDeviceLayout) : BaseFlowViewModel ) } .flow + .onStart { authToken.checkToken(prefs.account?.toAndroidAccount()) } .cachedIn(viewModelScope) .combine( flow = viewStates.map { it.nameFilter }.distinctUntilChanged(), @@ -114,7 +117,7 @@ class HistoryListViewModel(wideLayout: FoldableDeviceLayout) : BaseFlowViewModel override fun handleEvent(event: HistoryListEvent) { when (event) { - HistoryListEvent.OnBackPress -> emitAction(HistoryListAction.ActivityAction(CommonActivityAction.Finish)) + HistoryListEvent.OnBackPress -> emitAction(HistoryListAction.OnBackPress) is HistoryListEvent.OnNameFilter -> viewState = viewState.copy(nameFilter = event.query) is HistoryListEvent.SelectApp -> { viewState = viewState.copy(selectedApp = event.app) diff --git a/app/src/main/java/com/anod/appwatcher/installed/InstalledActivity.kt b/app/src/main/java/com/anod/appwatcher/installed/InstalledActivity.kt deleted file mode 100644 index 29ebaecd..00000000 --- a/app/src/main/java/com/anod/appwatcher/installed/InstalledActivity.kt +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) 2020. Alex Gavrishev -package com.anod.appwatcher.installed - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.annotation.Keep -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.anod.appwatcher.compose.AppTheme -import com.anod.appwatcher.compose.BaseComposeActivity -import com.anod.appwatcher.compose.MainDetailScreen -import com.anod.appwatcher.details.DetailsDialog -import com.anod.appwatcher.model.Filters -import com.anod.appwatcher.preferences.Preferences -import com.anod.appwatcher.utils.prefs -import com.anod.appwatcher.watchlist.DetailContent -import com.anod.appwatcher.watchlist.MainActivity -import com.anod.appwatcher.watchlist.WatchListPagingSource -import info.anodsplace.framework.content.onCommonActivityAction -import kotlinx.coroutines.launch - -@Keep -class InstalledActivity : BaseComposeActivity() { - private val viewModel: InstalledListViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - viewModel.handleEvent(InstalledListEvent.SetWideLayout(foldableDevice.layout.value)) - - setContent { - AppTheme( - theme = viewModel.prefs.theme - ) { - val screenState by viewModel.viewStates.collectAsState(initial = viewModel.viewState) - - val pagingSourceConfig = WatchListPagingSource.Config( - filterId = Filters.ALL, - tagId = null, - showRecentlyDiscovered = false, - showOnDevice = true, - showRecentlyInstalled = false - ) - - if (screenState.wideLayout.isWideLayout) { - MainDetailScreen( - wideLayout = screenState.wideLayout, - main = { - InstalledListScreen( - screenState = screenState, - pagingSourceConfig = pagingSourceConfig, - onEvent = viewModel::handleEvent, - installedApps = viewModel.installedApps - ) - }, - detail = { - DetailContent( - app = screenState.selectedApp, - onDismissRequest = { viewModel.handleEvent(InstalledListEvent.SelectApp(app = null)) }, - onCommonActivityAction = { onCommonActivityAction(it) } - ) - } - ) - } else { - InstalledListScreen( - screenState = screenState, - pagingSourceConfig = pagingSourceConfig, - onEvent = viewModel::handleEvent, - installedApps = viewModel.installedApps - ) - if (screenState.selectedApp != null) { - DetailsDialog( - app = screenState.selectedApp!!, - onDismissRequest = { viewModel.handleEvent(InstalledListEvent.SelectApp(app = null)) }, - onCommonActivityAction = { onCommonActivityAction(it) } - ) - } - } - } - } - - lifecycleScope.launch { - viewModel.viewActions.collect { onCommonActivityAction(it) } - } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.CREATED) { - foldableDevice.layout.collect { - viewModel.handleEvent(InstalledListEvent.SetWideLayout(it)) - } - } - } - } - - companion object { - private fun intent(sortId: Int, showImportAction: Boolean, context: Context): Intent { - return Intent(context, InstalledActivity::class.java).apply { - putExtra(MainActivity.ARG_SORT, sortId) - putExtra(MainActivity.ARG_SHOW_ACTION, showImportAction) - } - } - - fun intent(importMode: Boolean, context: Context) = intent( - if (importMode) Preferences.SORT_NAME_ASC else Preferences.SORT_DATE_DESC, - importMode, - context - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/installed/InstalledListScreen.kt b/app/src/main/java/com/anod/appwatcher/installed/InstalledListScreen.kt index d598d2b5..9a1b4257 100644 --- a/app/src/main/java/com/anod/appwatcher/installed/InstalledListScreen.kt +++ b/app/src/main/java/com/anod/appwatcher/installed/InstalledListScreen.kt @@ -17,14 +17,57 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.paging.LoadState import androidx.paging.compose.collectAsLazyPagingItems import com.anod.appwatcher.R +import com.anod.appwatcher.model.Filters +import com.anod.appwatcher.navigation.SceneNavKey +import com.anod.appwatcher.preferences.Preferences import com.anod.appwatcher.watchlist.WatchListPage import com.anod.appwatcher.watchlist.WatchListPagingSource import info.anodsplace.applog.AppLog import info.anodsplace.framework.content.InstalledApps +import info.anodsplace.framework.content.onScreenCommonAction + +@Composable +fun InstalledListScreenScene( + showAction: Boolean, + navigateBack: () -> Unit +) { + val context = LocalContext.current + val viewModel: InstalledListViewModel = viewModel( + factory = InstalledListViewModel.Factory( + showAction = showAction, + sortId = if (showAction) Preferences.SORT_NAME_ASC else Preferences.SORT_DATE_DESC + ), + key = SceneNavKey.Installed.toString() + ) + val screenState by viewModel.viewStates.collectAsState(initial = viewModel.viewState) + + val pagingSourceConfig = WatchListPagingSource.Config( + filterId = Filters.ALL, + tagId = null, + showRecentlyDiscovered = false, + showOnDevice = true, + showRecentlyInstalled = false + ) + + InstalledListScreen( + screenState = screenState, + pagingSourceConfig = pagingSourceConfig, + onEvent = viewModel::handleEvent, + installedApps = viewModel.installedApps + ) + + LaunchedEffect(true) { + viewModel.viewActions.collect { action -> + context.onScreenCommonAction(action, navigateBack) + } + } +} @Composable fun InstalledListScreen( diff --git a/app/src/main/java/com/anod/appwatcher/installed/InstalledListViewModel.kt b/app/src/main/java/com/anod/appwatcher/installed/InstalledListViewModel.kt index 8f4b3154..31eea1cd 100644 --- a/app/src/main/java/com/anod/appwatcher/installed/InstalledListViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/installed/InstalledListViewModel.kt @@ -5,7 +5,11 @@ import android.graphics.Rect import android.widget.Toast import androidx.compose.runtime.Immutable import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras import com.anod.appwatcher.R import com.anod.appwatcher.accounts.AuthTokenBlocking import com.anod.appwatcher.accounts.CheckTokenError @@ -21,14 +25,15 @@ import com.anod.appwatcher.utils.networkConnection import com.anod.appwatcher.utils.prefs import com.anod.appwatcher.watchlist.WatchListEvent import info.anodsplace.framework.app.FoldableDeviceLayout -import info.anodsplace.framework.content.CommonActivityAction import info.anodsplace.framework.content.InstalledApps +import info.anodsplace.framework.content.ScreenCommonAction import info.anodsplace.framework.content.getInstalledPackagesCodes import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import kotlin.reflect.KClass @Immutable data class InstalledListState( @@ -55,10 +60,13 @@ sealed interface InstalledListEvent { class SelectApp(val app: App?) : InstalledListEvent class AuthTokenError(val error: CheckTokenError) : InstalledListEvent object Import : InstalledListEvent - object NoAccount : InstalledListEvent } -class InstalledListViewModel(state: SavedStateHandle) : BaseFlowViewModel(), KoinComponent { +class InstalledListViewModel( + state: SavedStateHandle, + showAction: Boolean, + sortId: Int +) : BaseFlowViewModel(), KoinComponent { private val importManager: ImportBulkManager by inject() private val packageManager: PackageManager by inject() private val packageChanged: PackageChangedReceiver by inject() @@ -66,10 +74,24 @@ class InstalledListViewModel(state: SavedStateHandle) : BaseFlowViewModel create(modelClass: KClass, extras: CreationExtras): T { + return InstalledListViewModel( + state = extras.createSavedStateHandle(), + showAction = showAction, + sortId = sortId, + ) as T + } + } + init { viewState = InstalledListState( - sortId = state.getInt("sort"), - selectionMode = state["showAction"] ?: false, + sortId = state.getInt("sort", sortId) , + selectionMode = state["showAction"] ?: showAction, enablePullToRefresh = prefs.enablePullToRefresh ) viewModelScope.launch { @@ -108,27 +130,25 @@ class InstalledListViewModel(state: SavedStateHandle) : BaseFlowViewModel import() is InstalledListEvent.AuthTokenError -> { if (event.error is CheckTokenError.RequiresInteraction) { - emitAction(CommonActivityAction.StartActivity(event.error.intent)) + emitAction(ScreenCommonAction.StartActivity(event.error.intent)) } else { tokenErrorToast() } } - - InstalledListEvent.NoAccount -> tokenErrorToast() } } private fun tokenErrorToast() { if (networkConnection.isNetworkAvailable) { emitAction( - CommonActivityAction.ShowToast( + ScreenCommonAction.ShowToast( resId = R.string.failed_gain_access, length = Toast.LENGTH_SHORT ) ) } else { emitAction( - CommonActivityAction.ShowToast( + ScreenCommonAction.ShowToast( resId = R.string.check_connection, length = Toast.LENGTH_SHORT ) @@ -141,10 +161,10 @@ class InstalledListViewModel(state: SavedStateHandle) : BaseFlowViewModel { - handleEvent(InstalledListEvent.AuthTokenError(result.error)) - false - } - - is CheckTokenResult.Success -> true + return when (val result = authToken.checkToken(prefs.account?.toAndroidAccount())) { + is CheckTokenResult.Error -> { + handleEvent(InstalledListEvent.AuthTokenError(result.error)) + false } + + is CheckTokenResult.Success -> true } } diff --git a/app/src/main/java/com/anod/appwatcher/navigation/SceneNavKey.kt b/app/src/main/java/com/anod/appwatcher/navigation/SceneNavKey.kt new file mode 100644 index 00000000..d896242a --- /dev/null +++ b/app/src/main/java/com/anod/appwatcher/navigation/SceneNavKey.kt @@ -0,0 +1,45 @@ +package com.anod.appwatcher.navigation + +import androidx.navigation3.runtime.NavKey +import com.anod.appwatcher.database.entities.App +import com.anod.appwatcher.database.entities.Tag +import kotlinx.serialization.Serializable + +sealed interface SceneNavKey : NavKey { + @Serializable + data object Main : SceneNavKey + + @Serializable + data class Search( + val keyword: String = "", + val focus: Boolean = false, + val initiateSearch: Boolean = false, + val isPackageSearch: Boolean = false, + val isShareSource: Boolean = false + ) : SceneNavKey + + @Serializable + data object Settings : SceneNavKey + + @Serializable + data object RefreshHistory : SceneNavKey + + @Serializable + data object UserLog : SceneNavKey + + @Serializable + data class Installed(val importMode: Boolean) : SceneNavKey + + @Serializable + data object WishList : SceneNavKey + + @Serializable + data object PurchaseHistory : SceneNavKey + + @Serializable + data class TagWatchList(val tag: Tag) : SceneNavKey + + @Serializable + data class AppDetails(val selectedApp: App) : SceneNavKey +} + diff --git a/app/src/main/java/com/anod/appwatcher/preferences/SettingsActivity.kt b/app/src/main/java/com/anod/appwatcher/preferences/SettingsActivity.kt deleted file mode 100644 index c48707f8..00000000 --- a/app/src/main/java/com/anod/appwatcher/preferences/SettingsActivity.kt +++ /dev/null @@ -1,199 +0,0 @@ -package com.anod.appwatcher.preferences - -import android.annotation.SuppressLint -import android.content.Intent -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.viewModels -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.Icon -import androidx.compose.material3.Surface -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.anod.appwatcher.AppWatcherActivity -import com.anod.appwatcher.backup.ExportBackupTask -import com.anod.appwatcher.backup.ImportBackupTask -import com.anod.appwatcher.backup.gdrive.GDriveSignIn -import com.anod.appwatcher.compose.AppTheme -import com.anod.appwatcher.compose.BaseComposeActivity -import com.anod.appwatcher.compose.MainDetailScreen -import com.anod.appwatcher.utils.prefs -import com.google.android.gms.auth.api.signin.GoogleSignInAccount -import com.jakewharton.processphoenix.ProcessPhoenix -import info.anodsplace.applog.AppLog -import info.anodsplace.framework.content.onCommonActivityAction -import info.anodsplace.permissions.AppPermission -import info.anodsplace.permissions.AppPermissions -import info.anodsplace.permissions.toRequestInput -import kotlinx.coroutines.launch - -@SuppressLint("Registered") -open class SettingsActivity : BaseComposeActivity(), GDriveSignIn.Listener { - - private lateinit var gDriveErrorIntentRequest: ActivityResultLauncher - private lateinit var notificationPermissionRequest: ActivityResultLauncher - private val gDriveSignIn: GDriveSignIn by lazy { GDriveSignIn(this, this) } - private val viewModel: SettingsViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - viewModel.handleEvent(SettingsViewEvent.SetWideLayout(foldableDevice.layout.value)) - - gDriveErrorIntentRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { } - notificationPermissionRequest = registerForActivityResult(AppPermissions.Request()) { - val enabled = it[AppPermission.PostNotification.value] ?: false - viewModel.handleEvent(SettingsViewEvent.NotificationPermissionResult(enabled)) - if (!enabled) { - viewModel.handleEvent(SettingsViewEvent.ShowAppSettings) - } - } - - setContent { - AppTheme( - theme = viewModel.prefs.theme - ) { - val screenState by viewModel.viewStates.collectAsState(initial = viewModel.viewState) - - if (screenState.wideLayout.isWideLayout) { - MainDetailScreen( - wideLayout = screenState.wideLayout, - main = { - SettingsScreen( - screenState = screenState, - onEvent = viewModel::handleEvent - ) - }, - detail = { - Surface { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = null, - modifier = Modifier.size(128.dp), - ) - } - } - } - ) - } else { - SettingsScreen( - screenState = screenState, - onEvent = viewModel::handleEvent - ) - } - } - } - - lifecycleScope.launch { - lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.viewActions.collect { action -> - AppLog.d("Action collected $action") - handleUiAction(action) - } - } - } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.CREATED) { - foldableDevice.layout.collect { - viewModel.handleEvent(SettingsViewEvent.SetWideLayout(it)) - } - } - } - } - - override fun onResume() { - super.onResume() - viewModel.handleEvent(SettingsViewEvent.CheckNotificationPermission) - } - - private fun handleUiAction(action: SettingsViewAction) { - when (action) { - is SettingsViewAction.ExportResult -> onExportResult(action.result) - is SettingsViewAction.ImportResult -> onImportResult(action.result) - SettingsViewAction.GDriveSignIn -> gDriveSignIn.signIn() - SettingsViewAction.GDriveSignOut -> { - lifecycleScope.launch { gDriveSignIn.signOut() } - } - is SettingsViewAction.GDriveErrorIntent -> gDriveErrorIntentRequest.launch(action.intent) - SettingsViewAction.Recreate -> { - this@SettingsActivity.setResult(RESULT_OK, Intent().putExtra("recreateWatchlistOnBack", true)) - this@SettingsActivity.recreate() - recreateWatchlist() - } - SettingsViewAction.Rebirth -> { - ProcessPhoenix.triggerRebirth(applicationContext, Intent(applicationContext, AppWatcherActivity::class.java)) - } - SettingsViewAction.RequestNotificationPermission -> notificationPermissionRequest.launch(AppPermission.PostNotification.toRequestInput()) - is SettingsViewAction.ActivityAction -> onCommonActivityAction(action = action.action) - } - } - - private fun onImportResult(result: Int) { - when (result) { - -1 -> { - AppLog.d("Importing...") - } - else -> { - AppLog.d("Import finished with code: $result") - ImportBackupTask.showImportFinishToast(this@SettingsActivity, result) - } - } - } - - private fun onExportResult(result: Int) { - when (result) { - -1 -> { - AppLog.d("Exporting...") - } - else -> { - AppLog.d("Export finished with code: $result") - ExportBackupTask.showFinishToast(this@SettingsActivity, result) - } - } - } - - @Deprecated("Deprecated in Java") - override fun onBackPressed() { - super.onBackPressed() - if (viewModel.viewState.recreateWatchlistOnBack) { - this.recreateWatchlist() - } - } - - @Deprecated("Deprecated in Java") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - gDriveSignIn.onActivityResult(requestCode, resultCode, data) - super.onActivityResult(requestCode, resultCode, data) - } - - private fun recreateWatchlist() { - val i = Intent(this@SettingsActivity, AppWatcherActivity::class.java) - i.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP - startActivity(i) - } - - override fun onGDriveLoginSuccess(googleSignInAccount: GoogleSignInAccount) { - viewModel.handleEvent(SettingsViewEvent.GDriveLoginResult(true, 0)) - } - - override fun onGDriveLoginError(errorCode: Int) { - viewModel.handleEvent(SettingsViewEvent.GDriveLoginResult(false, errorCode)) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/preferences/SettingsScreen.kt b/app/src/main/java/com/anod/appwatcher/preferences/SettingsScreen.kt index 8ca62f6d..45df8a43 100644 --- a/app/src/main/java/com/anod/appwatcher/preferences/SettingsScreen.kt +++ b/app/src/main/java/com/anod/appwatcher/preferences/SettingsScreen.kt @@ -1,5 +1,8 @@ package com.anod.appwatcher.preferences +import android.app.Activity.RESULT_OK +import android.content.Context +import android.content.Intent import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -22,6 +25,9 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -31,18 +37,103 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.NavKey +import com.anod.appwatcher.AppWatcherActivity import com.anod.appwatcher.R import com.anod.appwatcher.backup.DbBackupManager +import com.anod.appwatcher.backup.ExportBackupTask +import com.anod.appwatcher.backup.ImportBackupTask import com.anod.appwatcher.compose.AppTheme +import com.jakewharton.processphoenix.ProcessPhoenix import info.anodsplace.applog.AppLog import info.anodsplace.compose.IconShapeSelector import info.anodsplace.compose.Preference import info.anodsplace.compose.PreferenceItem import info.anodsplace.compose.PreferencesScreen import info.anodsplace.compose.key +import info.anodsplace.framework.app.findActivity import info.anodsplace.framework.content.CreateDocument +import info.anodsplace.framework.content.showToast +import info.anodsplace.framework.content.startActivity +import info.anodsplace.permissions.AppPermission +import info.anodsplace.permissions.AppPermissions +import info.anodsplace.permissions.toRequestInput import org.koin.java.KoinJavaComponent +@Composable +fun SettingsScreenScene(navigateBack: () -> Unit, navigateTo: (NavKey) -> Unit) { + val viewModel: SettingsViewModel = viewModel() + val screenState by viewModel.viewStates.collectAsState(initial = viewModel.viewState) + val context = LocalContext.current + val notificationPermissionRequest = rememberLauncherForActivityResult(AppPermissions.Request()) { + val enabled = it[AppPermission.PostNotification.value] ?: false + viewModel.handleEvent(SettingsViewEvent.NotificationPermissionResult(enabled)) + if (!enabled) { + viewModel.handleEvent(SettingsViewEvent.ShowAppSettings) + } + } + val gDriveErrorIntentRequest = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + viewModel.handleEvent(SettingsViewEvent.GDriveActivityResult(it)) + } + SettingsScreen( + screenState = screenState, + onEvent = viewModel::handleEvent + ) + LaunchedEffect(true) { + viewModel.viewActions.collect { action -> + when (action) { + is SettingsViewAction.ExportResult -> onExportResult(action.result, context) + is SettingsViewAction.ImportResult -> onImportResult(action.result, context) + is SettingsViewAction.GDriveErrorIntent -> gDriveErrorIntentRequest.launch(action.intent) + SettingsViewAction.Recreate -> { + context.findActivity().setResult(RESULT_OK, Intent().putExtra("recreateWatchlistOnBack", true)) + context.findActivity().recreate() + recreateWatchlist(context) + } + SettingsViewAction.Rebirth -> { + ProcessPhoenix.triggerRebirth(context.applicationContext, Intent(context.applicationContext, AppWatcherActivity::class.java)) + } + SettingsViewAction.RequestNotificationPermission -> notificationPermissionRequest.launch(AppPermission.PostNotification.toRequestInput()) + SettingsViewAction.NavigateBack -> navigateBack() + is SettingsViewAction.NavigateTo -> navigateTo(action.navKey) + is SettingsViewAction.ShowToast -> context.showToast(action) + is SettingsViewAction.StartActivity -> context.startActivity(action) + } + } + } +} + +private fun recreateWatchlist(context: Context) { + val i = Intent(context, AppWatcherActivity::class.java) + i.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP + context.startActivity(i) +} + +private fun onImportResult(result: Int, context: Context) { + when (result) { + -1 -> { + AppLog.d("Importing...") + } + else -> { + AppLog.d("Import finished with code: $result") + ImportBackupTask.showImportFinishToast(context, result) + } + } +} + +private fun onExportResult(result: Int, context: Context) { + when (result) { + -1 -> { + AppLog.d("Exporting...") + } + else -> { + AppLog.d("Export finished with code: $result") + ExportBackupTask.showFinishToast(context, result) + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen(screenState: SettingsViewState, onEvent: (SettingsViewEvent) -> Unit, prefs: Preferences = KoinJavaComponent.getKoin().get()) { @@ -68,7 +159,7 @@ fun SettingsScreen(screenState: SettingsViewState, onEvent: (SettingsViewEvent) CenterAlignedTopAppBar( title = { Text(text = stringResource(id = R.string.navdrawer_item_settings)) }, navigationIcon = { - IconButton(onClick = { onEvent(SettingsViewEvent.OnBackNav) }) { + IconButton(onClick = { onEvent(SettingsViewEvent.NavigateBack) }) { Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(id = R.string.back)) } }, diff --git a/app/src/main/java/com/anod/appwatcher/preferences/SettingsViewModel.kt b/app/src/main/java/com/anod/appwatcher/preferences/SettingsViewModel.kt index c9d457da..9ef08545 100644 --- a/app/src/main/java/com/anod/appwatcher/preferences/SettingsViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/preferences/SettingsViewModel.kt @@ -5,32 +5,34 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.widget.Toast +import androidx.activity.result.ActivityResult import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatDelegate import androidx.compose.runtime.Immutable import androidx.lifecycle.viewModelScope +import androidx.navigation3.runtime.NavKey import androidx.work.Operation import com.anod.appwatcher.R import com.anod.appwatcher.backup.ExportBackupTask import com.anod.appwatcher.backup.ImportBackupTask +import com.anod.appwatcher.backup.gdrive.GDriveSignIn import com.anod.appwatcher.backup.gdrive.GDriveSync import com.anod.appwatcher.backup.gdrive.UploadServiceContentObserver import com.anod.appwatcher.database.AppsDatabase import com.anod.appwatcher.database.Cleanup -import com.anod.appwatcher.sync.SchedulesHistoryActivity +import com.anod.appwatcher.navigation.SceneNavKey import com.anod.appwatcher.sync.SyncNotification import com.anod.appwatcher.sync.SyncScheduler import com.anod.appwatcher.sync.UpdatedApp -import com.anod.appwatcher.userLog.UserLogActivity import com.anod.appwatcher.utils.BaseFlowViewModel import com.anod.appwatcher.utils.prefs -import com.google.android.gms.auth.api.signin.GoogleSignIn import com.google.android.gms.oss.licenses.OssLicensesMenuActivity import info.anodsplace.applog.AppLog import info.anodsplace.compose.PreferenceItem import info.anodsplace.context.ApplicationContext import info.anodsplace.framework.app.FoldableDeviceLayout -import info.anodsplace.framework.content.CommonActivityAction +import info.anodsplace.framework.content.ShowToastActionDefaults +import info.anodsplace.framework.content.StartActivityAction import info.anodsplace.framework.content.forAppInfo import info.anodsplace.notification.NotificationManager import info.anodsplace.permissions.AppPermission @@ -60,16 +62,16 @@ sealed interface SettingsViewEvent { class UpdateIconsShape(val newPath: String) : SettingsViewEvent class GDriveSyncToggle(val checked: Boolean) : SettingsViewEvent object GDriveSyncNow : SettingsViewEvent + class GDriveActivityResult(val activityResult: ActivityResult) : SettingsViewEvent class ChangeUpdatePolicy(val frequency: Int, val isWifiOnly: Boolean, val isRequiresCharging: Boolean) : SettingsViewEvent class UpdateCrashReports(val checked: Boolean) : SettingsViewEvent class SetRecreateFlag(val item: PreferenceItem, val enabled: Boolean, val update: (Boolean) -> Unit) : SettingsViewEvent class UpdateTheme(val newTheme: Int) : SettingsViewEvent - object OnBackNav : SettingsViewEvent + object NavigateBack : SettingsViewEvent object TestNotification : SettingsViewEvent object OssLicenses : SettingsViewEvent object OpenUserLog : SettingsViewEvent object OpenRefreshHistory : SettingsViewEvent - class GDriveLoginResult(val isSuccess: Boolean, val errorCode: Int) : SettingsViewEvent object NotificationPermissionRequest : SettingsViewEvent class NotificationPermissionResult(val granted: Boolean) : SettingsViewEvent class SetWideLayout(val wideLayout: FoldableDeviceLayout) : SettingsViewEvent @@ -79,35 +81,23 @@ sealed interface SettingsViewEvent { } sealed interface SettingsViewAction { - class ActivityAction(val action: CommonActivityAction) : SettingsViewAction - object GDriveSignIn : SettingsViewAction - object GDriveSignOut : SettingsViewAction + data object NavigateBack : SettingsViewAction + data class StartActivity(override val intent: Intent) : SettingsViewAction, StartActivityAction + class ShowToast(@StringRes resId: Int = 0, text: String = "", length: Int = Toast.LENGTH_SHORT) : ShowToastActionDefaults(resId, text, length), SettingsViewAction class GDriveErrorIntent(val intent: Intent) : SettingsViewAction object Recreate : SettingsViewAction object Rebirth : SettingsViewAction object RequestNotificationPermission : SettingsViewAction class ExportResult(val result: Int) : SettingsViewAction class ImportResult(val result: Int) : SettingsViewAction + data class NavigateTo(val navKey: NavKey) : SettingsViewAction } -private val finishAction = SettingsViewAction.ActivityAction(CommonActivityAction.Finish) - -private fun startActivityAction(intent: Intent, addMultiWindowFlags: Boolean = false): SettingsViewAction.ActivityAction { - return SettingsViewAction.ActivityAction( - action = CommonActivityAction.StartActivity( - intent = intent, - addMultiWindowFlags = addMultiWindowFlags - ) - ) -} - -private fun showToastAction(@StringRes resId: Int = 0, text: String = "", length: Int = Toast.LENGTH_SHORT): SettingsViewAction.ActivityAction { - return SettingsViewAction.ActivityAction( - action = CommonActivityAction.ShowToast( - resId = resId, - text = text, - length = length - ) +private fun showToastAction(@StringRes resId: Int = 0, text: String = "", length: Int = Toast.LENGTH_SHORT): SettingsViewAction { + return SettingsViewAction.ShowToast( + resId = resId, + text = text, + length = length ) } @@ -117,6 +107,7 @@ class SettingsViewModel : BaseFlowViewModel onGDriveLoginResult(event.isSuccess, event.errorCode) SettingsViewEvent.GDriveSyncNow -> gDriveSyncNow() is SettingsViewEvent.GDriveSyncToggle -> gDriveSyncToggle(event.checked) - SettingsViewEvent.OnBackNav -> emitAction(finishAction) - SettingsViewEvent.OpenRefreshHistory -> emitAction(startActivityAction( - Intent(application, SchedulesHistoryActivity::class.java), - addMultiWindowFlags = true - )) - SettingsViewEvent.OpenUserLog -> emitAction(startActivityAction( - Intent(application, UserLogActivity::class.java), - addMultiWindowFlags = true + SettingsViewEvent.NavigateBack -> emitAction(SettingsViewAction.NavigateBack) + SettingsViewEvent.OpenRefreshHistory -> emitAction( + SettingsViewAction.NavigateTo( + navKey = SceneNavKey.RefreshHistory + ) + ) + + SettingsViewEvent.OpenUserLog -> emitAction( + SettingsViewAction.NavigateTo( + navKey = SceneNavKey.UserLog )) - SettingsViewEvent.OssLicenses -> emitAction(startActivityAction( + + SettingsViewEvent.OssLicenses -> emitAction( + SettingsViewAction.StartActivity( Intent(application, OssLicensesMenuActivity::class.java), - addMultiWindowFlags = true )) is SettingsViewEvent.SetRecreateFlag -> { val result = setRecreateFlag(event.item, event.enabled) @@ -179,9 +172,9 @@ class SettingsViewModel : BaseFlowViewModel emitAction(startActivityAction( + SettingsViewEvent.ShowAppSettings -> emitAction( + SettingsViewAction.StartActivity( intent = Intent().forAppInfo(application.packageName), - addMultiWindowFlags = true )) SettingsViewEvent.CheckNotificationPermission -> { val areNotificationsEnabled = prefs.areNotificationsEnabled @@ -202,6 +195,7 @@ class SettingsViewModel : BaseFlowViewModel { viewState = viewState.copy(wideLayout = event.wideLayout) } + is SettingsViewEvent.GDriveActivityResult -> onGDriveActivityResult(event.activityResult) } } @@ -224,19 +218,46 @@ class SettingsViewModel : BaseFlowViewModel emitAction(SettingsViewAction.GDriveErrorIntent(e.intent)) + is GDriveSignIn.GoogleSignInFailedException -> onGDriveLoginResult(false, e.resultCode) + else -> onGDriveLoginResult(false, 0) + } + } + } } else { prefs.isDriveSyncEnabled = false viewState = viewState.copy( isProgressVisible = false, items = preferenceItems(prefs, inProgress = false, playServices, application) ) - emitAction(SettingsViewAction.GDriveSignOut) + viewModelScope.launch { + gDriveSignIn.signOut() + } + } + } + + private fun onGDriveActivityResult(activityResult: ActivityResult): Unit { + viewModelScope.launch { + try { + gDriveSignIn.onActivityResult(activityResult.resultCode, activityResult.data) + onGDriveLoginResult(true, -1) + } catch (e: Throwable) { + when (e) { + is GDriveSignIn.GoogleSignInFailedException -> onGDriveLoginResult(false, e.resultCode) + else -> onGDriveLoginResult(false, 0) + } + } } } private fun gDriveSyncNow() { - val googleAccount = GoogleSignIn.getLastSignedInAccount(context) + val googleAccount = GDriveSignIn.getLastSignedInAccount(context) if (googleAccount != null) { appScope.launch { try { diff --git a/app/src/main/java/com/anod/appwatcher/search/SearchComposeActivity.kt b/app/src/main/java/com/anod/appwatcher/search/SearchComposeActivity.kt deleted file mode 100644 index 8930be33..00000000 --- a/app/src/main/java/com/anod/appwatcher/search/SearchComposeActivity.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.anod.appwatcher.search - -import android.content.Intent -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.lifecycle.lifecycleScope -import com.anod.appwatcher.accounts.AccountSelectionDialog -import com.anod.appwatcher.accounts.AccountSelectionResult -import com.anod.appwatcher.compose.AppTheme -import com.anod.appwatcher.compose.BaseComposeActivity -import com.anod.appwatcher.compose.MainDetailScreen -import com.anod.appwatcher.details.DetailsDialog -import com.anod.appwatcher.watchlist.DetailContent -import info.anodsplace.framework.app.FoldableDeviceLayout -import info.anodsplace.framework.content.onCommonActivityAction -import kotlinx.coroutines.launch - -open class SearchComposeActivity : BaseComposeActivity() { - val viewModel: SearchViewModel by viewModels(factoryProducer = { - SearchViewModel.Factory( - initialState = intentToState(intent, foldableDevice.layout.value), - ) - }) - - private lateinit var accountSelectionDialog: AccountSelectionDialog - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - accountSelectionDialog = AccountSelectionDialog(this, viewModel.prefs) - - setContent { - AppTheme( - theme = viewModel.prefs.theme - ) { - val screenState by viewModel.viewStates.collectAsState(initial = viewModel.viewState) - if (screenState.wideLayout.isWideLayout) { - MainDetailScreen( - wideLayout = screenState.wideLayout, - main = { - SearchResultsScreen( - screenState = screenState, - onEvent = viewModel::handleEvent, - pagingDataFlow = { viewModel.pagingData }, - viewActions = viewModel.viewActions, - onActivityAction = { onCommonActivityAction(it) }, - onShowAccountDialog = { accountSelectionDialog.show() } - ) - }, - detail = { - DetailContent( - app = screenState.selectedApp, - onDismissRequest = { viewModel.handleEvent(SearchViewEvent.SelectApp(app = null)) }, - onCommonActivityAction = { onCommonActivityAction(it) } - ) - } - ) - } else { - SearchResultsScreen( - screenState = screenState, - onEvent = viewModel::handleEvent, - pagingDataFlow = { viewModel.pagingData }, - viewActions = viewModel.viewActions, - onActivityAction = { onCommonActivityAction(it) }, - onShowAccountDialog = { accountSelectionDialog.show() } - ) - if (screenState.selectedApp != null) { - DetailsDialog( - app = screenState.selectedApp!!, - onDismissRequest = { viewModel.handleEvent(SearchViewEvent.SelectApp(app = null)) }, - onCommonActivityAction = { onCommonActivityAction(it) } - ) - } - } - } - } - - lifecycleScope.launch { - accountSelectionDialog.accountSelected.collect { result -> - when (result) { - AccountSelectionResult.Canceled -> viewModel.handleEvent(SearchViewEvent.AccountSelectError(errorMessage = "")) - is AccountSelectionResult.Error -> viewModel.handleEvent(SearchViewEvent.AccountSelectError(errorMessage = result.errorMessage)) - is AccountSelectionResult.Success -> viewModel.handleEvent(SearchViewEvent.AccountSelected(account = result.account)) - } - } - } - - lifecycleScope.launch { - foldableDevice.layout.collect { - viewModel.handleEvent(SearchViewEvent.SetWideLayout(it)) - } - } - } - - private fun intentToState(intent: Intent?, wideLayout: FoldableDeviceLayout) = SearchViewState( - wideLayout = wideLayout, - searchQuery = intent?.getStringExtra(EXTRA_KEYWORD) ?: "", - isPackageSearch = intent?.getBooleanExtra(EXTRA_PACKAGE, false) ?: false, - initiateSearch = intent?.getBooleanExtra(EXTRA_EXACT, false) ?: false, - isShareSource = intent?.getBooleanExtra(EXTRA_SHARE, false) ?: false, - hasFocus = intent?.getBooleanExtra(EXTRA_FOCUS, false) ?: false - ) - - companion object { - const val EXTRA_KEYWORD = "keyword" - const val EXTRA_EXACT = "exact" - const val EXTRA_SHARE = "share" - const val EXTRA_FOCUS = "focus" - const val EXTRA_PACKAGE = "package" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/search/SearchResultsScreen.kt b/app/src/main/java/com/anod/appwatcher/search/SearchResultsScreen.kt index 6b3dcf5f..43a3999a 100644 --- a/app/src/main/java/com/anod/appwatcher/search/SearchResultsScreen.kt +++ b/app/src/main/java/com/anod/appwatcher/search/SearchResultsScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -33,6 +34,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.paging.LoadState import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems @@ -43,6 +45,7 @@ import com.anod.appwatcher.R import com.anod.appwatcher.compose.AppTheme import com.anod.appwatcher.compose.SearchTopBar import com.anod.appwatcher.database.entities.App +import com.anod.appwatcher.navigation.SceneNavKey import com.anod.appwatcher.tags.TagSelectionDialog import com.anod.appwatcher.tags.TagSnackbar import com.anod.appwatcher.utils.AppIconLoader @@ -51,20 +54,44 @@ import finsky.api.Document import finsky.protos.AppDetails import finsky.protos.DocDetails import finsky.protos.DocV2 -import info.anodsplace.framework.content.CommonActivityAction +import info.anodsplace.framework.app.FoldableDeviceLayout import info.anodsplace.framework.content.InstalledApps +import info.anodsplace.framework.content.startActivity import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import org.koin.java.KoinJavaComponent +fun SceneNavKey.Search.toViewState(wideLayout: FoldableDeviceLayout) = SearchViewState( + wideLayout = wideLayout, + searchQuery = this.keyword, + initiateSearch = this.initiateSearch, + isPackageSearch = this.isPackageSearch, + isShareSource = this.isShareSource, + hasFocus = this.focus +) + +@Composable +fun SearchResultsScreenScene(initialState: SearchViewState, navigateBack: () -> Unit = {}) { + val viewModel: SearchViewModel = viewModel(factory = SearchViewModel.Factory(initialState)) + val screenState by viewModel.viewStates.collectAsState(initial = viewModel.viewState) + SearchResultsScreen( + screenState = screenState, + pagingDataFlow = { viewModel.pagingData }, + onEvent = viewModel::handleEvent, + viewActions = viewModel.viewActions, + onShowAccountDialog = { /* accountSelectionDialog.show() */ }, + navigateBack = navigateBack + ) +} + @Composable fun SearchResultsScreen( screenState: SearchViewState, pagingDataFlow: () -> Flow>, onEvent: (SearchViewEvent) -> Unit, viewActions: Flow, - onActivityAction: (CommonActivityAction) -> Unit = { }, onShowAccountDialog: () -> Unit = { }, + navigateBack: () -> Unit = {}, appIconLoader: AppIconLoader = KoinJavaComponent.getKoin().get(), ) { val context = LocalContext.current @@ -127,7 +154,7 @@ fun SearchResultsScreen( var showTagList: Pair? by remember { mutableStateOf(null) } var deleteNoticeDocument: Document? by remember { mutableStateOf(null) } - LaunchedEffect(key1 = viewActions, key2 = onShowAccountDialog, key3 = onActivityAction) { + LaunchedEffect(key1 = true, key2 = onShowAccountDialog) { viewActions.collect { action -> when (action) { SearchViewAction.ShowAccountDialog -> onShowAccountDialog() @@ -136,36 +163,37 @@ fun SearchResultsScreen( message = action.message, duration = action.duration ) - if (action.finish) { - onActivityAction(CommonActivityAction.Finish) + if (action.exitScreen) { + navigateBack() } } is SearchViewAction.ShowTagSnackbar -> { - val finishActivity = action.isShareSource + val exitScreen = action.isShareSource val result = snackbarHostState.showSnackbar(TagSnackbar.Visuals(action.info, context)) if (result == SnackbarResult.ActionPerformed) { - showTagList = Pair(action.info, finishActivity) - } else if (finishActivity) { - onActivityAction(CommonActivityAction.Finish) + showTagList = Pair(action.info, exitScreen) + } else if (exitScreen) { + navigateBack() } } is SearchViewAction.AlreadyWatchedNotice -> { deleteNoticeDocument = action.document } - is SearchViewAction.ActivityAction -> onActivityAction(action.action) + SearchViewAction.NavigateBack -> navigateBack() + is SearchViewAction.StartActivity -> context.startActivity(action) } } } if (showTagList != null) { - val (appInfo, finishActivity) = showTagList!! + val (appInfo, exitScreen) = showTagList!! TagSelectionDialog( appId = appInfo.appId, appTitle = appInfo.title, onDismissRequest = { showTagList = null - if (finishActivity) { - onActivityAction(CommonActivityAction.Finish) + if (exitScreen) { + navigateBack() } } ) @@ -262,8 +290,8 @@ private fun LoadingStatePreview() { screenState = SearchViewState(searchStatus = SearchStatus.Loading), pagingDataFlow = { flowOf() }, onEvent = { }, + viewActions = flowOf(), appIconLoader = appIconLoader, - viewActions = flowOf() ) } } @@ -280,8 +308,8 @@ private fun EmptyStatePreview() { screenState = SearchViewState(searchStatus = SearchStatus.NoResults(query = "")), pagingDataFlow = { flowOf() }, onEvent = { }, + viewActions = flowOf(), appIconLoader = appIconLoader, - viewActions = flowOf() ) } } @@ -298,8 +326,8 @@ private fun RetryStatePreview() { screenState = SearchViewState(searchStatus = SearchStatus.Error("")), pagingDataFlow = { flowOf() }, onEvent = { }, + viewActions = flowOf(), appIconLoader = appIconLoader, - viewActions = flowOf() ) } } @@ -340,8 +368,8 @@ private fun SearchSingleResultPreview() { ), pagingDataFlow = { flowOf() }, onEvent = { }, + viewActions = flowOf(), appIconLoader = appIconLoader, - viewActions = flowOf() ) } } \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/search/SearchViewModel.kt b/app/src/main/java/com/anod/appwatcher/search/SearchViewModel.kt index 883c5b89..bf50055e 100644 --- a/app/src/main/java/com/anod/appwatcher/search/SearchViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/search/SearchViewModel.kt @@ -34,8 +34,8 @@ import finsky.api.DfeApi import finsky.api.Document import finsky.api.toDocument import info.anodsplace.framework.app.FoldableDeviceLayout -import info.anodsplace.framework.content.CommonActivityAction import info.anodsplace.framework.content.InstalledApps +import info.anodsplace.framework.content.StartActivityAction import info.anodsplace.playstore.AppDetailsFilter import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @@ -57,19 +57,17 @@ sealed interface SearchStatus { } sealed interface SearchViewAction { - class ActivityAction(val action: CommonActivityAction) : SearchViewAction - class ShowSnackbar(val message: String, val duration: SnackbarDuration = SnackbarDuration.Short, val finish: Boolean = false) : SearchViewAction + class StartActivity(override val intent: Intent) : SearchViewAction, StartActivityAction + class ShowSnackbar(val message: String, val duration: SnackbarDuration = SnackbarDuration.Short, val exitScreen: Boolean = false) : SearchViewAction data object ShowAccountDialog : SearchViewAction class ShowTagSnackbar(val info: App, val isShareSource: Boolean) : SearchViewAction class AlreadyWatchedNotice(val document: Document) : SearchViewAction + data object NavigateBack: SearchViewAction } -private fun startActivityAction(intent: Intent, finish: Boolean = false): SearchViewAction.ActivityAction { - return SearchViewAction.ActivityAction( - action = CommonActivityAction.StartActivity( - intent = intent, - finish = finish - ) +private fun startActivityAction(intent: Intent): SearchViewAction { + return SearchViewAction.StartActivity( + intent = intent, ) } @@ -146,26 +144,22 @@ class SearchViewModel( is SearchViewEvent.OnSearchEnter -> onSearchRequest(event.query) is SearchViewEvent.AccountSelectError -> onAccountSelectError(event.errorMessage) is SearchViewEvent.AccountSelected -> onAccountSelected(event.account) - SearchViewEvent.OnBackPressed -> onBackPressed() + SearchViewEvent.OnBackPressed -> emitAction(SearchViewAction.NavigateBack) is SearchViewEvent.SelectApp -> { viewState = viewState.copy(selectedApp = event.app) } } } - private fun onBackPressed() { - emitAction(SearchViewAction.ActivityAction(CommonActivityAction.Finish)) - } - private fun onAccountSelectError(errorMessage: String) { if (networkConnection.isNetworkAvailable) { if (errorMessage.isNotBlank()) { - emitAction(SearchViewAction.ShowSnackbar(message = errorMessage, duration = SnackbarDuration.Short, finish = true)) + emitAction(SearchViewAction.ShowSnackbar(message = errorMessage, duration = SnackbarDuration.Short, exitScreen = true)) } else { - emitAction(SearchViewAction.ShowSnackbar(message = context.getString(R.string.failed_gain_access), duration = SnackbarDuration.Long, finish = true)) + emitAction(SearchViewAction.ShowSnackbar(message = context.getString(R.string.failed_gain_access), duration = SnackbarDuration.Long, exitScreen = true)) } } else { - emitAction(SearchViewAction.ShowSnackbar(message = context.getString(R.string.check_connection), duration = SnackbarDuration.Short, finish = true)) + emitAction(SearchViewAction.ShowSnackbar(message = context.getString(R.string.check_connection), duration = SnackbarDuration.Short, exitScreen = true)) } } @@ -180,7 +174,7 @@ class SearchViewModel( SearchViewAction.ShowSnackbar( message = context.getString(R.string.check_connection), duration = SnackbarDuration.Short, - finish = false + exitScreen = false ) ) } else if (searchStatus is SearchStatus.Error) { @@ -188,7 +182,7 @@ class SearchViewModel( SearchViewAction.ShowSnackbar( message = context.getString(R.string.error_fetching_info), duration = SnackbarDuration.Short, - finish = false + exitScreen = false ) ) } @@ -289,12 +283,12 @@ class SearchViewModel( try { accountInitializer.initialize(account) } catch (e: AuthTokenStartIntent) { - emitAction(startActivityAction(intent = e.intent, finish = true)) + emitAction(startActivityAction(intent = e.intent)) // TODO: finish = true } catch (e: Exception) { if (networkConnection.isNetworkAvailable) { - emitAction(SearchViewAction.ShowSnackbar(message = context.getString(R.string.failed_gain_access), duration = SnackbarDuration.Long, finish = true)) + emitAction(SearchViewAction.ShowSnackbar(message = context.getString(R.string.failed_gain_access), duration = SnackbarDuration.Long, exitScreen = true)) } else { - emitAction(SearchViewAction.ShowSnackbar(message = context.getString(R.string.check_connection), duration = SnackbarDuration.Short, finish = true)) + emitAction(SearchViewAction.ShowSnackbar(message = context.getString(R.string.check_connection), duration = SnackbarDuration.Short, exitScreen = true)) } } } diff --git a/app/src/main/java/com/anod/appwatcher/sync/SchedulesHistoryActivity.kt b/app/src/main/java/com/anod/appwatcher/sync/SchedulesHistoryScreen.kt similarity index 82% rename from app/src/main/java/com/anod/appwatcher/sync/SchedulesHistoryActivity.kt rename to app/src/main/java/com/anod/appwatcher/sync/SchedulesHistoryScreen.kt index c8f0a700..6a27943a 100644 --- a/app/src/main/java/com/anod/appwatcher/sync/SchedulesHistoryActivity.kt +++ b/app/src/main/java/com/anod/appwatcher/sync/SchedulesHistoryScreen.kt @@ -1,8 +1,6 @@ // Copyright (c) 2020. Alex Gavrishev package com.anod.appwatcher.sync -import android.os.Bundle -import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -17,66 +15,56 @@ import androidx.compose.material3.SuggestionChipDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import com.anod.appwatcher.R import com.anod.appwatcher.compose.AppTheme import com.anod.appwatcher.compose.BackArrowIconButton -import com.anod.appwatcher.compose.BaseComposeActivity -import com.anod.appwatcher.database.AppsDatabase import com.anod.appwatcher.database.entities.Failed import com.anod.appwatcher.database.entities.New import com.anod.appwatcher.database.entities.Schedule import com.anod.appwatcher.database.entities.Skipped import com.anod.appwatcher.database.entities.Success import com.anod.appwatcher.utils.isLightColor -import info.anodsplace.framework.content.CommonActivityAction -import info.anodsplace.framework.content.onCommonActivityAction +import info.anodsplace.framework.content.onScreenCommonAction +import kotlinx.collections.immutable.ImmutableList import java.text.DateFormat -import java.text.SimpleDateFormat import java.util.Date -import java.util.Locale -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toPersistentList -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -/** - * @author Alex Gavrishev - * @date 04/01/2018 - */ -class SchedulesHistoryActivity : BaseComposeActivity(), KoinComponent { - private val database: AppsDatabase by inject() - private val dateFormat = SimpleDateFormat("MMM d, HH:mm:ss", Locale.getDefault()) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - val schedules by database.schedules().load().collectAsState(initial = emptyList()) - SchedulesHistoryScreen( - schedules = schedules.toPersistentList(), - dateFormat = dateFormat, - onActivityAction = { onCommonActivityAction(it) } - ) +@Composable +fun SchedulesHistoryScreenScene(navigateBack: () -> Unit) { + val viewModel: SchedulesHistoryViewModel = viewModel() + val viewState by viewModel.viewStates.collectAsState() + SchedulesHistoryScreen( + schedules = viewState.schedules, + dateFormat = viewState.dateFormat, + navigateBack = navigateBack + ) + val context = LocalContext.current + LaunchedEffect(true) { + viewModel.viewActions.collect { action -> + context.onScreenCommonAction(action, navigateBack) } } } @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SchedulesHistoryScreen(schedules: ImmutableList, dateFormat: DateFormat, onActivityAction: (CommonActivityAction) -> Unit) { +fun SchedulesHistoryScreen(schedules: ImmutableList, dateFormat: DateFormat, navigateBack: () -> Unit) { AppTheme { Surface { Column(modifier = Modifier.fillMaxWidth()) { CenterAlignedTopAppBar( title = { Text(text = stringResource(id = R.string.refresh_history)) }, - navigationIcon = { BackArrowIconButton(onClick = { onActivityAction(CommonActivityAction.Finish) }) }, + navigationIcon = { BackArrowIconButton(onClick = { navigateBack() }) }, ) LazyColumn { items(schedules.size) { index -> diff --git a/app/src/main/java/com/anod/appwatcher/sync/SchedulesHistoryViewModel.kt b/app/src/main/java/com/anod/appwatcher/sync/SchedulesHistoryViewModel.kt new file mode 100644 index 00000000..5edf42d3 --- /dev/null +++ b/app/src/main/java/com/anod/appwatcher/sync/SchedulesHistoryViewModel.kt @@ -0,0 +1,46 @@ +package com.anod.appwatcher.sync + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.viewModelScope +import com.anod.appwatcher.database.AppsDatabase +import com.anod.appwatcher.database.entities.Schedule +import com.anod.appwatcher.utils.BaseFlowViewModel +import info.anodsplace.framework.content.ScreenCommonAction +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Locale + +@Immutable +data class SchedulesHistoryState( + val schedules: ImmutableList = persistentListOf(), + val dateFormat: DateFormat = SimpleDateFormat("MMM d, HH:mm:ss", Locale.getDefault()) +) + +sealed interface SchedulesHistoryStateEvent { + data object OnBackNav : SchedulesHistoryStateEvent +} + +class SchedulesHistoryViewModel : BaseFlowViewModel(), KoinComponent { + private val database: AppsDatabase by inject() + + init { + viewState = SchedulesHistoryState() + viewModelScope.launch { + database.schedules().load().collect { schedules -> + viewState = viewState.copy(schedules = schedules.toPersistentList()) + } + } + } + + override fun handleEvent(event: SchedulesHistoryStateEvent) { + when (event) { + SchedulesHistoryStateEvent.OnBackNav -> emitAction(ScreenCommonAction.NavigateBack) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/sync/SyncNotification.kt b/app/src/main/java/com/anod/appwatcher/sync/SyncNotification.kt index 9f1841be..29003a60 100644 --- a/app/src/main/java/com/anod/appwatcher/sync/SyncNotification.kt +++ b/app/src/main/java/com/anod/appwatcher/sync/SyncNotification.kt @@ -6,12 +6,12 @@ import android.app.PendingIntent import android.content.Intent import android.net.Uri import androidx.core.app.NotificationCompat +import androidx.core.net.toUri import com.anod.appwatcher.AppWatcherActivity import com.anod.appwatcher.NotificationActivity import com.anod.appwatcher.R import com.anod.appwatcher.preferences.Preferences import com.anod.appwatcher.utils.color.DynamicColors -import com.anod.appwatcher.watchlist.MainActivity import info.anodsplace.context.ApplicationContext import info.anodsplace.framework.text.Html @@ -92,9 +92,9 @@ class SyncNotification(private val context: ApplicationContext, private val noti private fun create(updatedApps: List): Notification { val notificationIntent = Intent(context.actual, AppWatcherActivity::class.java) notificationIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP - val data = Uri.parse("com.anod.appwatcher://notification") + val data = "com.anod.appwatcher://notification".toUri() notificationIntent.data = data - notificationIntent.putExtra(MainActivity.EXTRA_FROM_NOTIFICATION, true) + notificationIntent.putExtra(AppWatcherActivity.EXTRA_FROM_NOTIFICATION, true) val contentIntent = PendingIntent.getActivity(context.actual, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE) val title = renderTitle(updatedApps) diff --git a/app/src/main/java/com/anod/appwatcher/tags/AppsTagDialog.kt b/app/src/main/java/com/anod/appwatcher/tags/AppsTagDialog.kt index 0b100529..7b4b6ff8 100644 --- a/app/src/main/java/com/anod/appwatcher/tags/AppsTagDialog.kt +++ b/app/src/main/java/com/anod/appwatcher/tags/AppsTagDialog.kt @@ -31,7 +31,7 @@ import com.anod.appwatcher.R import com.anod.appwatcher.compose.AppIconImage import com.anod.appwatcher.compose.AppTheme import com.anod.appwatcher.compose.SearchTopBar -import com.anod.appwatcher.compose.rememberViwModeStoreOwner +import com.anod.appwatcher.compose.rememberViewModeStoreOwner import com.anod.appwatcher.database.entities.App import com.anod.appwatcher.database.entities.Price import com.anod.appwatcher.database.entities.Tag @@ -42,7 +42,7 @@ import org.koin.java.KoinJavaComponent.getKoin @Composable fun AppsTagDialog(tag: Tag, onDismissRequest: () -> Unit) { - val storeOwner = rememberViwModeStoreOwner() + val storeOwner = rememberViewModeStoreOwner() val viewModel: AppsTagViewModel = viewModel( viewModelStoreOwner = storeOwner, factory = AppsTagViewModel.Factory(tag) diff --git a/app/src/main/java/com/anod/appwatcher/tags/EditTagDialog.kt b/app/src/main/java/com/anod/appwatcher/tags/EditTagDialog.kt index 660fdd70..53d12ec4 100644 --- a/app/src/main/java/com/anod/appwatcher/tags/EditTagDialog.kt +++ b/app/src/main/java/com/anod/appwatcher/tags/EditTagDialog.kt @@ -38,7 +38,7 @@ import androidx.compose.ui.window.Dialog import androidx.lifecycle.viewmodel.compose.viewModel import com.anod.appwatcher.R import com.anod.appwatcher.compose.AppTheme -import com.anod.appwatcher.compose.rememberViwModeStoreOwner +import com.anod.appwatcher.compose.rememberViewModeStoreOwner import com.anod.appwatcher.database.entities.Tag import info.anodsplace.compose.BottomSheet import info.anodsplace.compose.ButtonsPanel @@ -46,7 +46,7 @@ import info.anodsplace.compose.ColorDialogContent @Composable fun EditTagDialog(tag: Tag, onDismissRequest: (tagId: Int) -> Unit) { - val storeOwner = rememberViwModeStoreOwner() + val storeOwner = rememberViewModeStoreOwner() val viewModel: EditTagViewModel = viewModel( key = "tag-${tag.id}", viewModelStoreOwner = storeOwner, diff --git a/app/src/main/java/com/anod/appwatcher/tags/EditTagViewModel.kt b/app/src/main/java/com/anod/appwatcher/tags/EditTagViewModel.kt index 35647606..394112d1 100644 --- a/app/src/main/java/com/anod/appwatcher/tags/EditTagViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/tags/EditTagViewModel.kt @@ -20,7 +20,7 @@ data class EditTagState( ) sealed interface EditTagEvent { - class UpdateColor(@ColorInt val color: Int) : EditTagEvent + class UpdateColor(@param:ColorInt val color: Int) : EditTagEvent class SaveAndDismiss(val name: String) : EditTagEvent object Delete : EditTagEvent object Dismiss : EditTagEvent diff --git a/app/src/main/java/com/anod/appwatcher/tags/TagSelectionDialog.kt b/app/src/main/java/com/anod/appwatcher/tags/TagSelectionDialog.kt index 4e9963e7..82fe94dd 100644 --- a/app/src/main/java/com/anod/appwatcher/tags/TagSelectionDialog.kt +++ b/app/src/main/java/com/anod/appwatcher/tags/TagSelectionDialog.kt @@ -9,8 +9,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Label import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.Label import androidx.compose.material3.Button import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api @@ -34,14 +32,14 @@ import androidx.compose.ui.window.Dialog import androidx.lifecycle.viewmodel.compose.viewModel import com.anod.appwatcher.R import com.anod.appwatcher.compose.AppTheme -import com.anod.appwatcher.compose.rememberViwModeStoreOwner +import com.anod.appwatcher.compose.rememberViewModeStoreOwner import com.anod.appwatcher.database.entities.Tag import info.anodsplace.compose.CheckBoxItem import info.anodsplace.compose.CheckBoxLazyList @Composable fun TagSelectionDialog(appId: String, appTitle: String, onDismissRequest: () -> Unit) { - val storeOwner = rememberViwModeStoreOwner() + val storeOwner = rememberViewModeStoreOwner() val viewModel: TagsSelectionViewModel = viewModel( key = appId, viewModelStoreOwner = storeOwner, diff --git a/app/src/main/java/com/anod/appwatcher/tags/TagWatchListComposeActivity.kt b/app/src/main/java/com/anod/appwatcher/tags/TagWatchListComposeActivity.kt deleted file mode 100644 index db57e9fa..00000000 --- a/app/src/main/java/com/anod/appwatcher/tags/TagWatchListComposeActivity.kt +++ /dev/null @@ -1,143 +0,0 @@ -package com.anod.appwatcher.tags - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.Color -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.anod.appwatcher.compose.AppTheme -import com.anod.appwatcher.compose.BaseComposeActivity -import com.anod.appwatcher.compose.MainDetailScreen -import com.anod.appwatcher.database.entities.Tag -import com.anod.appwatcher.details.DetailsDialog -import com.anod.appwatcher.model.Filters -import com.anod.appwatcher.utils.prefs -import com.anod.appwatcher.watchlist.DetailContent -import com.anod.appwatcher.watchlist.WatchListEvent -import com.anod.appwatcher.watchlist.WatchListPagingSource -import com.anod.appwatcher.watchlist.WatchListStateViewModel -import info.anodsplace.framework.app.addMultiWindowFlags -import info.anodsplace.framework.content.onCommonActivityAction -import kotlinx.coroutines.launch - -class TagWatchListComposeActivity : BaseComposeActivity() { - private val viewModel: WatchListStateViewModel by viewModels(factoryProducer = { - WatchListStateViewModel.Factory( - defaultFilterId = Filters.ALL, - wideLayout = foldableDevice.layout.value, - collectRecentlyInstalledApps = false - ) - }) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - val screenState by viewModel.viewStates.collectAsState(initial = viewModel.viewState) - val customPrimaryColor by remember(screenState) { - derivedStateOf { Color(screenState.tag.color) } - } - AppTheme( - customPrimaryColor = customPrimaryColor, - theme = viewModel.prefs.theme - ) { - val pagingSourceConfig = WatchListPagingSource.Config( - filterId = screenState.filterId, - tagId = screenState.tag.id, - showRecentlyDiscovered = viewModel.prefs.showRecentlyDiscovered, - showOnDevice = false, - showRecentlyInstalled = false - ) - - if (screenState.wideLayout.isWideLayout) { - MainDetailScreen( - wideLayout = screenState.wideLayout, - main = { - TagWatchListScreen( - screenState = screenState, - pagingSourceConfig = pagingSourceConfig, - onEvent = viewModel::handleEvent, - installedApps = viewModel.installedApps - ) - }, - detail = { - DetailContent( - app = screenState.selectedApp, - onDismissRequest = { viewModel.handleEvent(WatchListEvent.SelectApp(app = null)) }, - onCommonActivityAction = { onCommonActivityAction(it) } - ) - } - ) - } else { - TagWatchListScreen( - screenState = screenState, - pagingSourceConfig = pagingSourceConfig, - onEvent = viewModel::handleEvent, - installedApps = viewModel.installedApps - ) - if (screenState.selectedApp != null) { - DetailsDialog( - app = screenState.selectedApp!!, - onDismissRequest = { viewModel.handleEvent(WatchListEvent.SelectApp(app = null)) }, - onCommonActivityAction = { onCommonActivityAction(it) } - ) - } - } - - if (screenState.showAppTagDialog) { - AppsTagDialog( - tag = screenState.tag, - onDismissRequest = { viewModel.handleEvent(WatchListEvent.AddAppToTag(show = false)) } - ) - } - - if (screenState.showEditTagDialog) { - EditTagDialog( - tag = screenState.tag, - onDismissRequest = { viewModel.handleEvent(WatchListEvent.EditTag(show = false)) } - ) - } - } - } - - lifecycleScope.launch { - viewModel.viewActions.collect { onCommonActivityAction(it) } - } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.CREATED) { - foldableDevice.layout.collect { - viewModel.handleEvent(WatchListEvent.SetWideLayout(it)) - } - } - } - } - - @Deprecated("Deprecated in Java") - override fun onBackPressed() { - if (viewModel.viewState.wideLayout.isWideLayout) { - if (viewModel.viewState.selectedApp != null) { - viewModel.handleEvent(WatchListEvent.SelectApp(app = null)) - } else { - super.onBackPressed() - } - } else { - super.onBackPressed() - } - } - - companion object { - fun createTagIntent(tag: Tag, context: Context) = Intent(context, TagWatchListComposeActivity::class.java).apply { - putExtra(WatchListStateViewModel.EXTRA_TAG, tag) - addMultiWindowFlags(context) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/tags/TagWatchListScreen.kt b/app/src/main/java/com/anod/appwatcher/tags/TagWatchListScreen.kt index 09626d75..458d6971 100644 --- a/app/src/main/java/com/anod/appwatcher/tags/TagWatchListScreen.kt +++ b/app/src/main/java/com/anod/appwatcher/tags/TagWatchListScreen.kt @@ -4,19 +4,101 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.NavKey import com.anod.appwatcher.R +import com.anod.appwatcher.compose.AppTheme import com.anod.appwatcher.compose.EditIcon import com.anod.appwatcher.compose.FilterMenuItem import com.anod.appwatcher.compose.PinShortcutIcon import com.anod.appwatcher.compose.SortMenuItem import com.anod.appwatcher.compose.TagAppIconButton +import com.anod.appwatcher.database.entities.Tag +import com.anod.appwatcher.model.Filters +import com.anod.appwatcher.navigation.SceneNavKey +import com.anod.appwatcher.utils.prefs +import com.anod.appwatcher.watchlist.WatchListAction import com.anod.appwatcher.watchlist.WatchListEvent import com.anod.appwatcher.watchlist.WatchListPagingSource import com.anod.appwatcher.watchlist.WatchListScreen import com.anod.appwatcher.watchlist.WatchListSharedState +import com.anod.appwatcher.watchlist.WatchListStateViewModel import com.anod.appwatcher.watchlist.WatchListTopBar +import info.anodsplace.framework.app.FoldableDeviceLayout import info.anodsplace.framework.content.InstalledApps +import info.anodsplace.framework.content.showToast +import info.anodsplace.framework.content.startActivity + +@Composable +fun TagWatchListScreenScene(wideLayout: FoldableDeviceLayout, tag: Tag, navigateBack: () -> Unit, navigateTo: (NavKey) -> Unit) { + val viewModel: WatchListStateViewModel = viewModel(factory = + WatchListStateViewModel.Factory( + defaultFilterId = Filters.ALL, + wideLayout = wideLayout, + collectRecentlyInstalledApps = false, + initialTag = tag + ), + key = SceneNavKey.TagWatchList.toString() + ) + val screenState by viewModel.viewStates.collectAsState(initial = viewModel.viewState) + val context = LocalContext.current + val customPrimaryColor by remember(screenState) { + derivedStateOf { Color(screenState.tag.color) } + } + AppTheme( + customPrimaryColor = customPrimaryColor, + theme = viewModel.prefs.theme + ) { + val pagingSourceConfig = WatchListPagingSource.Config( + filterId = screenState.filterId, + tagId = screenState.tag.id, + showRecentlyDiscovered = viewModel.prefs.showRecentlyDiscovered, + showOnDevice = false, + showRecentlyInstalled = false + ) + + TagWatchListScreen( + screenState = screenState, + pagingSourceConfig = pagingSourceConfig, + onEvent = viewModel::handleEvent, + installedApps = viewModel.installedApps + ) + + if (screenState.showAppTagDialog) { + AppsTagDialog( + tag = screenState.tag, + onDismissRequest = { viewModel.handleEvent(WatchListEvent.AddAppToTag(show = false)) } + ) + } + + if (screenState.showEditTagDialog) { + EditTagDialog( + tag = screenState.tag, + onDismissRequest = { viewModel.handleEvent(WatchListEvent.EditTag(show = false)) } + ) + } + } + + LaunchedEffect(true) { + viewModel.viewActions.collect { + when (it) { + is WatchListAction.SelectApp -> {} + is WatchListAction.ShowToast -> context.showToast(it) + is WatchListAction.StartActivity -> context.startActivity(it) + WatchListAction.NavigateBack -> navigateBack() + is WatchListAction.NavigateTo -> navigateTo(it.navKey) + } + } + } +} @Composable fun TagWatchListScreen( diff --git a/app/src/main/java/com/anod/appwatcher/tags/TagsSelectionViewModel.kt b/app/src/main/java/com/anod/appwatcher/tags/TagsSelectionViewModel.kt index 265c1e55..a9b90139 100644 --- a/app/src/main/java/com/anod/appwatcher/tags/TagsSelectionViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/tags/TagsSelectionViewModel.kt @@ -2,7 +2,6 @@ package com.anod.appwatcher.tags import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Label -import androidx.compose.material.icons.filled.Label import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color import androidx.lifecycle.ViewModel @@ -14,7 +13,7 @@ import com.anod.appwatcher.database.AppsDatabase import com.anod.appwatcher.database.entities.Tag import com.anod.appwatcher.utils.BaseFlowViewModel import info.anodsplace.compose.CheckBoxItem -import info.anodsplace.framework.content.CommonActivityAction +import info.anodsplace.framework.content.ScreenCommonAction import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map @@ -39,7 +38,7 @@ sealed interface TagSelectionEvent { } @OptIn(ExperimentalCoroutinesApi::class) -class TagsSelectionViewModel(appId: String, appTitle: String) : BaseFlowViewModel(), KoinComponent { +class TagsSelectionViewModel(appId: String, appTitle: String) : BaseFlowViewModel(), KoinComponent { class Factory(private val appId: String, private val appTitle: String) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") diff --git a/app/src/main/java/com/anod/appwatcher/userLog/UserLogActivity.kt b/app/src/main/java/com/anod/appwatcher/userLog/UserLogActivity.kt deleted file mode 100644 index 349b9b7f..00000000 --- a/app/src/main/java/com/anod/appwatcher/userLog/UserLogActivity.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.anod.appwatcher.userLog - -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.lifecycle.lifecycleScope -import com.anod.appwatcher.compose.BaseComposeActivity -import info.anodsplace.framework.content.onCommonActivityAction -import kotlinx.coroutines.launch - -/** - * @author Alex Gavrishev - * @date 04/01/2018 - */ -class UserLogActivity : BaseComposeActivity() { - private val viewModel: UserLogViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - val screenState by viewModel.viewStates.collectAsState(initial = viewModel.viewState) - - UserLogScreen( - screenState = screenState, - onEvent = viewModel::handleEvent - ) - } - - lifecycleScope.launch { - viewModel.viewActions.collect { onCommonActivityAction(it) } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/userLog/UserLogScreen.kt b/app/src/main/java/com/anod/appwatcher/userLog/UserLogScreen.kt index b09b370f..396786f1 100644 --- a/app/src/main/java/com/anod/appwatcher/userLog/UserLogScreen.kt +++ b/app/src/main/java/com/anod/appwatcher/userLog/UserLogScreen.kt @@ -13,6 +13,9 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -21,15 +24,34 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import com.anod.appwatcher.R import com.anod.appwatcher.compose.AppTheme import com.anod.appwatcher.compose.BackArrowIconButton import com.anod.appwatcher.compose.ShareIconButton import com.anod.appwatcher.preferences.Preferences +import info.anodsplace.framework.content.onScreenCommonAction +import info.anodsplace.notification.NotificationManager import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList import org.koin.java.KoinJavaComponent +@Composable +fun UserLogScreenScene(navigateBack: () -> Unit) { + val viewModel: UserLogViewModel = viewModel() + val screenState by viewModel.viewStates.collectAsState(initial = viewModel.viewState) + UserLogScreen( + screenState = screenState, + onEvent = viewModel::handleEvent + ) + val context = LocalContext.current + LaunchedEffect(true) { + viewModel.viewActions.collect { action -> + context.onScreenCommonAction(action, navigateBack) + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun UserLogScreen(screenState: UserLogState, onEvent: (UserLogEvent) -> Unit, prefs: Preferences = KoinJavaComponent.getKoin().get()) { @@ -142,6 +164,6 @@ private fun UserLogScreenPreview() { """.trimIndent().split("\n").map { UserLogMessage.from(it) }.toPersistentList() ), onEvent = {}, - prefs = Preferences(context, info.anodsplace.notification.NotificationManager.NoOp(), scope) + prefs = Preferences(context, NotificationManager.NoOp(), scope) ) } \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/userLog/UserLogViewModel.kt b/app/src/main/java/com/anod/appwatcher/userLog/UserLogViewModel.kt index e939f358..514f73b4 100644 --- a/app/src/main/java/com/anod/appwatcher/userLog/UserLogViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/userLog/UserLogViewModel.kt @@ -3,7 +3,7 @@ package com.anod.appwatcher.userLog import android.content.Intent import androidx.compose.runtime.Immutable import com.anod.appwatcher.utils.BaseFlowViewModel -import info.anodsplace.framework.content.CommonActivityAction +import info.anodsplace.framework.content.ScreenCommonAction import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList @@ -18,7 +18,7 @@ sealed interface UserLogEvent { data object Share : UserLogEvent } -class UserLogViewModel : BaseFlowViewModel() { +class UserLogViewModel : BaseFlowViewModel() { private val userLogger = UserLogger() init { @@ -29,8 +29,8 @@ class UserLogViewModel : BaseFlowViewModel emitAction(CommonActivityAction.Finish) - UserLogEvent.Share -> emitAction(CommonActivityAction.StartActivity(intent = Intent().apply { + UserLogEvent.OnBackNav -> emitAction(ScreenCommonAction.NavigateBack) + UserLogEvent.Share -> emitAction(ScreenCommonAction.StartActivity(intent = Intent().apply { action = Intent.ACTION_SEND putExtra(Intent.EXTRA_TITLE, "AppWatcher Log") putExtra(Intent.EXTRA_TEXT, userLogger.content) diff --git a/app/src/main/java/com/anod/appwatcher/utils/AndroidVersions.kt b/app/src/main/java/com/anod/appwatcher/utils/AndroidVersions.kt index 8d99f065..e00b7d57 100644 --- a/app/src/main/java/com/anod/appwatcher/utils/AndroidVersions.kt +++ b/app/src/main/java/com/anod/appwatcher/utils/AndroidVersions.kt @@ -37,5 +37,6 @@ val androidVersions = mapOf( Build.VERSION_CODES.S_V2 to "12", Build.VERSION_CODES.TIRAMISU to "13", Build.VERSION_CODES.UPSIDE_DOWN_CAKE to "14", - 35 to "15" + Build.VERSION_CODES.VANILLA_ICE_CREAM to "15", + Build.VERSION_CODES.BAKLAVA to "16" ) \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/utils/StoreIntent.kt b/app/src/main/java/com/anod/appwatcher/utils/StoreIntent.kt index 034be7d4..288ef722 100644 --- a/app/src/main/java/com/anod/appwatcher/utils/StoreIntent.kt +++ b/app/src/main/java/com/anod/appwatcher/utils/StoreIntent.kt @@ -4,6 +4,7 @@ import android.content.ComponentName import android.content.Intent import android.net.Uri import android.os.Build +import androidx.core.net.toUri /** * @author Alex Gavrishev @@ -17,23 +18,16 @@ object StoreIntent { fun Intent.forPlayStore(pkg: String): Intent { val url = String.format(StoreIntent.URL_PLAY_STORE, pkg) this.action = Intent.ACTION_VIEW - this.data = Uri.parse(url) + this.data = url.toUri() return this } fun Intent.forMyApps(update: Boolean): Intent { action = "com.google.android.finsky.VIEW_MY_DOWNLOADS" - component = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - ComponentName( + component = ComponentName( "com.android.vending", "com.android.vending.AssetBrowserActivity" ) - } else { - ComponentName( - "com.android.vending", - "com.google.android.finsky.activities.MainActivity" - ) - } if (update) { this.putExtra("trigger_update_all", true) } diff --git a/app/src/main/java/com/anod/appwatcher/utils/color/ColorRoles.kt b/app/src/main/java/com/anod/appwatcher/utils/color/ColorRoles.kt index 82108780..a0b80df6 100644 --- a/app/src/main/java/com/anod/appwatcher/utils/color/ColorRoles.kt +++ b/app/src/main/java/com/anod/appwatcher/utils/color/ColorRoles.kt @@ -24,17 +24,17 @@ import androidx.annotation.ColorInt */ class ColorRoles( /** Returns the accent color, used as the main color from the color role. */ - @ColorInt val accent: Int, + @param:ColorInt val accent: Int, /** * Returns the on_accent color, used for content such as icons and text on top of the Accent * color. */ - @ColorInt val onAccent: Int, + @param:ColorInt val onAccent: Int, /** Returns the accent_container color, used with less emphasis than the accent color. */ - @ColorInt val accentContainer: Int, + @param:ColorInt val accentContainer: Int, /** * Returns the on_accent_container color, used for content such as icons and text on top of the * accent_container color. */ - @ColorInt val onAccentContainer: Int + @param:ColorInt val onAccentContainer: Int ) \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/watchlist/DetailContent.kt b/app/src/main/java/com/anod/appwatcher/watchlist/DetailContent.kt index 977729fc..ff089ff9 100644 --- a/app/src/main/java/com/anod/appwatcher/watchlist/DetailContent.kt +++ b/app/src/main/java/com/anod/appwatcher/watchlist/DetailContent.kt @@ -11,24 +11,27 @@ import androidx.compose.ui.res.painterResource import com.anod.appwatcher.R import com.anod.appwatcher.database.entities.App import com.anod.appwatcher.details.DetailsPanel -import info.anodsplace.framework.content.CommonActivityAction @Composable -fun DetailContent(app: App?, onDismissRequest: () -> Unit, onCommonActivityAction: (action: CommonActivityAction) -> Unit) { +fun DetailContent(app: App?, onDismissRequest: () -> Unit) { Surface { if (app == null) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Image(painter = painterResource(id = R.drawable.ic_empty_box_smile), contentDescription = null) - } + EmptyBoxSmile() } else { DetailsPanel( app = app, onDismissRequest = onDismissRequest, - onCommonActivityAction = onCommonActivityAction ) } } +} + +@Composable +fun EmptyBoxSmile() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Image(painter = painterResource(id = R.drawable.ic_empty_box_smile), contentDescription = null) + } } \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/watchlist/DrawerItem.kt b/app/src/main/java/com/anod/appwatcher/watchlist/DrawerItem.kt index 0b7e0589..2455fb40 100644 --- a/app/src/main/java/com/anod/appwatcher/watchlist/DrawerItem.kt +++ b/app/src/main/java/com/anod/appwatcher/watchlist/DrawerItem.kt @@ -16,7 +16,7 @@ import com.anod.appwatcher.R data class DrawerItem( val id: Id, val icon: ImageVector, - @StringRes val title: Int + @param:StringRes val title: Int ) { sealed interface Id { object Refresh : Id diff --git a/app/src/main/java/com/anod/appwatcher/watchlist/MainActivity.kt b/app/src/main/java/com/anod/appwatcher/watchlist/MainActivity.kt deleted file mode 100644 index 9eb4969d..00000000 --- a/app/src/main/java/com/anod/appwatcher/watchlist/MainActivity.kt +++ /dev/null @@ -1,239 +0,0 @@ -package com.anod.appwatcher.watchlist - -import android.content.Intent -import android.os.Bundle -import android.widget.Toast -import androidx.activity.OnBackPressedCallback -import androidx.activity.compose.setContent -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.viewModels -import androidx.compose.material3.DrawerValue -import androidx.compose.material3.rememberDrawerState -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.core.os.bundleOf -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.anod.appwatcher.BuildConfig -import com.anod.appwatcher.MarketSearchActivity -import com.anod.appwatcher.R -import com.anod.appwatcher.SettingsActivity -import com.anod.appwatcher.accounts.AccountSelectionDialog -import com.anod.appwatcher.accounts.toAndroidAccount -import com.anod.appwatcher.compose.AppTheme -import com.anod.appwatcher.compose.BaseComposeActivity -import com.anod.appwatcher.compose.MainDetailScreen -import com.anod.appwatcher.database.entities.Tag -import com.anod.appwatcher.details.DetailsDialog -import com.anod.appwatcher.history.HistoryListActivity -import com.anod.appwatcher.installed.InstalledActivity -import com.anod.appwatcher.tags.TagWatchListComposeActivity -import com.anod.appwatcher.utils.getIntentFlags -import com.anod.appwatcher.utils.prefs -import com.anod.appwatcher.wishlist.WishListActivity -import info.anodsplace.applog.AppLog -import info.anodsplace.framework.content.onCommonActivityAction -import info.anodsplace.permissions.AppPermission -import info.anodsplace.permissions.AppPermissions -import info.anodsplace.permissions.toRequestInput -import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent - -abstract class MainActivity : BaseComposeActivity(), KoinComponent { - private val mainViewModel: MainViewModel by viewModels() - private val listViewModel: WatchListStateViewModel by viewModels(factoryProducer = { - WatchListStateViewModel.Factory( - defaultFilterId = prefs.defaultMainFilterId, - wideLayout = foldableDevice.layout.value, - collectRecentlyInstalledApps = prefs.showRecent - ) - }) - private lateinit var accountSelectionDialog: AccountSelectionDialog - private lateinit var notificationPermissionRequest: ActivityResultLauncher - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val extras = intent?.extras ?: bundleOf() - if (BuildConfig.DEBUG) { - AppLog.d("Intent flags: ${getIntentFlags(intent?.flags ?: 0).joinToString()}") - } - if (extras.containsKey("open_recently_installed")) { - intent!!.extras!!.remove("open_recently_installed") - startActivity(InstalledActivity.intent(importMode = false, context = this)) - finish() - return - } - - if (extras.containsKey(WatchListStateViewModel.EXTRA_TAG_ID)) { - val extraTagId = extras.getInt(WatchListStateViewModel.EXTRA_TAG_ID) - intent!!.extras!!.remove(WatchListStateViewModel.EXTRA_TAG_ID) - startActivity(TagWatchListComposeActivity.createTagIntent( - tag = Tag( - id = extraTagId, - name = "", - color = extras.getInt(WatchListStateViewModel.EXTRA_TAG_COLOR) - ), - context = this - )) - // Do not finish and return to support back action - } - - accountSelectionDialog = AccountSelectionDialog(this, prefs) - notificationPermissionRequest = registerForActivityResult(AppPermissions.Request()) { - val enabled = it[AppPermission.PostNotification.value] ?: false - mainViewModel.handleEvent(MainViewEvent.NotificationPermissionResult(enabled = enabled)) - } - - onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (mainViewModel.viewState.isDrawerOpen) { - mainViewModel.handleEvent(MainViewEvent.DrawerState(isOpen = false)) - } else { - listViewModel.handleEvent(WatchListEvent.NavigationButton) - } - } - }) - - setContent { - AppTheme( - theme = prefs.theme, - transparentSystemUi = true - ) { - val mainState by mainViewModel.viewStates.collectAsState(initial = mainViewModel.viewState) - val listState by listViewModel.viewStates.collectAsState(initial = listViewModel.viewState) - - val pagingSourceConfig = WatchListPagingSource.Config( - filterId = listState.filterId, - tagId = null, - showRecentlyDiscovered = prefs.showRecentlyDiscovered, - showOnDevice = prefs.showOnDevice, - showRecentlyInstalled = prefs.showRecent - ) - - val drawerValue = if (mainState.isDrawerOpen) DrawerValue.Open else DrawerValue.Closed - val drawerState = rememberDrawerState(initialValue = drawerValue) - LaunchedEffect(true) { - repeatOnLifecycle(state = Lifecycle.State.RESUMED) { - mainViewModel.viewActions.collect { action -> - if (action is MainViewAction.DrawerState) { - if (action.isOpen) { - drawerState.open() - } else { - drawerState.close() - } - } else { - onMainAction(action) - } - } - } - } - - if (listState.wideLayout.isWideLayout) { - MainDetailScreen( - wideLayout = listState.wideLayout, - main = { - MainScreen( - mainState = mainState, - drawerState = drawerState, - onMainEvent = mainViewModel::handleEvent, - listState = listState, - pagingSourceConfig = pagingSourceConfig, - onListEvent = listViewModel::handleEvent, - installedApps = listViewModel.installedApps - ) - }, - detail = { - DetailContent( - app = listState.selectedApp, - onDismissRequest = { listViewModel.handleEvent(WatchListEvent.SelectApp(app = null)) }, - onCommonActivityAction = { onCommonActivityAction(it) } - ) - } - ) - } else { - MainScreen( - mainState = mainState, - drawerState = drawerState, - onMainEvent = mainViewModel::handleEvent, - listState = listState, - pagingSourceConfig = pagingSourceConfig, - onListEvent = listViewModel::handleEvent, - installedApps = listViewModel.installedApps - ) - if (listState.selectedApp != null) { - DetailsDialog( - app = listState.selectedApp!!, - onDismissRequest = { listViewModel.handleEvent(WatchListEvent.SelectApp(app = null)) }, - onCommonActivityAction = { onCommonActivityAction(it) } - ) - } - } - } - } - - lifecycleScope.launch { - listViewModel.viewActions.collect { onCommonActivityAction(it) } - } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.CREATED) { - foldableDevice.layout.collect { - listViewModel.handleEvent(WatchListEvent.SetWideLayout(it)) - } - } - } - - lifecycleScope.launch { - accountSelectionDialog.accountSelected.collect { result -> - mainViewModel.handleEvent(MainViewEvent.SetAccount(result)) - } - } - - val account = prefs.account?.toAndroidAccount() - if (account == null) { - Toast.makeText(this, R.string.failed_gain_access, Toast.LENGTH_LONG).show() - accountSelectionDialog.show() - } - } - - override fun onResume() { - super.onResume() - - mainViewModel.handleEvent(MainViewEvent.InitAccount) - AppLog.d("mark updates as viewed.") - prefs.isLastUpdatesViewed = true - } - - private fun onMainAction(action: MainViewAction) { - when (action) { - is MainViewAction.NavigateTo -> { - when (action.id) { - DrawerItem.Id.Add -> startActivity(Intent(this, MarketSearchActivity::class.java)) - DrawerItem.Id.Installed -> startActivity(InstalledActivity.intent(false, this)) - DrawerItem.Id.Refresh -> { } - DrawerItem.Id.Settings -> startActivity(Intent(this, SettingsActivity::class.java)) - DrawerItem.Id.Wishlist -> startActivity(WishListActivity.intent(this)) - DrawerItem.Id.Purchases -> startActivity(HistoryListActivity.intent(this)) - } - } - is MainViewAction.NavigateToTag -> startActivity(TagWatchListComposeActivity.createTagIntent(action.tag, this)) - MainViewAction.RequestNotificationPermission -> notificationPermissionRequest.launch(AppPermission.PostNotification.toRequestInput()) - MainViewAction.ChooseAccount -> accountSelectionDialog.show() - is MainViewAction.ActivityAction -> onCommonActivityAction(action.action) - is MainViewAction.DrawerState -> { } - } - } - - companion object { - const val EXTRA_FROM_NOTIFICATION = "extra_noti" - const val EXTRA_EXPAND_SEARCH = "expand_search" - - const val ARG_FILTER = "filter" - const val ARG_SORT = "sort" - const val ARG_TAG = "tag" - const val ARG_SHOW_ACTION = "showAction" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/watchlist/MainScreen.kt b/app/src/main/java/com/anod/appwatcher/watchlist/MainScreen.kt index 803d9893..e651d185 100644 --- a/app/src/main/java/com/anod/appwatcher/watchlist/MainScreen.kt +++ b/app/src/main/java/com/anod/appwatcher/watchlist/MainScreen.kt @@ -1,22 +1,153 @@ package com.anod.appwatcher.watchlist +import android.accounts.Account +import android.content.Context +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResultLauncher import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.Text +import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.NavKey import com.anod.appwatcher.R +import com.anod.appwatcher.accounts.AccountSelectionRequest +import com.anod.appwatcher.accounts.AccountSelectionResult import com.anod.appwatcher.compose.FilterMenuAction import com.anod.appwatcher.compose.OpenDrawerIcon import com.anod.appwatcher.compose.PlayStoreMyAppsIcon import com.anod.appwatcher.compose.RefreshIcon import com.anod.appwatcher.compose.SortMenuItem import com.anod.appwatcher.database.entities.Tag +import com.anod.appwatcher.navigation.SceneNavKey +import com.anod.appwatcher.preferences.Preferences import com.anod.appwatcher.tags.EditTagDialog +import info.anodsplace.framework.app.FoldableDeviceLayout import info.anodsplace.framework.content.InstalledApps +import info.anodsplace.framework.content.showToast +import info.anodsplace.framework.content.startActivity +import info.anodsplace.permissions.AppPermission +import info.anodsplace.permissions.AppPermissions +import info.anodsplace.permissions.toRequestInput +@Composable +fun MainScreenScene(prefs: Preferences, wideLayout: FoldableDeviceLayout, navigateBack: () -> Unit, navigateTo: (NavKey) -> Unit) { + val mainViewModel: MainViewModel = viewModel() + val listViewModel: WatchListStateViewModel = viewModel(factory = + WatchListStateViewModel.Factory( + defaultFilterId = prefs.defaultMainFilterId, + wideLayout = wideLayout, + collectRecentlyInstalledApps = prefs.showRecent, + initialTag = Tag.empty + ), + key = SceneNavKey.Main.toString() + ) + + val mainState by mainViewModel.viewStates.collectAsState(initial = mainViewModel.viewState) + val listState by listViewModel.viewStates.collectAsState(initial = listViewModel.viewState) + val drawerValue = if (mainState.isDrawerOpen) DrawerValue.Open else DrawerValue.Closed + val drawerState = rememberDrawerState(initialValue = drawerValue) + val context = LocalContext.current + + val notificationPermissionRequest = rememberLauncherForActivityResult(AppPermissions.Request()) { + val enabled = it[AppPermission.PostNotification.value] ?: false + mainViewModel.handleEvent(MainViewEvent.NotificationPermissionResult(enabled)) + } + + val accountSelectionRequest = rememberLauncherForActivityResult(AccountSelectionRequest()) { + mainViewModel.handleEvent(MainViewEvent.SetAccount(it)) + } + + LaunchedEffect(true) { + mainViewModel.viewActions.collect { action -> + if (action is MainViewAction.DrawerState) { + if (action.isOpen) { + drawerState.open() + } else { + drawerState.close() + } + } else { + onMainAction( + action = action, + context = context, + accountSelectionRequest = accountSelectionRequest, + notificationPermissionRequest = notificationPermissionRequest, + navigateTo = navigateTo + ) + } + } + } + LaunchedEffect(true) { + listViewModel.viewActions.collect { action -> + when (action) { + is WatchListAction.StartActivity -> context.startActivity(action) + is WatchListAction.ShowToast -> context.showToast(action) + is WatchListAction.SelectApp -> navigateTo(SceneNavKey.AppDetails(action.app)) + WatchListAction.NavigateBack -> navigateBack() + is WatchListAction.NavigateTo -> navigateTo(action.navKey) + } + } + } + val pagingSourceConfig = WatchListPagingSource.Config( + filterId = listState.filterId, + tagId = null, + showRecentlyDiscovered = prefs.showRecentlyDiscovered, + showOnDevice = prefs.showOnDevice, + showRecentlyInstalled = prefs.showRecent + ) + MainScreen( + mainState = mainState, + drawerState = drawerState, + onMainEvent = mainViewModel::handleEvent, + listState = listState, + pagingSourceConfig = pagingSourceConfig, + onListEvent = listViewModel::handleEvent, + installedApps = listViewModel.installedApps + ) + + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + mainViewModel.handleEvent(MainViewEvent.OnResume) + } +} + +private fun onMainAction( + action: MainViewAction, + context: Context, + accountSelectionRequest: ManagedActivityResultLauncher, + notificationPermissionRequest: ActivityResultLauncher, + navigateTo: (NavKey) -> Unit +) { + when (action) { + is MainViewAction.NavigateTo -> { + when (action.id) { + DrawerItem.Id.Add -> navigateTo(SceneNavKey.Search()) + DrawerItem.Id.Installed -> navigateTo(SceneNavKey.Installed(importMode = false)) + DrawerItem.Id.Refresh -> {} + DrawerItem.Id.Settings -> navigateTo(SceneNavKey.Settings) + DrawerItem.Id.Wishlist -> navigateTo(SceneNavKey.WishList) + DrawerItem.Id.Purchases -> navigateTo(SceneNavKey.PurchaseHistory) + } + } + is MainViewAction.NavigateToTag -> navigateTo(SceneNavKey.TagWatchList(tag = action.tag)) + MainViewAction.RequestNotificationPermission -> notificationPermissionRequest.launch(AppPermission.PostNotification.toRequestInput()) + is MainViewAction.ChooseAccount -> accountSelectionRequest.launch(action.currentAccount) + is MainViewAction.ShowToast -> context.showToast(action) + is MainViewAction.DrawerState -> { } + is MainViewAction.StartActivity -> context.startActivity(action) + } +} @Composable fun MainScreen( mainState: MainViewState, @@ -56,7 +187,7 @@ fun MainScreen( subtitle = subtitle, filterId = filterId, onListEvent = { - if (it is WatchListEvent.NavigationButton) { + if (it is WatchListEvent.OnBackPressed) { if (listState.showSearch) { onListEvent(WatchListEvent.ShowSearch(show = false)) } else { diff --git a/app/src/main/java/com/anod/appwatcher/watchlist/MainViewModel.kt b/app/src/main/java/com/anod/appwatcher/watchlist/MainViewModel.kt index 02f34a32..b104ff54 100644 --- a/app/src/main/java/com/anod/appwatcher/watchlist/MainViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/watchlist/MainViewModel.kt @@ -24,7 +24,8 @@ import com.anod.appwatcher.utils.networkConnection import com.anod.appwatcher.utils.prefs import com.google.firebase.crashlytics.FirebaseCrashlytics import info.anodsplace.applog.AppLog -import info.anodsplace.framework.content.CommonActivityAction +import info.anodsplace.framework.content.ShowToastActionDefaults +import info.anodsplace.framework.content.StartActivityAction import info.anodsplace.ktx.Hash import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -53,37 +54,29 @@ sealed interface MainViewEvent { class SetAccount(val result: AccountSelectionResult) : MainViewEvent class NavigateToTag(val tag: Tag) : MainViewEvent class NotificationPermissionResult(val enabled: Boolean) : MainViewEvent - data object InitAccount : MainViewEvent + data object OnResume : MainViewEvent class DrawerState(val isOpen: Boolean) : MainViewEvent } sealed interface MainViewAction { - class ActivityAction(val action: CommonActivityAction) : MainViewAction - data object ChooseAccount : MainViewAction + class StartActivity(override val intent: Intent) : MainViewAction, StartActivityAction + class ShowToast(@StringRes resId: Int = 0, text: String = "", length: Int = Toast.LENGTH_SHORT) : ShowToastActionDefaults(resId, text, length), MainViewAction + data class ChooseAccount(val currentAccount: Account?) : MainViewAction class NavigateTo(val id: DrawerItem.Id) : MainViewAction data object RequestNotificationPermission : MainViewAction class NavigateToTag(val tag: Tag) : MainViewAction class DrawerState(val isOpen: Boolean) : MainViewAction } -private fun startActivityAction(intent: Intent, addMultiWindowFlags: Boolean = false): MainViewAction.ActivityAction { - return MainViewAction.ActivityAction( - action = CommonActivityAction.StartActivity( - intent = intent, - addMultiWindowFlags = addMultiWindowFlags - ) - ) -} +private fun startActivityAction(intent: Intent): MainViewAction + = MainViewAction.StartActivity(intent) -private fun showToastAction(@StringRes resId: Int = 0, text: String = "", length: Int = Toast.LENGTH_SHORT): MainViewAction.ActivityAction { - return MainViewAction.ActivityAction( - action = CommonActivityAction.ShowToast( - resId = resId, - text = text, - length = length - ) +private fun showToastAction(@StringRes resId: Int = 0, text: String = "", length: Int = Toast.LENGTH_SHORT): MainViewAction + = MainViewAction.ShowToast( + resId = resId, + text = text, + length = length ) -} class MainViewModel : BaseFlowViewModel(), KoinComponent { private val database: AppsDatabase by inject() @@ -126,6 +119,11 @@ class MainViewModel : BaseFlowViewModel { when (event.result) { AccountSelectionResult.Canceled -> if (viewState.account == null) { - onAccountNotFound("") + showAccountErrorToast("") } - is AccountSelectionResult.Error -> onAccountNotFound(event.result.errorMessage) + + is AccountSelectionResult.Error -> showAccountErrorToast(event.result.errorMessage) is AccountSelectionResult.Success -> { onAccountSelect(event.result.account) } @@ -148,9 +147,9 @@ class MainViewModel : BaseFlowViewModel emitAction(MainViewAction.NavigateToTag(tag = event.tag)) - MainViewEvent.ChooseAccount -> emitAction(MainViewAction.ChooseAccount) + MainViewEvent.ChooseAccount -> emitAction(MainViewAction.ChooseAccount(prefs.account?.toAndroidAccount())) is MainViewEvent.NotificationPermissionResult -> onNotificationResult(event.enabled) - is MainViewEvent.InitAccount -> initAccount() + is MainViewEvent.OnResume -> onResume() is MainViewEvent.DrawerState -> { viewState = viewState.copy(isDrawerOpen = event.isOpen) emitAction(MainViewAction.DrawerState(isOpen = event.isOpen)) @@ -158,6 +157,12 @@ class MainViewModel : BaseFlowViewModel(), KoinComponent { +) : BaseFlowViewModel(), KoinComponent { private val authToken: AuthTokenBlocking by inject() private val application: Application by inject() private val db: AppsDatabase by inject() @@ -138,21 +145,18 @@ class WatchListStateViewModel( val installedApps = InstalledApps.MemoryCache(InstalledApps.PackageManager(packageManager)) - companion object { - const val EXTRA_TAG = "extra_tag" - const val EXTRA_TAG_ID = "tag_id" - const val EXTRA_TAG_COLOR = "tag_color" - } - class Factory( private val defaultFilterId: Int, private val wideLayout: FoldableDeviceLayout, - private val collectRecentlyInstalledApps: Boolean - ) : AbstractSavedStateViewModelFactory() { + private val collectRecentlyInstalledApps: Boolean, + private val initialTag: Tag + ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun create(key: String, modelClass: Class, handle: SavedStateHandle): T { + override fun create(modelClass: KClass, extras: CreationExtras): T { + val state = extras.createSavedStateHandle() return WatchListStateViewModel( - state = handle, + state = state, + tag = initialTag, defaultFilterId = defaultFilterId, wideLayout = wideLayout, collectRecentlyInstalledApps = collectRecentlyInstalledApps @@ -164,13 +168,6 @@ class WatchListStateViewModel( val expandSearch = state.remove("expand_search") ?: false val fromNotification = state.remove("extra_noti") ?: false val filterId = if (fromNotification || expandSearch) defaultFilterId else state.getInt("tab_id", defaultFilterId) - val extraTag: Tag? = state[EXTRA_TAG] - val tag: Tag = if (extraTag == null) { - val extraTagId: Int = state[EXTRA_TAG_ID] ?: 0 - if (extraTagId != 0) Tag(extraTagId, "", state[EXTRA_TAG_COLOR] ?: Tag.DEFAULT_COLOR) else Tag.empty - } else { - extraTag - } viewState = WatchListSharedState( tag = tag, sortId = prefs.sortIndex, @@ -189,13 +186,18 @@ class WatchListStateViewModel( } } + if (viewState.tag.isEmpty) { + AppLog.d("mark updates as viewed.") + prefs.isLastUpdatesViewed = true + } + if (!viewState.tag.isEmpty) { viewModelScope.launch { db.tags() .observeTag(viewState.tag.id) .collect { tag -> if (tag == null) { - emitAction(CommonActivityAction.Finish) + // TODO: emitAction(CommonActivityAction.Finish.toWatchListAction()) } else { viewState = viewState.copy( tag = tag, @@ -255,31 +257,15 @@ class WatchListStateViewModel( is WatchListEvent.FilterById -> viewState = viewState.copy(filterId = event.filterId) is WatchListEvent.EditTag -> viewState = viewState.copy(showEditTagDialog = event.show) is WatchListEvent.ShowSearch -> viewState = viewState.copy(showSearch = event.show) - is WatchListEvent.SelectApp -> viewState = viewState.copy(selectedApp = event.app) - - WatchListEvent.NavigationButton -> { - if (viewState.showSearch) { - viewState = viewState.copy(showSearch = false, titleFilter = "") - } else if (viewState.wideLayout.isWideLayout && viewState.selectedApp != null) { - viewState = viewState.copy(selectedApp = null) - } else { - emitAction(CommonActivityAction.Finish) - } - } + is WatchListEvent.SelectApp -> emitAction(WatchListAction.SelectApp(event.app)) + WatchListEvent.OnBackPressed -> emitAction(WatchListAction.NavigateBack) is WatchListEvent.SearchSubmit -> { val query = viewState.titleFilter viewState = viewState.copy(showSearch = false, titleFilter = "") - emitAction( - startActivityAction( - intent = MarketSearchActivity.intent( - application, - query, - true, - initiateSearch = true - ) - ) - ) + emitAction(WatchListAction.NavigateTo( + SceneNavKey.Search(keyword = query, focus = true, initiateSearch = true) + )) } is WatchListEvent.UpdateSyncProgress -> { @@ -294,25 +280,18 @@ class WatchListStateViewModel( } WatchListEvent.PlayStoreMyApps -> emitAction(startActivityAction( intent = Intent().forMyApps(true), - addMultiWindowFlags = true )) WatchListEvent.Refresh -> refresh() is WatchListEvent.AppClick -> { - viewState = viewState.copy(selectedApp = event.app) + emitAction(WatchListAction.SelectApp(event.app)) } is WatchListEvent.AppLongClick -> {} is WatchListEvent.EmptyButton -> { when (event.idx) { - 1 -> emitAction(startActivityAction( - intent = MarketSearchActivity.intent( - application, - keyword = "", - focus = true, - ) - )) - 2 -> emitAction(startActivityAction( - intent = InstalledActivity.intent(importMode = true, application) + 1 -> emitAction(WatchListAction.NavigateTo( + SceneNavKey.Search(focus = true,) )) + 2 -> emitAction(WatchListAction.NavigateTo(SceneNavKey.Installed(importMode = true))) 3 -> emitAction(startActivityAction( intent = Intent.makeMainActivity(ComponentName("com.android.vending", "com.android.vending.AssetBrowserActivity")) )) @@ -320,8 +299,8 @@ class WatchListStateViewModel( } is WatchListEvent.SectionHeaderClick -> { when (event.type) { - SectionHeader.RecentlyInstalled -> emitAction(startActivityAction( - intent = InstalledActivity.intent(importMode = false, application) + SectionHeader.RecentlyInstalled -> emitAction(WatchListAction.NavigateTo( + SceneNavKey.Installed(importMode = false) )) else -> { } } @@ -332,7 +311,7 @@ class WatchListStateViewModel( } private fun pinTagShortcut() { - val intent = AppWatcherActivity.createTagShortcutIntent(viewState.tag.id, viewState.tag.color, application) + val intent = AppWatcherActivity.tagShortcutIntent(viewState.tag.id, viewState.tag.color, application) viewModelScope.launch { try { val icon = createTagIcon() @@ -344,7 +323,7 @@ class WatchListStateViewModel( )) } catch (e: Exception) { AppLog.e(e) - emitAction(CommonActivityAction.ShowToast( + emitAction(showToastAction( resId = R.string.unable_pin_shortcut, length = Toast.LENGTH_SHORT )) @@ -354,7 +333,7 @@ class WatchListStateViewModel( private suspend fun createTagIcon(): Icon = withContext(Dispatchers.Default) { val roles = MaterialColors.getColorRoles(viewState.tag.color, false) - val background = ColorDrawable(roles.accent) + val background = roles.accent.toDrawable() val foreground: Drawable = ContextCompat.getDrawable(application, R.drawable.shortcut_tag)!! foreground.setTint(roles.onAccent) return@withContext AdaptiveIconDrawable(background, foreground).toIcon(application) @@ -366,14 +345,14 @@ class WatchListStateViewModel( appScope.launch { try { requestRefresh() - } catch (e: Exception) { + } catch (_: Exception) { if (networkConnection.isNetworkAvailable) { - emitAction(CommonActivityAction.ShowToast( + emitAction(showToastAction( resId = R.string.failed_gain_access, length = Toast.LENGTH_SHORT )) } else { - emitAction(CommonActivityAction.ShowToast( + emitAction(showToastAction( resId = R.string.check_connection, length = Toast.LENGTH_SHORT )) diff --git a/app/src/main/java/com/anod/appwatcher/watchlist/WatchListTopBar.kt b/app/src/main/java/com/anod/appwatcher/watchlist/WatchListTopBar.kt index a60132eb..cfad31e3 100644 --- a/app/src/main/java/com/anod/appwatcher/watchlist/WatchListTopBar.kt +++ b/app/src/main/java/com/anod/appwatcher/watchlist/WatchListTopBar.kt @@ -57,7 +57,7 @@ fun WatchListTopBar( onEvent = { event -> when (event) { SearchTopBarEvent.NavigationAction -> { - onEvent(WatchListEvent.NavigationButton) + onEvent(WatchListEvent.OnBackPressed) } SearchTopBarEvent.SearchAction -> { onEvent(WatchListEvent.ShowSearch(show = true)) diff --git a/app/src/main/java/com/anod/appwatcher/wishlist/WishListActivity.kt b/app/src/main/java/com/anod/appwatcher/wishlist/WishListActivity.kt deleted file mode 100644 index ae728cd6..00000000 --- a/app/src/main/java/com/anod/appwatcher/wishlist/WishListActivity.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.anod.appwatcher.wishlist - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.widget.Toast -import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.lifecycle.lifecycleScope -import com.anod.appwatcher.R -import com.anod.appwatcher.compose.AppTheme -import com.anod.appwatcher.compose.BaseComposeActivity -import com.anod.appwatcher.compose.MainDetailScreen -import com.anod.appwatcher.details.DetailsDialog -import com.anod.appwatcher.utils.prefs -import com.anod.appwatcher.watchlist.DetailContent -import info.anodsplace.framework.content.onCommonActivityAction -import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent - -/** - * @author Alex Gavrishev - * * - * @date 16/12/2016. - */ -class WishListActivity : BaseComposeActivity(), KoinComponent { - - private val viewModel: WishListViewModel by viewModels(factoryProducer = { - WishListViewModel.Factory( - wideLayout = foldableDevice.layout.value, - ) - }) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (!viewModel.authenticated) { - Toast.makeText(this, R.string.choose_an_account, Toast.LENGTH_SHORT).show() - finish() - return - } - - setContent { - AppTheme( - theme = viewModel.prefs.theme - ) { - val screenState by viewModel.viewStates.collectAsState(initial = viewModel.viewState) - if (screenState.wideLayout.isWideLayout) { - MainDetailScreen( - wideLayout = screenState.wideLayout, - main = { - WishListScreen( - screenState = screenState, - onEvent = viewModel::handleEvent, - pagingDataFlow = viewModel.pagingData, - viewActions = viewModel.viewActions, - onActivityAction = { onCommonActivityAction(it) } - ) - }, - detail = { - DetailContent( - app = screenState.selectedApp, - onDismissRequest = { viewModel.handleEvent(WishListEvent.SelectApp(app = null)) }, - onCommonActivityAction = { onCommonActivityAction(it) } - ) - } - ) - } else { - WishListScreen( - screenState = screenState, - onEvent = viewModel::handleEvent, - pagingDataFlow = viewModel.pagingData, - viewActions = viewModel.viewActions, - onActivityAction = { onCommonActivityAction(it) } - ) - if (screenState.selectedApp != null) { - DetailsDialog( - app = screenState.selectedApp!!, - onDismissRequest = { viewModel.handleEvent(WishListEvent.SelectApp(app = null)) }, - onCommonActivityAction = { onCommonActivityAction(it) } - ) - } - } - } - } - - lifecycleScope.launch { - foldableDevice.layout.collect { - viewModel.handleEvent(WishListEvent.SetWideLayout(it)) - } - } - } - - companion object { - fun intent(context: Context): Intent = Intent(context, WishListActivity::class.java) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/wishlist/WishListScreen.kt b/app/src/main/java/com/anod/appwatcher/wishlist/WishListScreen.kt index dee072cd..c64aacdb 100644 --- a/app/src/main/java/com/anod/appwatcher/wishlist/WishListScreen.kt +++ b/app/src/main/java/com/anod/appwatcher/wishlist/WishListScreen.kt @@ -20,10 +20,10 @@ import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -31,6 +31,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.paging.LoadState import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems @@ -39,23 +40,43 @@ import androidx.paging.compose.itemKey import com.anod.appwatcher.R import com.anod.appwatcher.compose.SearchTopBar import com.anod.appwatcher.database.entities.App +import com.anod.appwatcher.navigation.SceneNavKey import com.anod.appwatcher.search.ListItem import com.anod.appwatcher.search.MarketAppItem import com.anod.appwatcher.search.RetryButton import com.anod.appwatcher.tags.TagSelectionDialog -import com.anod.appwatcher.tags.TagSnackbar +import com.anod.appwatcher.tags.TagSnackbar.Visuals import com.anod.appwatcher.utils.AppIconLoader -import info.anodsplace.framework.content.CommonActivityAction +import info.anodsplace.framework.app.FoldableDeviceLayout +import info.anodsplace.framework.content.startActivity import kotlinx.coroutines.flow.Flow import org.koin.java.KoinJavaComponent +@Composable +fun WishListScreenScene(wideLayout: FoldableDeviceLayout, navigateBack: () -> Unit) { + val viewModel: WishListViewModel = viewModel( + factory = WishListViewModel.Factory( + wideLayout = wideLayout, + ), + key = SceneNavKey.WishList.toString() + ) + val screenState by viewModel.viewStates.collectAsState(initial = viewModel.viewState) + WishListScreen( + screenState = screenState, + onEvent = viewModel::handleEvent, + pagingDataFlow = viewModel.pagingData, + viewActions = viewModel.viewActions, + navigateBack = navigateBack + ) +} + @Composable fun WishListScreen( screenState: WishListState, pagingDataFlow: Flow>, onEvent: (WishListEvent) -> Unit, viewActions: Flow, - onActivityAction: (CommonActivityAction) -> Unit, + navigateBack: () -> Unit = {}, appIconLoader: AppIconLoader = KoinJavaComponent.getKoin().get(), ) { val context = LocalContext.current @@ -86,28 +107,36 @@ fun WishListScreen( .padding(paddingValues) .fillMaxSize(), ) { - val items = pagingDataFlow.collectAsLazyPagingItems() - when (items.loadState.refresh) { - is LoadState.Loading -> { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() + if (screenState.isError) { + RetryButton(onRetryClick = { + onEvent(WishListEvent.RetryClick) + }) + } else { + val items = pagingDataFlow.collectAsLazyPagingItems() + when (items.loadState.refresh) { + is LoadState.Loading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } } - } - is LoadState.Error -> { - RetryButton(onRetryClick = { - items.refresh() - }) - } - is LoadState.NotLoading -> { - val isEmpty = items.itemCount < 1 - if (isEmpty) { - WishlistEmpty() - } else { - WishlistResults( - items = items, - onEvent = onEvent, - appIconLoader = appIconLoader - ) + + is LoadState.Error -> { + RetryButton(onRetryClick = { + items.refresh() + }) + } + + is LoadState.NotLoading -> { + val isEmpty = items.itemCount < 1 + if (isEmpty) { + WishlistEmpty() + } else { + WishlistResults( + items = items, + onEvent = onEvent, + appIconLoader = appIconLoader + ) + } } } } @@ -115,17 +144,18 @@ fun WishListScreen( } var showTagList: App? by remember { mutableStateOf(null) } - val latestOnActivityAction by rememberUpdatedState(onActivityAction) LaunchedEffect(key1 = viewActions) { viewActions.collect { action -> when (action) { is WishListAction.ShowTagSnackbar -> { - val result = snackbarHostState.showSnackbar(TagSnackbar.Visuals(action.info, context)) + val result = snackbarHostState.showSnackbar(Visuals(action.info, context)) if (result == SnackbarResult.ActionPerformed) { showTagList = action.info } } - is WishListAction.ActivityAction -> latestOnActivityAction(action.action) + + is WishListAction.NavigateBack -> navigateBack() + is WishListAction.StartActivity -> context.startActivity(action) } } } diff --git a/app/src/main/java/com/anod/appwatcher/wishlist/WishListViewModel.kt b/app/src/main/java/com/anod/appwatcher/wishlist/WishListViewModel.kt index 58142177..b57fb1af 100644 --- a/app/src/main/java/com/anod/appwatcher/wishlist/WishListViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/wishlist/WishListViewModel.kt @@ -1,5 +1,6 @@ package com.anod.appwatcher.wishlist +import android.content.Intent import android.content.pm.PackageManager import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel @@ -11,6 +12,10 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.filter +import com.anod.appwatcher.accounts.AuthTokenBlocking +import com.anod.appwatcher.accounts.CheckTokenError +import com.anod.appwatcher.accounts.CheckTokenResult +import com.anod.appwatcher.accounts.toAndroidAccount import com.anod.appwatcher.database.AppsDatabase import com.anod.appwatcher.database.entities.App import com.anod.appwatcher.database.observePackages @@ -18,18 +23,21 @@ import com.anod.appwatcher.search.ListItem import com.anod.appwatcher.search.updateRowId import com.anod.appwatcher.utils.BaseFlowViewModel import com.anod.appwatcher.utils.date.UploadDateParserCache +import com.anod.appwatcher.utils.prefs +import com.anod.appwatcher.wishlist.WishListAction.StartActivity import finsky.api.DfeApi import finsky.api.FilterComposite import finsky.api.FilterPredicate import info.anodsplace.framework.app.FoldableDeviceLayout -import info.anodsplace.framework.content.CommonActivityAction import info.anodsplace.framework.content.InstalledApps +import info.anodsplace.framework.content.StartActivityAction import info.anodsplace.playstore.AppDetailsFilter import info.anodsplace.playstore.AppNameFilter import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -38,18 +46,23 @@ data class WishListState( val nameFilter: String = "", val wideLayout: FoldableDeviceLayout = FoldableDeviceLayout(), val selectedApp: App? = null, + val isError: Boolean = false ) sealed interface WishListAction { class ShowTagSnackbar(val info: App) : WishListAction - class ActivityAction(val action: CommonActivityAction) : WishListAction + class StartActivity(override val intent: Intent) : WishListAction, StartActivityAction + data object NavigateBack : WishListAction } sealed interface WishListEvent { data object OnBackPress : WishListEvent + data object NoAccount : WishListEvent + data object RetryClick : WishListEvent class OnNameFilter(val query: String) : WishListEvent class SelectApp(val app: App?) : WishListEvent class SetWideLayout(val wideLayout: FoldableDeviceLayout) : WishListEvent + class AuthTokenError(val error: CheckTokenError) : WishListEvent } class WishListViewModel(wideLayout: FoldableDeviceLayout) : BaseFlowViewModel(), KoinComponent { @@ -67,6 +80,7 @@ class WishListViewModel(wideLayout: FoldableDeviceLayout) : BaseFlowViewModel pageData.updateRowId(watchedPackages) } + private suspend fun checkAuthToken(): Boolean { + val account = prefs.account?.toAndroidAccount() + return if (account == null) { + handleEvent(WishListEvent.NoAccount) + false + } else { + when (val result = authToken.checkToken(account)) { + is CheckTokenResult.Error -> { + handleEvent(WishListEvent.AuthTokenError(result.error)) + false + } + + is CheckTokenResult.Success -> true + } + } + } + override fun handleEvent(event: WishListEvent) { when (event) { - WishListEvent.OnBackPress -> emitAction(WishListAction.ActivityAction(CommonActivityAction.Finish)) + WishListEvent.OnBackPress -> emitAction(WishListAction.NavigateBack) is WishListEvent.OnNameFilter -> viewState = viewState.copy(nameFilter = event.query) is WishListEvent.SelectApp -> { viewState = viewState.copy(selectedApp = event.app) @@ -124,6 +159,18 @@ class WishListViewModel(wideLayout: FoldableDeviceLayout) : BaseFlowViewModel { viewState = viewState.copy(wideLayout = event.wideLayout) } + + is WishListEvent.AuthTokenError -> { + viewState = viewState.copy(isError = true) + if (event.error is CheckTokenError.RequiresInteraction) { + emitAction(StartActivity(event.error.intent)) + } + } + WishListEvent.NoAccount -> { + viewState = viewState.copy(isError = true) + } + + WishListEvent.RetryClick -> viewState = viewState.copy(isError = false) } } diff --git a/build.gradle.kts b/build.gradle.kts index 4de010b2..0fe82984 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,4 +16,6 @@ plugins { alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.ktlint.gradle) apply false + alias(libs.plugins.kotlinx.serialization) apply false + alias(libs.plugins.baselineprofile) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8673c396..78ee7370 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,54 +1,56 @@ [versions] -activity-compose = "1.10.1" -agp = "8.10.1" +activity-compose = "1.11.0" +agp = "8.13.0" annotation = "1.9.1" -ksp = "2.1.21-2.0.1" -androidx-junit = "1.2.1" +ksp = "2.3.0" +androidx-junit = "1.3.0" appcompat = "1.7.1" -benchmark-macro-junit4 = "1.3.4" -coil = "3.2.0" -compose-bom = "2025.06.00" -compose-material3 = "1.4.0-alpha15" +benchmark = "1.4.1" +coil = "3.3.0" +compose-bom = "2025.10.01" +compose-material3 = "1.4.0" core-splashscreen = "1.0.1" -core-ktx = "1.16.0" -espresso-core = "3.6.1" -firebase-crashlytics = "19.4.4" -firebase-analytics = "22.4.0" -firebase-crashlytics-gradle = "3.0.4" -google-api-client-android = "2.8.0" -google-api-client = "2.8.0" -google-services = "4.4.2" +core-ktx = "1.17.0" +espresso-core = "3.7.0" +firebase-crashlytics = "20.0.3" +firebase-analytics = "23.0.0" +firebase-crashlytics-gradle = "3.0.6" +google-api-client-android = "2.8.1" +google-api-client = "2.8.1" +google-services = "4.4.4" junit = "4.13.2" -koin-core = "4.1.0" -kotlin = "2.1.21" +koin-core = "4.1.1" +kotlin = "2.2.21" kotlinx-collections-immutable = "0.4.0" -kotlinx-datetime = "0.6.2" -ktor = "3.1.3" -ktlint-gradle = "12.3.0" -ktlint-compose = "0.4.22" +kotlinx-datetime = "0.7.1" +kotlinx-serialization = "1.9.0" +ktor = "3.3.1" +ktlint-gradle = "13.1.0" +ktlint-compose = "0.4.27" coroutines = "1.10.2" leakcanary-android = "2.14" -lifecycle = "2.9.1" -okhttp = "4.12.0" -oss-licenses-plugin = "0.10.6" +lifecycle = "2.9.4" +navigation3-ui = "1.0.0-SNAPSHOT" +navigation3-adaptive = "1.0.0-SNAPSHOT" +okhttp = "5.2.1" +oss-licenses-plugin = "0.10.9" paging = "3.3.6" palette = "1.0.0" -play-services-auth = "21.3.0" +play-services-auth = "21.4.0" play-services-identity = "18.1.0" -play-services-oss-licenses = "17.1.0" +play-services-oss-licenses = "17.3.0" process-phoenix = "3.0.0" -room = "2.7.1" -runtime-tracing = "1.8.2" +room = "2.8.3" +runtime-tracing = "1.9.4" uiautomator = "2.3.0" -window = "1.4.0" -work-runtime = "2.10.1" +window = "1.5.0" +work-runtime = "2.11.0" [libraries] -androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } -androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activity-compose" } +androidx-activity = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } -androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmark-macro-junit4" } +androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmark" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } androidx-compose-material-icons-core = { module = "androidx.compose.material:material-icons-core" } @@ -64,6 +66,8 @@ androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", vers androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" } androidx-palette = { module = "androidx.palette:palette", version.ref = "palette" } androidx-palette-ktx = { module = "androidx.palette:palette-ktx", version.ref = "palette" } +androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "navigation3-ui" } +androidx-navigation3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "navigation3-adaptive" } androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } androidx-window = { module = "androidx.window:window", version.ref = "window" } @@ -84,6 +88,7 @@ google-services = { module = "com.google.gms:google-services", version.ref = "go junit = { module = "junit:junit", version.ref = "junit" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin-core" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinx-collections-immutable" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } @@ -119,10 +124,12 @@ work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "w [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +baselineprofile = { id = "androidx.baselineprofile", version.ref = "benchmark" } android-library = { id = "com.android.library", version.ref = "agp" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin"} ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ktlint-gradle = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" } room = { id = "androidx.room", version.ref = "room" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 0087cd3b..9bbc975c 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8fc91c82..2e111328 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists \ No newline at end of file +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 7a5952e7..faf93008 100755 --- a/gradlew +++ b/gradlew @@ -1,79 +1,129 @@ -#!/usr/bin/env bash +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit -# Use the maximum available, or set MAX_FD != -1 to use that actual. -MAX_FD="maximum" +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum -warn ( ) { +warn () { echo "$*" -} +} >&2 -die ( ) { +die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -# For Cygwin, ensure paths are in UNIX format before anything is touched. -if $cygwin ; then - [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` -fi - -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >&- -APP_HOME="`pwd -P`" -cd "$SAVED" >&- - CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -82,83 +132,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, put options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=$((i+1)) + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules -function splitJvmOpts() { - JVM_OPTS=("$@") -} -eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS -JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" -exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 8a0b282a..9d21a218 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,4 +1,22 @@ -@if "%DEBUG%" == "" @echo off +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -8,26 +26,30 @@ @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -35,54 +57,36 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail -:init -@rem Get command-line arguments, handling Windowz variants - -if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ - :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/lib b/lib index 3583ae33..3da0a8f5 160000 --- a/lib +++ b/lib @@ -1 +1 @@ -Subproject commit 3583ae33846fe92040350154e958a3d2f183fcce +Subproject commit 3da0a8f5aa63d3dd3b1739b35c2ac1a627a6e46b diff --git a/macrobenchmark/build.gradle.kts b/macrobenchmark/build.gradle.kts index fa86ede9..bac44a34 100644 --- a/macrobenchmark/build.gradle.kts +++ b/macrobenchmark/build.gradle.kts @@ -1,8 +1,16 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { id("com.android.test") alias(libs.plugins.kotlin) } +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_11 + } +} + android { namespace = "info.anodpslace.appwatcher.macrobenchmark" compileSdk = 36 @@ -12,12 +20,8 @@ android { targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { - jvmTarget = "11" - } - defaultConfig { - minSdk = 28 + minSdk = 31 targetSdk = 36 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/playstore/build.gradle.kts b/playstore/build.gradle.kts index 8f47477c..58870569 100644 --- a/playstore/build.gradle.kts +++ b/playstore/build.gradle.kts @@ -1,13 +1,21 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin) } +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_11 + } +} + android { compileSdk = 36 defaultConfig { - minSdk = 27 + minSdk = 31 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -16,9 +24,6 @@ android { targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { - jvmTarget = "11" - } namespace = "info.anodsplace.playstore" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 76f49e7a..9f39d591 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,9 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { + url = uri("https://androidx.dev/snapshots/builds/13647192/artifacts/repository") + } } } @@ -11,6 +14,9 @@ pluginManagement { google() mavenCentral() gradlePluginPortal() + maven { + url = uri("https://androidx.dev/snapshots/builds/13647192/artifacts/repository") + } } }