Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 = 17009
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
Loading
Loading