Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
- Create task worktrees inside the main checkout's ignored `wt/` folder, for example `git worktree add -b <branch> wt/<branch> origin/master` (Windows: `wt\<branch>`).
- After creating a task worktree, copy necessary ignored local build files from the main checkout into the same relative paths in the task worktree, especially `local.properties` and `app/google-services.json` when available.
- When the task branch/PR has been merged, remove the task worktree with `git worktree remove <path>` and run `git worktree prune` to delete stale metadata.
- If a task changes a submodule pointer, commit and push the submodule repository first, then verify the exact SHA is fetchable from its remote before committing or opening/pushing the parent AppWatcher PR.
- When changing files inside the `lib` submodule, treat `lib` as its own repository: commit the `lib` changes inside `lib`, push that commit to the submodule remote, and verify the exact pushed SHA is fetchable.
- After the `lib` commit is pushed and verified, update the parent AppWatcher repository's `lib` gitlink/submodule pointer to that new commit, then include the pointer update in the parent AppWatcher commit/PR.

## Architecture

Expand Down
6 changes: 4 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ android {
applicationId = "com.anod.appwatcher"
minSdk = 31
targetSdk = 36
versionCode = 17007
versionCode = 17008
versionName = "1.7.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
Expand Down Expand Up @@ -171,6 +171,8 @@ dependencies {
implementation(libs.kotlinx.collections.immutable)

testImplementation(libs.junit)
testImplementation(libs.androidx.test.core)
testImplementation(libs.robolectric)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.junit)
Expand Down Expand Up @@ -206,4 +208,4 @@ afterEvaluate {
googleServicesJsonFiles.set(releaseGoogleServicesFile.map { listOf(it) })
}
}
}
}
18 changes: 18 additions & 0 deletions app/src/main/java/com/anod/appwatcher/AppWatcherActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,19 @@ import com.anod.appwatcher.watchlist.DetailContent
import com.anod.appwatcher.watchlist.DetailPlaceholder
import com.anod.appwatcher.watchlist.MainScreenScene
import com.anod.appwatcher.wishlist.WishListScreenScene
import info.anodsplace.applog.AppLog
import info.anodsplace.framework.app.addMultiWindowFlags
import org.koin.core.component.KoinComponent

internal fun shouldMarkUpdatesViewedFromNotification(fromNotification: Boolean, isRestoredLaunch: Boolean) = fromNotification && !isRestoredLaunch

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
class AppWatcherActivity : BaseComposeActivity(), KoinComponent {

override fun onCreate(savedInstanceState: Bundle?) {
setTheme(R.style.AppTheme_Main)
super.onCreate(savedInstanceState)
markUpdatesViewedFromNotification(intent, isRestoredLaunch = savedInstanceState != null)

val elements = createInitialBackstack()
setContent {
Expand Down Expand Up @@ -124,6 +128,20 @@ class AppWatcherActivity : BaseComposeActivity(), KoinComponent {
}
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
markUpdatesViewedFromNotification(intent, isRestoredLaunch = false)
}

private fun markUpdatesViewedFromNotification(intent: Intent?, isRestoredLaunch: Boolean) {
if (!shouldMarkUpdatesViewedFromNotification(intent?.getBooleanExtra(EXTRA_FROM_NOTIFICATION, false) ?: false, isRestoredLaunch)) {
return
}
AppLog.d("mark updates as viewed from notification.")
prefs.isLastUpdatesViewed = true
}

private fun createInitialBackstack(): Array<NavKey> {
val extras = intent?.extras ?: bundleOf()
if (extras.containsKey(EXTRA_SEARCH_KEYWORD)) {
Expand Down
11 changes: 1 addition & 10 deletions app/src/main/java/com/anod/appwatcher/AppWatcherApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,8 @@ import com.anod.appwatcher.preferences.Preferences
import com.anod.appwatcher.sync.SyncNotification
import com.anod.appwatcher.utils.networkConnection
import com.google.firebase.crashlytics.FirebaseCrashlytics
import finsky.api.DfeError
import info.anodsplace.applog.AndroidLogger
import info.anodsplace.applog.AppLog
import java.io.IOException
import java.net.UnknownHostException
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.context.startKoin
Expand Down Expand Up @@ -79,8 +76,7 @@ class AppWatcherApplication : Application(), AppLog.Listener, Configuration.Prov
}

override fun onLogException(tr: Throwable) {
if (isNetworkError(tr) || tr is kotlinx.coroutines.CancellationException) {
// Ignore
if (CrashlyticsExceptionFilter.shouldIgnore(tr, networkConnection::isNetworkException)) {
return
}

Expand All @@ -89,11 +85,6 @@ class AppWatcherApplication : Application(), AppLog.Listener, Configuration.Prov
}
}

private fun isNetworkError(tr: Throwable): Boolean = tr is DfeError ||
tr is UnknownHostException ||
(tr is IOException && tr.message?.contains("NetworkError") == true) ||
networkConnection.isNetworkException(tr)

private inner class FirebaseLogger : AndroidLogger() {
override fun println(priority: Int, tag: String, msg: String) {
super.println(priority, tag, msg)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.anod.appwatcher

import com.google.android.gms.auth.UserRecoverableAuthException
import com.google.api.client.googleapis.extensions.android.gms.auth.UserRecoverableAuthIOException
import finsky.api.DfeError
import java.io.IOException
import java.net.UnknownHostException
import kotlinx.coroutines.CancellationException

internal object CrashlyticsExceptionFilter {
fun shouldIgnore(tr: Throwable, isNetworkException: (Throwable) -> Boolean): Boolean {
val root = rootCause(tr)
return root is CancellationException ||
root is UserRecoverableAuthException ||
root is UserRecoverableAuthIOException ||
root is DfeError ||
root.javaClass.name == "android.system.GaiException" ||
root is UnknownHostException ||
(root is IOException && root.message?.contains("NetworkError") == true) ||
isNetworkException(root)
}

private fun rootCause(tr: Throwable): Throwable {
var current = tr
val seen = mutableSetOf<Throwable>()
repeat(MAX_CAUSE_DEPTH) {
val cause = current.cause
if (cause == null || !seen.add(cause)) {
return current
}
current = cause
}
return current
}

private const val MAX_CAUSE_DEPTH = 16
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import com.anod.appwatcher.database.entities.Tag
import com.google.android.gms.auth.UserRecoverableAuthException
import info.anodsplace.applog.AppLog
import java.io.BufferedReader
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
Expand All @@ -32,6 +33,8 @@ class GDriveSync(private val googleAccount: Account, private val context: info.a
sLock.withLock {
return@withContext doSyncLocked(database)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
AppLog.e("Sync failed: ${e.message}", "GDriveSync")
throw SyncError(DriveService.extractUserRecoverableException(e), e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import com.anod.appwatcher.AppWatcherActivity
import com.anod.appwatcher.utils.prefs
import info.anodsplace.applog.AppLog
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CancellationException
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.parameter.parametersOf
Expand Down Expand Up @@ -63,9 +64,16 @@ class UploadService(appContext: Context, params: WorkerParameters) : CoroutineWo
val worker = get<GDriveUpload> { parametersOf(googleAccount) }
try {
worker.doUploadInBackground()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
AppLog.e("UploadService::doWork - ${e.message}", e)
DriveService.extractUserRecoverableException(e)?.let {
val recoverable = DriveService.extractUserRecoverableException(e)
if (recoverable == null) {
AppLog.e("UploadService::doWork - ${e.message}", e)
} else {
AppLog.e("UploadService::doWork requires interactive sign in: ${e.message}", TAG)
}
recoverable?.let {
val settingActivity = AppWatcherActivity.gDriveSignInIntent(applicationContext).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
Expand Down
34 changes: 18 additions & 16 deletions app/src/main/java/com/anod/appwatcher/database/AppListTable.kt
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ interface AppListTable {
"CASE WHEN :sortId = 0 THEN ${Columns.TITLE} COLLATE NOCASE END ASC, " +
"CASE WHEN :sortId = 1 THEN ${Columns.TITLE} COLLATE NOCASE END DESC, " +
"CASE WHEN :sortId = 2 THEN ${Columns.UPLOAD_TIMESTAMP} END ASC, " +
"CASE WHEN :sortId = 3 THEN ${Columns.UPLOAD_TIMESTAMP} END DESC "
"CASE WHEN :sortId = 3 THEN ${Columns.UPLOAD_TIMESTAMP} END DESC, " +
"$TABLE.${BaseColumns._ID} ASC "
)
fun loadAppList(includeDeleted: Boolean, sortId: Int, recentTime: Long): Cursor

Expand Down Expand Up @@ -206,38 +207,28 @@ interface AppListTable {
}

private fun createAppsListCountQuery(tagId: Int?, titleFilter: String): Pair<String, Array<String>> {
val appTagsTable = when (tagId) {
null -> ""
Tag.empty.id -> "LEFT JOIN ${AppTagsTable.TABLE} ON ${AppTagsTable.TableColumns.APP_ID} = ${TableColumns.APP_ID} "
else -> "INNER JOIN ${AppTagsTable.TABLE} ON ${AppTagsTable.TableColumns.APP_ID} = ${TableColumns.APP_ID} "
}
val selection = createSelection(tagId, titleFilter, null)
val sql =
"SELECT COUNT(DISTINCT $TABLE.${BaseColumns._ID}) " +
"FROM $TABLE " + appTagsTable +
"FROM $TABLE " +
"WHERE ${selection.first} "
return Pair(sql, selection.second)
}

private fun createAppsListQuery(
internal fun createAppsListQuery(
sortId: Int,
orderByRecentlyDiscovered: Boolean,
tagId: Int?,
titleFilter: String,
offset: SqlOffset?
): Pair<String, Array<String>> {
val appTagsTable = when (tagId) {
null -> ""
Tag.empty.id -> "LEFT JOIN ${AppTagsTable.TABLE} ON ${AppTagsTable.TableColumns.APP_ID} = ${TableColumns.APP_ID} "
else -> "INNER JOIN ${AppTagsTable.TABLE} ON ${AppTagsTable.TableColumns.APP_ID} = ${TableColumns.APP_ID} "
}
val rangeSql = if (offset == null) "" else " LIMIT ? OFFSET ? "
val selection = createSelection(tagId, titleFilter, offset)

val sql =
"SELECT $TABLE.*, ${ChangelogTable.TableColumns.DETAILS}, ${ChangelogTable.TableColumns.NO_NEW_DETAILS}, " +
"CASE WHEN ${Columns.SYNC_TIMESTAMP} > $recentTime THEN 1 ELSE 0 END ${Columns.RECENT_FLAG} " +
"FROM $TABLE " + appTagsTable +
"FROM $TABLE " +
"LEFT JOIN ${ChangelogTable.TABLE} ON ${TableColumns.APP_ID} == ${ChangelogTable.TableColumns.APP_ID} " +
"AND ${TableColumns.VERSION_NUMBER} == ${ChangelogTable.TableColumns.VERSION_CODE} " +
"WHERE ${selection.first} " +
Expand Down Expand Up @@ -293,9 +284,20 @@ interface AppListTable {

if (tagId != null) {
if (tagId == Tag.empty.id) {
selc.add(AppTagsTable.TableColumns.TAG_ID + " IS NULL")
selc.add(
"NOT EXISTS (" +
"SELECT 1 FROM ${AppTagsTable.TABLE} " +
"WHERE ${AppTagsTable.TableColumns.APP_ID} = ${TableColumns.APP_ID}" +
")"
)
} else {
selc.add(AppTagsTable.TableColumns.TAG_ID + " = ?")
selc.add(
"EXISTS (" +
"SELECT 1 FROM ${AppTagsTable.TABLE} " +
"WHERE ${AppTagsTable.TableColumns.APP_ID} = ${TableColumns.APP_ID} " +
"AND ${AppTagsTable.TableColumns.TAG_ID} = ?" +
")"
)
args.add(tagId.toString())
}
}
Expand Down
31 changes: 18 additions & 13 deletions app/src/main/java/com/anod/appwatcher/preferences/Preferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -134,16 +134,16 @@ class Preferences(context: Context, private val notificationManager: Notificatio
set(notify) = preferences.edit().putBoolean("notify-no-changes", notify).apply()

var showRecent: Boolean
get() = preferences.getBoolean("show-recent", true)
set(value) = preferences.edit().putBoolean("show-recent", value).apply()
get() = preferences.getBoolean(SHOW_RECENT, true)
set(value) = preferences.edit().putBoolean(SHOW_RECENT, value).apply()

var showOnDevice: Boolean
get() = preferences.getBoolean("show-on-device", false)
set(value) = preferences.edit().putBoolean("show-on-device", value).apply()
get() = preferences.getBoolean(SHOW_ON_DEVICE, false)
set(value) = preferences.edit().putBoolean(SHOW_ON_DEVICE, value).apply()

var showRecentlyDiscovered: Boolean
get() = preferences.getBoolean("show-recently-updated", true)
set(value) = preferences.edit().putBoolean("show-recently-updated", value).apply()
get() = preferences.getBoolean(SHOW_RECENTLY_DISCOVERED, true)
set(value) = preferences.edit().putBoolean(SHOW_RECENTLY_DISCOVERED, value).apply()

val selectedTheme: SelectedTheme
get() = SelectedTheme(themeIndex, themeMode)
Expand All @@ -167,12 +167,12 @@ class Preferences(context: Context, private val notificationManager: Notificatio
}

var defaultMainFilterId: Int
get() = preferences.getInt(FILTER_ID, Filters.ALL)
set(filterId) = preferences.edit().putInt(FILTER_ID, filterId).apply()
get() = preferences.getInt(DEFAULT_MAIN_FILTER_ID, Filters.ALL)
set(filterId) = preferences.edit().putInt(DEFAULT_MAIN_FILTER_ID, filterId).apply()

var enablePullToRefresh: Boolean
get() = preferences.getBoolean("pull-to-refresh", true)
set(value) = preferences.edit().putBoolean("pull-to-refresh", value).apply()
get() = preferences.getBoolean(PULL_TO_REFRESH, true)
set(value) = preferences.edit().putBoolean(PULL_TO_REFRESH, value).apply()

var collectCrashReports: Boolean
get() = preferences.getBoolean("crash-reports", true)
Expand All @@ -183,8 +183,8 @@ class Preferences(context: Context, private val notificationManager: Notificatio
}

var iconShape: String
get() = preferences.getString("adaptive-icon-shape", defaultSystemMask)!!
set(value) = preferences.edit().putString("adaptive-icon-shape", value).apply()
get() = preferences.getString(ICON_SHAPE, defaultSystemMask)!!
set(value) = preferences.edit().putString(ICON_SHAPE, value).apply()

val defaultSystemMask: String by lazy {
AdaptiveIcon.getSystemDefaultMask()
Expand All @@ -202,7 +202,12 @@ class Preferences(context: Context, private val notificationManager: Notificatio
private const val ACCOUNT_NAME = "account_name"
private const val ACCOUNT_TYPE = "account_type"
private const val SORT_INDEX = "sort_index"
private const val FILTER_ID = "default_main_filter_id"
const val DEFAULT_MAIN_FILTER_ID = "default_main_filter_id"
const val SHOW_RECENT = "show-recent"
const val SHOW_ON_DEVICE = "show-on-device"
const val SHOW_RECENTLY_DISCOVERED = "show-recently-updated"
const val PULL_TO_REFRESH = "pull-to-refresh"
const val ICON_SHAPE = "adaptive-icon-shape"

const val SORT_NAME_ASC = 0
const val SORT_NAME_DESC = 1
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.anod.appwatcher.preferences

import android.app.Activity.RESULT_OK
import android.content.Context
import android.content.Intent
import android.net.Uri
Expand Down Expand Up @@ -56,7 +55,6 @@ 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
Expand Down Expand Up @@ -95,11 +93,6 @@ fun SettingsScreenScene(prefs: Preferences, navigateBack: () -> Unit, navigateTo
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))
}
Expand All @@ -119,12 +112,6 @@ fun SettingsScreenScene(prefs: Preferences, navigateBack: () -> Unit, navigateTo
}
}

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 -> {
Expand Down Expand Up @@ -287,17 +274,17 @@ fun onSettingsItemClick(prefs: Preferences, item: PreferenceItem, onEvent: (Sett
"crash-reports" -> {
onEvent(SettingsViewEvent.UpdateCrashReports((item as PreferenceItem.Switch).checked))
}
"pull-to-refresh" -> {
onEvent(SettingsViewEvent.SetRecreateFlag(item, prefs.enablePullToRefresh) { prefs.enablePullToRefresh = it })
Preferences.PULL_TO_REFRESH -> {
onEvent(SettingsViewEvent.UpdateListPreference(item) { prefs.enablePullToRefresh = it })
}
"show-recent" -> {
onEvent(SettingsViewEvent.SetRecreateFlag(item, prefs.showRecent) { prefs.showRecent = it })
Preferences.SHOW_RECENT -> {
onEvent(SettingsViewEvent.UpdateListPreference(item) { prefs.showRecent = it })
}
"show-on-device" -> {
onEvent(SettingsViewEvent.SetRecreateFlag(item, prefs.showOnDevice) { prefs.showOnDevice = it })
Preferences.SHOW_ON_DEVICE -> {
onEvent(SettingsViewEvent.UpdateListPreference(item) { prefs.showOnDevice = it })
}
"show-recently-updated" -> {
onEvent(SettingsViewEvent.SetRecreateFlag(item, prefs.showRecentlyDiscovered) { prefs.showRecentlyDiscovered = it })
Preferences.SHOW_RECENTLY_DISCOVERED -> {
onEvent(SettingsViewEvent.UpdateListPreference(item) { prefs.showRecentlyDiscovered = it })
}
"default-filter" -> {
prefs.defaultMainFilterId = (item as PreferenceItem.Pick).value.toInt()
Expand Down
Loading
Loading