10 Commits
Author SHA1 Message Date
sora 405ff5c525 Warn if logout/delete would impact unsynced data 2026-09-10 01:58:45 +02:00
sora 49a275c53e Fix sync of archived 2026-09-10 01:30:25 +02:00
sora b7037d8074 Restore deleted 2026-09-10 01:29:37 +02:00
sora 98b7bcaaf7 webview cleanup 2026-09-10 01:28:19 +02:00
sora 50c82068a4 Login/Logout Improvements 2026-09-10 00:37:09 +02:00
sora a83601c0c0 sync trigger logic improvements 2026-09-10 00:35:43 +02:00
sora 5bc83e22ba Add local delete of archived projects 2026-09-10 00:34:02 +02:00
sora d6ee50bc34 Sync Flag lifecycle 2026-09-10 00:26:55 +02:00
sora 06f31571a3 Fix db crash on big projects 2026-09-10 00:26:16 +02:00
sora c70bb808c1 UI fix of button overlap in label lists 2026-09-09 10:23:37 +02:00
13 changed files with 604 additions and 201 deletions
@@ -1,7 +1,6 @@
package net.helcel.cowspent.android.account package net.helcel.cowspent.android.account
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Build import android.os.Build
@@ -38,6 +37,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.helcel.cowspent.R import net.helcel.cowspent.R
import net.helcel.cowspent.android.main.MainConstants import net.helcel.cowspent.android.main.MainConstants
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.persistence.CowspentServerSyncHelper
import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.CospendClientUtil import net.helcel.cowspent.util.CospendClientUtil
import net.helcel.cowspent.util.CospendClientUtil.LoginStatus import net.helcel.cowspent.util.CospendClientUtil.LoginStatus
@@ -108,8 +109,7 @@ class AccountActivity : AppCompatActivity() {
viewModel.useSso = false viewModel.useSso = false
preferences.edit { putBoolean(SETTINGS_USE_SSO, false) } preferences.edit { putBoolean(SETTINGS_USE_SSO, false) }
} }
}, }
onLogout = { viewModel.logout() }
) )
} }
@@ -147,7 +147,7 @@ class AccountActivity : AppCompatActivity() {
allowFileAccess = false allowFileAccess = false
javaScriptEnabled = true javaScriptEnabled = true
domStorageEnabled = true domStorageEnabled = true
userAgentString = getWebLoginUserAgent(context) userAgentString = getWebLoginUserAgent()
} }
webViewClient = object : WebViewClient() { webViewClient = object : WebViewClient() {
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
@@ -188,6 +188,12 @@ class AccountActivity : AppCompatActivity() {
} }
} }
private fun startInitialAccountSync() {
if (!CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) return
CowspentSQLiteOpenHelper.getInstance(applicationContext)
.cowspentServerSyncHelper.runAccountProjectsSync()
}
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
@@ -207,6 +213,8 @@ class AccountActivity : AppCompatActivity() {
viewModel.serverUrl = ssoAccount.url viewModel.serverUrl = ssoAccount.url
viewModel.username = ssoAccount.userId viewModel.username = ssoAccount.userId
startInitialAccountSync()
val resultData = Intent() val resultData = Intent()
resultData.putExtra(MainConstants.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED) resultData.putExtra(MainConstants.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED)
setResult(RESULT_OK, resultData) setResult(RESULT_OK, resultData)
@@ -234,13 +242,9 @@ class AccountActivity : AppCompatActivity() {
} }
} }
private fun getWebLoginUserAgent(context: Context): String { private fun getWebLoginUserAgent(): String {
val defaultUA = try { val manufacturer = Build.MANUFACTURER.replaceFirstChar { it.titlecase(Locale.ROOT) }
android.webkit.WebSettings.getDefaultUserAgent(context) return "$manufacturer ${Build.MODEL} (Cowspent/Android)"
} catch (_: Exception) {
Build.MANUFACTURER + " " + Build.MODEL
}
return "$defaultUA Cowspent/Android"
} }
private fun parseAndLoginFromWebView(dataString: String) { private fun parseAndLoginFromWebView(dataString: String) {
@@ -312,6 +316,8 @@ class AccountActivity : AppCompatActivity() {
remove(SETTINGS_KEY_LAST_MODIFIED) remove(SETTINGS_KEY_LAST_MODIFIED)
} }
startInitialAccountSync()
val data = Intent() val data = Intent()
data.putExtra(MainConstants.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED) data.putExtra(MainConstants.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED)
setResult(RESULT_OK, data) setResult(RESULT_OK, data)
@@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -28,8 +29,7 @@ fun AccountScreen(
viewModel: AccountViewModel, viewModel: AccountViewModel,
onBack: () -> Unit, onBack: () -> Unit,
onConnect: () -> Unit, onConnect: () -> Unit,
onSsoClick: (Boolean) -> Unit, onSsoClick: (Boolean) -> Unit
onLogout: () -> Unit
) { ) {
AccountScreenContent( AccountScreenContent(
isLoggedIn = viewModel.isLoggedIn, isLoggedIn = viewModel.isLoggedIn,
@@ -51,7 +51,57 @@ fun AccountScreen(
onBack = onBack, onBack = onBack,
onConnect = onConnect, onConnect = onConnect,
onSsoClick = onSsoClick, onSsoClick = onSsoClick,
onLogout = onLogout onLogout = { viewModel.requestLogout() }
)
viewModel.logoutImpact?.let { impact ->
LogoutConfirmationDialog(
impact = impact,
onDismiss = { viewModel.cancelLogout() },
onConfirm = { viewModel.confirmLogout() }
)
}
}
@Composable
fun LogoutConfirmationDialog(
impact: AccountViewModel.LogoutImpact,
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.logout_confirm_title)) },
text = {
Column {
Text(
if (impact.projects > 0) {
pluralStringResource(
R.plurals.logout_confirm_projects, impact.projects, impact.projects
)
} else {
stringResource(R.string.logout_confirm_no_projects)
}
)
if (impact.unsyncedBills > 0) {
Spacer(Modifier.height(12.dp))
Text(
text = pluralStringResource(
R.plurals.warning_unsynced_bills,
impact.unsyncedBills,
impact.unsyncedBills
),
fontWeight = FontWeight.Bold
)
}
}
},
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_logout)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.simple_cancel)) }
}
) )
} }
@@ -2,9 +2,11 @@ package net.helcel.cowspent.android.account
import android.app.Application import android.app.Application
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.annotation.VisibleForTesting
import androidx.core.content.edit import androidx.core.content.edit
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -12,10 +14,17 @@ import androidx.preference.PreferenceManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.util.CospendClientUtil import net.helcel.cowspent.util.CospendClientUtil
import net.helcel.cowspent.util.SecureStorage import net.helcel.cowspent.util.SecureStorage
class AccountViewModel(application: Application) : AndroidViewModel(application) { class AccountViewModel(application: Application) : AndroidViewModel(application) {
private companion object {
const val TAG = "AccountViewModel"
}
private val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(application) private val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(application)
var useSso by mutableStateOf(preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) var useSso by mutableStateOf(preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false))
@@ -41,6 +50,9 @@ class AccountViewModel(application: Application) : AndroidViewModel(application)
var showWebView by mutableStateOf(false) var showWebView by mutableStateOf(false)
var isLoggedIn by mutableStateOf(false) var isLoggedIn by mutableStateOf(false)
/** Non-null while the logout confirmation is up, carrying what it would cost. */
var logoutImpact by mutableStateOf<LogoutImpact?>(null)
private set private set
var isValidatingLogin by mutableStateOf(false) var isValidatingLogin by mutableStateOf(false)
@@ -88,8 +100,63 @@ class AccountViewModel(application: Application) : AndroidViewModel(application)
} }
} }
private fun projectsTheAccountProvided(db: CowspentSQLiteOpenHelper): List<DBProject> {
val offered = db.accountProjects
val cospendPath = "/index.php/apps/cospend"
return db.projects.filter { project ->
offered.any {
it.remoteId == project.remoteId &&
project.serverUrl?.replace("/+$".toRegex(), "") ==
it.ncUrl.replace("/+$".toRegex(), "") + cospendPath
}
}
}
data class LogoutImpact(val projects: Int, val unsyncedBills: Int)
private fun measureLogoutImpact(): LogoutImpact {
val db = CowspentSQLiteOpenHelper.getInstance(getApplication())
val projects = projectsTheAccountProvided(db)
val unsynced = projects.sumOf { db.countUnsyncedBills(it.id) }
return LogoutImpact(projects.size, unsynced)
}
fun requestLogout() {
viewModelScope.launch {
logoutImpact = withContext(Dispatchers.IO) {
try {
measureLogoutImpact()
} catch (e: Exception) {
// Never let a failed count block signing out - just ask without the detail.
Log.e(TAG, "Could not measure what logging out would remove", e)
LogoutImpact(0, 0)
}
}
}
}
fun cancelLogout() {
logoutImpact = null
}
fun confirmLogout() {
logoutImpact = null
logout()
}
private fun forgetProjectsTheAccountProvided() {
try {
val db = CowspentSQLiteOpenHelper.getInstance(getApplication())
projectsTheAccountProvided(db).forEach { db.deleteProject(it.id) }
db.clearAccountProjects()
} catch (e: Exception) {
Log.e(TAG, "Could not remove the account's projects on logout", e)
}
}
@VisibleForTesting
fun logout() { fun logout() {
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { forgetProjectsTheAccountProvided() }
SecureStorage.removePassword(getApplication(), AccountActivity.SETTINGS_PASSWORD) SecureStorage.removePassword(getApplication(), AccountActivity.SETTINGS_PASSWORD)
} }
preferences.edit { preferences.edit {
@@ -219,6 +219,7 @@ fun CategoryList(
onDelete = { onDelete(category) } onDelete = { onDelete(category) }
) )
} }
item { LabelListBottomSpacer() }
} }
} }
@@ -238,9 +239,19 @@ fun PaymentModeList(
onDelete = { onDelete(pm) } onDelete = { onDelete(pm) }
) )
} }
item { LabelListBottomSpacer() }
} }
} }
/**
* Keeps the last row clear of the add button, which otherwise sits on top of its delete icon and
* swallows the tap - with nothing below to scroll to, that row simply cannot be deleted.
*/
@Composable
private fun LabelListBottomSpacer() {
Spacer(modifier = Modifier.height(64.dp).fillMaxWidth())
}
@Composable @Composable
fun LabelItem( fun LabelItem(
name: String, name: String,
@@ -132,6 +132,10 @@ fun BillsListScreen(
onProjectAction(projectOptionsProjectId, 1) onProjectAction(projectOptionsProjectId, 1)
viewModel.showProjectOptionsDialogByProjectId = null viewModel.showProjectOptionsDialogByProjectId = null
}, },
onForgetProject = {
onProjectAction(projectOptionsProjectId, 9)
viewModel.showProjectOptionsDialogByProjectId = null
},
onManageMembers = { onManageMembers = {
onProjectAction(projectOptionsProjectId, 2) onProjectAction(projectOptionsProjectId, 2)
viewModel.showProjectOptionsDialogByProjectId = null viewModel.showProjectOptionsDialogByProjectId = null
@@ -408,11 +412,9 @@ fun BillsListScreen(
.pullRefresh(pullRefreshState)) { .pullRefresh(pullRefreshState)) {
when { when {
viewModel.showNoProjects -> EmptyProjectsState(onAccountSwitcherClick, onAddProjectClick) viewModel.showNoProjects -> EmptyProjectsState(onAccountSwitcherClick, onAddProjectClick)
(viewModel.isRefreshing || viewModel.isLoadingBills) && viewModel.bills.isEmpty() ->
LoadingBillsState()
viewModel.showNoMembers -> EmptyMembersState() viewModel.showNoMembers -> EmptyMembersState()
// A large project takes a long time on its first sync. Until that finishes there
// is nothing stored for it yet, and reporting that as "no bills" tells the user
// the project is empty when it is still downloading.
viewModel.isRefreshing && viewModel.bills.isEmpty() -> LoadingBillsState()
viewModel.showNoBills -> EmptyBillsState() viewModel.showNoBills -> EmptyBillsState()
viewModel.bills.isEmpty() -> EmptyState() viewModel.bills.isEmpty() -> EmptyState()
else -> { else -> {
@@ -270,6 +270,7 @@ class BillsListViewActivity :
8 -> { 8 -> {
startActivity(net.helcel.cowspent.android.label.LabelManagementActivity.createIntent(this@BillsListViewActivity, pid)) startActivity(net.helcel.cowspent.android.label.LabelManagementActivity.createIntent(this@BillsListViewActivity, pid))
} }
9 -> onRemoveProjectClick(pid)
} }
}, },
onAccountSwitcherClick = { onAccountSwitcherClick = {
@@ -334,6 +335,7 @@ class BillsListViewActivity :
super.onResume() super.onResume()
val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext) val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
val selectedProjectId = preferences.getLong("selected_project", 0) val selectedProjectId = preferences.getLong("selected_project", 0)
setupDrawerProjects()
if (selectedProjectId != 0L) { if (selectedProjectId != 0L) {
refreshLists() refreshLists()
} }
@@ -341,6 +343,10 @@ class BillsListViewActivity :
synchronize(SyncTrigger.APP_OPEN) synchronize(SyncTrigger.APP_OPEN)
lifecycleScope.launch {
syncNewlyDiscoveredProjects(withContext(Dispatchers.IO) { db.projects })
}
registerBroadcastReceiver() registerBroadcastReceiver()
updateAvatarInDrawer(CowspentServerSyncHelper.isNextcloudAccountConfigured(this)) updateAvatarInDrawer(CowspentServerSyncHelper.isNextcloudAccountConfigured(this))
isActivityVisible = true isActivityVisible = true
@@ -377,6 +383,8 @@ class BillsListViewActivity :
fun onProjectClick(projectId: Long) { fun onProjectClick(projectId: Long) {
if (viewModel.selectedProjectId != projectId) { if (viewModel.selectedProjectId != projectId) {
viewModel.selectedMemberId = null viewModel.selectedMemberId = null
viewModel.bills = emptyList()
viewModel.isLoadingBills = true
} }
setSelectedProject(projectId) setSelectedProject(projectId)
navigationSelection = Category(null, null) navigationSelection = Category(null, null)
@@ -400,16 +408,34 @@ class BillsListViewActivity :
private fun onRemoveProjectClick(projectId: Long) { private fun onRemoveProjectClick(projectId: Long) {
if (projectId == 0L) return if (projectId == 0L) return
lifecycleScope.launch { lifecycleScope.launch {
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch val (proj, unsyncedBills) = withContext(Dispatchers.IO) {
val p = db.getProject(projectId) ?: return@withContext null
p to db.countUnsyncedBills(projectId)
} ?: return@launch
val message = buildString {
if (!proj.isLocal) append(getString(R.string.dialog_confirm_remove_project_msg))
if (unsyncedBills > 0) {
if (isNotEmpty()) append("\n\n")
append(
resources.getQuantityString(
R.plurals.warning_unsynced_bills, unsyncedBills, unsyncedBills
)
)
}
}
viewModel.showDialog( viewModel.showDialog(
title = getString(R.string.title_confirm), title = getString(R.string.title_confirm),
message = if (!proj.isLocal) getString(R.string.dialog_confirm_remove_project_msg) else null, message = message.ifEmpty { null },
positiveText = getString(R.string.simple_yes), positiveText = getString(R.string.simple_yes),
onConfirm = { onConfirm = {
lifecycleScope.launch { lifecycleScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
db.deleteProject(projectId) db.deleteProject(projectId)
// Otherwise the next account sync sees a project the account offers
// with no local row, and creates it again.
CowspentServerSyncHelper.forgetAccountProject(applicationContext, proj)
PreferenceManager.getDefaultSharedPreferences(applicationContext) PreferenceManager.getDefaultSharedPreferences(applicationContext)
.edit { remove(lastProjectSyncKey(projectId)) } .edit { remove(lastProjectSyncKey(projectId)) }
val dbProjects = db.projects val dbProjects = db.projects
@@ -621,76 +647,80 @@ class BillsListViewActivity :
val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0) val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0)
lifecycleScope.launch { lifecycleScope.launch {
val (projId, projName) = withContext(Dispatchers.IO) { try {
if (selectedProjectId != 0L) { val (projId, projName) = withContext(Dispatchers.IO) {
db.getProject(selectedProjectId)?.let { if (selectedProjectId != 0L) {
it.id to (if (it.name == "null" || it.name.isEmpty()) it.remoteId else it.name) db.getProject(selectedProjectId)?.let {
} ?: (0L to "") it.id to (if (it.name == "null" || it.name.isEmpty()) it.remoteId else it.name)
} else { } ?: (0L to "")
0L to "" } else {
} 0L to ""
} }
val title = if (selectedProjectId != 0L) projName else getString(R.string.app_name)
setSelectedProject(selectedProjectId)
viewModel.title = title
val query = viewModel.searchQuery.ifEmpty { null }
val (ljItems, memberCount) = withContext(Dispatchers.IO) {
val db = CowspentSQLiteOpenHelper.getInstance(applicationContext)
val billList: List<DBBill> = if (projId != 0L) {
db.searchBills(query, projId)
} else {
ArrayList()
} }
val bills = billList.filter { val title = if (selectedProjectId != 0L) projName else getString(R.string.app_name)
val mid = viewModel.selectedMemberId
mid == null || mid == it.payerId || it.billOwersIds.contains(mid) setSelectedProject(selectedProjectId)
viewModel.title = title
val query = viewModel.searchQuery.ifEmpty { null }
val (ljItems, memberCount) = withContext(Dispatchers.IO) {
val db = CowspentSQLiteOpenHelper.getInstance(applicationContext)
val billList: List<DBBill> = if (projId != 0L) {
db.searchBills(query, projId)
} else {
ArrayList()
}
val bills = billList.filter {
val mid = viewModel.selectedMemberId
mid == null || mid == it.payerId || it.billOwersIds.contains(mid)
}
viewModel.hasUnlabeledBills = bills.any { it.categoryId == 0L && it.state != DBBill.STATE_DELETED }
val projectMembers = db.getMembersOfProject(projId, null)
val memberMap = projectMembers.associateBy { it.id }
val projectPaymentModes = db.getPaymentModes(projId).associateBy { it.id }
val projectCategories = db.getCategories(projId).associateBy { it.id }
BillFormatter.formatBills(
bills,
memberMap,
projectCategories,
projectPaymentModes
)
val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
val itemList = BillsListUtils.groupAndSectionBills(
bills,
memberMap,
sdf,
applicationContext
)
itemList to projectMembers.size
} }
viewModel.hasUnlabeledBills = bills.any { it.categoryId == 0L && it.state != DBBill.STATE_DELETED } viewModel.showNoProjects = false
viewModel.showNoMembers = false
viewModel.showNoBills = false
val projectMembers = db.getMembersOfProject(projId, null) when {
val memberMap = projectMembers.associateBy { it.id } memberCount == 0 -> {
viewModel.showNoMembers = true
val projectPaymentModes = db.getPaymentModes(projId).associateBy { it.id } }
val projectCategories = db.getCategories(projId).associateBy { it.id } ljItems.isEmpty() -> {
viewModel.showNoBills = true
BillFormatter.formatBills( viewModel.bills = emptyList()
bills, }
memberMap, else -> {
projectCategories, viewModel.bills = ljItems
projectPaymentModes }
)
val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
val itemList = BillsListUtils.groupAndSectionBills(
bills,
memberMap,
sdf,
applicationContext
)
itemList to projectMembers.size
}
viewModel.showNoProjects = false
viewModel.showNoMembers = false
viewModel.showNoBills = false
when {
memberCount == 0 -> {
viewModel.showNoMembers = true
}
ljItems.isEmpty() -> {
viewModel.showNoBills = true
viewModel.bills = emptyList()
}
else -> {
viewModel.bills = ljItems
} }
} finally {
viewModel.isLoadingBills = false
} }
} }
} }
@@ -780,16 +810,19 @@ class BillsListViewActivity :
val selectedProjectId = preferences.getLong("selected_project", 0) val selectedProjectId = preferences.getLong("selected_project", 0)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
// The account and all-projects refresh belongs to opening the app, throttled by the // The account tells the app which projects exist at all, so a project joined elsewhere only
// SyncOnOpen interval so that resuming within the interval does not repeat it. // shows up once it has been read. That happens on login, on opening the app - throttled by
// the interval so resuming does not repeat it - and on a manual refresh, which is an
// explicit ask and so is never throttled.
val intervalMinutes = SyncSettings.OPEN_SYNC_INTERVAL_MINUTES val intervalMinutes = SyncSettings.OPEN_SYNC_INTERVAL_MINUTES
val lastAccountSync = preferences.getLong(getString(R.string.pref_key_last_account_sync_timestamp), 0L) val lastAccountSync = preferences.getLong(getString(R.string.pref_key_last_account_sync_timestamp), 0L)
val accountSyncDue = trigger == SyncTrigger.APP_OPEN && val openPass = trigger == SyncTrigger.APP_OPEN &&
now - lastAccountSync > intervalMinutes * 60 * 1000L now - lastAccountSync > intervalMinutes * 60 * 1000L
val accountSyncDue = openPass || trigger == SyncTrigger.MANUAL
lifecycleScope.launch { lifecycleScope.launch {
val remoteProjects = withContext(Dispatchers.IO) { db.projects } val remoteProjects = withContext(Dispatchers.IO) { db.projects }
.filter { !it.isLocal && !it.isArchived } .filter { !it.isLocal }
// Only the first one scheduled actually starts - the rest queue behind it - and it // Only the first one scheduled actually starts - the rest queue behind it - and it
// takes the screen's callback with it. So the selected project goes first: otherwise // takes the screen's callback with it. So the selected project goes first: otherwise
// the spinner follows a project the user is not looking at, stops when that one // the spinner follows a project the user is not looking at, stops when that one
@@ -799,7 +832,11 @@ class BillsListViewActivity :
viewModel.isRefreshing = true viewModel.isRefreshing = true
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack) db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
val started = if (accountSyncDue) { if (accountSyncDue && CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
db.cowspentServerSyncHelper.runAccountProjectsSync()
}
val started = if (openPass) {
// The pass is throttled by this stamp, so it has to be written here rather than // The pass is throttled by this stamp, so it has to be written here rather than
// left to the account sync: that only runs when an account is configured and only // left to the account sync: that only runs when an account is configured and only
// records itself on success, so without one the stamp stayed at zero and the pass // records itself on success, so without one the stamp stayed at zero and the pass
@@ -807,12 +844,13 @@ class BillsListViewActivity :
preferences.edit { preferences.edit {
putLong(getString(R.string.pref_key_last_account_sync_timestamp), now) putLong(getString(R.string.pref_key_last_account_sync_timestamp), now)
} }
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) { remoteProjects.filter { !it.isArchived }.count {
db.cowspentServerSyncHelper.runAccountProjectsSync() val full = neverSynced(it)
} val scheduled = db.cowspentServerSyncHelper.scheduleSync(false, it, full) != null
remoteProjects.count { if (scheduled) {
val scheduled = db.cowspentServerSyncHelper.scheduleSync(false, it, false) != null markProjectSynced(preferences, it.id, now)
if (scheduled) markProjectSynced(preferences, it.id, now) if (full) markFullySynced(preferences, it.id, now)
}
scheduled scheduled
} }
} else { } else {
@@ -824,13 +862,11 @@ class BillsListViewActivity :
// the back gesture does to a large project, since it destroys the activity while // the back gesture does to a large project, since it destroys the activity while
// the sync is still running. IHateMoney never advances the cursor, so the interval // the sync is still running. IHateMoney never advances the cursor, so the interval
// stays in charge there. // stays in charge there.
val neverCompleted = selectedProj != null && val neverCompleted = selectedProj != null && neverSynced(selectedProj)
selectedProj.type != ProjectType.IHATEMONEY &&
(selectedProj.lastSyncedTimestamp ?: 0L) == 0L
val due = trigger == SyncTrigger.MANUAL || neverCompleted || val due = trigger == SyncTrigger.MANUAL || neverCompleted ||
now - lastSync > SELECTED_PROJECT_SYNC_INTERVAL_MS now - lastSync > SELECTED_PROJECT_SYNC_INTERVAL_MS
val fullSync = trigger == SyncTrigger.MANUAL && val fullSync = neverCompleted || (trigger == SyncTrigger.MANUAL &&
!partialRefreshPreferred(preferences, selectedProjectId, now) !partialRefreshPreferred(preferences, selectedProjectId, now))
if (selectedProj != null && due && if (selectedProj != null && due &&
db.cowspentServerSyncHelper.scheduleSync(false, selectedProj, fullSync) != null db.cowspentServerSyncHelper.scheduleSync(false, selectedProj, fullSync) != null
) { ) {
@@ -846,6 +882,24 @@ class BillsListViewActivity :
} }
} }
private fun neverSynced(project: DBProject) =
project.type != ProjectType.IHATEMONEY && (project.lastSyncedTimestamp ?: 0L) == 0L
@VisibleForTesting
internal fun syncNewlyDiscoveredProjects(projects: List<DBProject>) {
if (!db.cowspentServerSyncHelper.isSyncPossible) return
val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
val now = System.currentTimeMillis()
projects
.filter { !it.isLocal && !it.isArchived && neverSynced(it) }
.forEach {
if (db.cowspentServerSyncHelper.scheduleSync(false, it, true) != null) {
markProjectSynced(preferences, it.id, now)
markFullySynced(preferences, it.id, now)
}
}
}
private fun lastProjectSyncKey(projectId: Long) = "lastProjectSyncTimestamp_$projectId" private fun lastProjectSyncKey(projectId: Long) = "lastProjectSyncTimestamp_$projectId"
private fun lastFullSyncKey(projectId: Long) = "lastFullSyncTimestamp_$projectId" private fun lastFullSyncKey(projectId: Long) = "lastFullSyncTimestamp_$projectId"
@@ -948,6 +1002,7 @@ class BillsListViewActivity :
synchronize(SyncTrigger.PROJECT_OPEN) synchronize(SyncTrigger.PROJECT_OPEN)
} }
} }
syncNewlyDiscoveredProjects(dbProjects)
} }
} }
} }
@@ -20,6 +20,7 @@ class BillsListViewModel : ViewModel() {
var selectedMemberId by mutableStateOf<Long?>(null) var selectedMemberId by mutableStateOf<Long?>(null)
var bills by mutableStateOf<List<Item>>(emptyList()) var bills by mutableStateOf<List<Item>>(emptyList())
var isRefreshing by mutableStateOf(false) var isRefreshing by mutableStateOf(false)
var isLoadingBills by mutableStateOf(false)
var searchQuery by mutableStateOf("") var searchQuery by mutableStateOf("")
var title by mutableStateOf("") var title by mutableStateOf("")
var accountName by mutableStateOf("") var accountName by mutableStateOf("")
@@ -24,6 +24,7 @@ import net.helcel.cowspent.model.ProjectType
fun ProjectOptionsDialogContent( fun ProjectOptionsDialogContent(
onEditProject: () -> Unit, onEditProject: () -> Unit,
onRemoveProject: () -> Unit, onRemoveProject: () -> Unit,
onForgetProject: () -> Unit = {},
onManageMembers: () -> Unit, onManageMembers: () -> Unit,
onManageCurrencies: () -> Unit, onManageCurrencies: () -> Unit,
onManageLabels: () -> Unit, onManageLabels: () -> Unit,
@@ -73,6 +74,15 @@ fun ProjectOptionsDialogContent(
val archiveLabel = if (isArchived) stringResource(R.string.action_unarchive) else stringResource(R.string.action_archive) val archiveLabel = if (isArchived) stringResource(R.string.action_unarchive) else stringResource(R.string.action_archive)
val archiveIcon = if (isArchived) Icons.Default.Unarchive else Icons.Default.Archive val archiveIcon = if (isArchived) Icons.Default.Unarchive else Icons.Default.Archive
row1.add(ProjectOption(archiveLabel, archiveIcon, onRemoveProject)) row1.add(ProjectOption(archiveLabel, archiveIcon, onRemoveProject))
if (isArchived) {
row1.add(
ProjectOption(
stringResource(R.string.action_forget),
Icons.Default.Delete,
onForgetProject
)
)
}
} else { } else {
row1.add(ProjectOption(stringResource(R.string.action_delete), Icons.Default.Delete, onRemoveProject)) row1.add(ProjectOption(stringResource(R.string.action_delete), Icons.Default.Delete, onRemoveProject))
} }
@@ -5,6 +5,7 @@ import android.os.Bundle
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@@ -15,8 +16,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.core.app.NavUtils import androidx.core.app.NavUtils
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import net.helcel.cowspent.R
import net.helcel.cowspent.android.about.AboutActivity import net.helcel.cowspent.android.about.AboutActivity
import net.helcel.cowspent.android.account.AccountActivity import net.helcel.cowspent.android.account.AccountActivity
import net.helcel.cowspent.android.helper.showToast
import net.helcel.cowspent.model.DBAccountProject
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.persistence.CowspentServerSyncHelper
import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.ColorUtils import net.helcel.cowspent.util.ColorUtils
@@ -25,6 +31,25 @@ import net.helcel.cowspent.util.ColorUtils
*/ */
class PreferencesActivity : AppCompatActivity() { class PreferencesActivity : AppCompatActivity() {
private val accountLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) {
setResult(RESULT_OK, result.data)
}
}
private fun restoreDeletedProjects(projects: List<DBAccountProject>) {
if (projects.isEmpty()) return
val syncHelper = CowspentSQLiteOpenHelper.getInstance(applicationContext).cowspentServerSyncHelper
syncHelper.restoreAccountProjects(projects)
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
syncHelper.runAccountProjectsSync()
}
setResult(RESULT_OK)
showToast(this, getString(R.string.settings_restore_deleted_projects_done))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -59,13 +84,14 @@ class PreferencesActivity : AppCompatActivity() {
SettingsScreen( SettingsScreen(
onBack = { NavUtils.navigateUpFromSameTask(this) }, onBack = { NavUtils.navigateUpFromSameTask(this) },
onAccountSettingsClick = { onAccountSettingsClick = {
startActivity(Intent(this, AccountActivity::class.java)) accountLauncher.launch(Intent(this, AccountActivity::class.java))
}, },
onAboutClick = { onAboutClick = {
startActivity(Intent(this, AboutActivity::class.java)) startActivity(Intent(this, AboutActivity::class.java))
}, },
onColorSelected = { appColor = it }, onColorSelected = { appColor = it },
onNightModeChanged = { nightMode = it } onNightModeChanged = { nightMode = it },
onRestoreDeletedProjects = { restoreDeletedProjects(it) }
) )
} }
} }
@@ -21,6 +21,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.RadioButton import androidx.compose.material.RadioButton
import androidx.compose.material.Scaffold import androidx.compose.material.Scaffold
import androidx.compose.material.Slider import androidx.compose.material.Slider
import androidx.compose.material.Checkbox
import androidx.compose.material.Switch import androidx.compose.material.Switch
import androidx.compose.material.SwitchDefaults import androidx.compose.material.SwitchDefaults
import androidx.compose.material.Text import androidx.compose.material.Text
@@ -34,11 +35,14 @@ import androidx.compose.material.icons.filled.Brightness2
import androidx.compose.material.icons.filled.Group import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Sync
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -47,6 +51,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@@ -56,6 +61,9 @@ import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import net.helcel.cowspent.R import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.ColorPicker import net.helcel.cowspent.android.helper.ColorPicker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.helcel.cowspent.model.DBAccountProject
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.persistence.CowspentServerSyncHelper import net.helcel.cowspent.persistence.CowspentServerSyncHelper
import net.helcel.cowspent.util.ColorUtils import net.helcel.cowspent.util.ColorUtils
@@ -68,7 +76,8 @@ fun SettingsScreen(
onAccountSettingsClick: () -> Unit, onAccountSettingsClick: () -> Unit,
onAboutClick: () -> Unit, onAboutClick: () -> Unit,
onColorSelected: (Int) -> Unit, onColorSelected: (Int) -> Unit,
onNightModeChanged: (String) -> Unit = {} onNightModeChanged: (String) -> Unit = {},
onRestoreDeletedProjects: (List<DBAccountProject>) -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) } val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) }
@@ -92,6 +101,16 @@ fun SettingsScreen(
val isNextcloudConfigured = CowspentServerSyncHelper.isNextcloudAccountConfigured(context) val isNextcloudConfigured = CowspentServerSyncHelper.isNextcloudAccountConfigured(context)
var deletedProjects by remember { mutableStateOf(emptyList<DBAccountProject>()) }
var showRestoreDialog by remember { mutableStateOf(false) }
// Off the main thread: this reads the account projects table.
LaunchedEffect(Unit) {
deletedProjects = withContext(Dispatchers.IO) {
CowspentSQLiteOpenHelper.getInstance(context).cowspentServerSyncHelper.deletedAccountProjects()
}
}
// States for preferences // States for preferences
var nightMode by remember(keyNightMode) { var nightMode by remember(keyNightMode) {
mutableStateOf(sharedPreferences.getString(keyNightMode, "-1") ?: "-1") mutableStateOf(sharedPreferences.getString(keyNightMode, "-1") ?: "-1")
@@ -257,6 +276,32 @@ fun SettingsScreen(
onClick = onAccountSettingsClick onClick = onAccountSettingsClick
) )
// Nothing held back means nothing to undo, so the row is not offered at all.
if (deletedProjects.isNotEmpty()) {
SettingsPreference(
title = stringResource(R.string.settings_restore_deleted_projects),
summary = pluralStringResource(
R.plurals.settings_restore_deleted_projects_summary,
deletedProjects.size,
deletedProjects.size
),
icon = Icons.Default.Restore,
onClick = { showRestoreDialog = true }
)
}
if (showRestoreDialog) {
RestoreDeletedProjectsDialog(
projects = deletedProjects,
onDismiss = { showRestoreDialog = false },
onConfirm = { chosen ->
showRestoreDialog = false
onRestoreDeletedProjects(chosen)
deletedProjects = deletedProjects - chosen.toSet()
}
)
}
// Other // Other
SettingsCategory(stringResource(R.string.settings_other)) SettingsCategory(stringResource(R.string.settings_other))
@@ -349,6 +394,60 @@ fun SettingsScreen(
} }
} }
@Composable
fun RestoreDeletedProjectsDialog(
projects: List<DBAccountProject>,
onDismiss: () -> Unit,
onConfirm: (List<DBAccountProject>) -> Unit
) {
val selected = remember(projects) { mutableStateListOf<DBAccountProject>().apply { addAll(projects) } }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_restore_deleted_projects)) },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
projects.forEach { project ->
val isSelected = project in selected
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = {
if (isSelected) selected.remove(project) else selected.add(project)
}
)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(checked = isSelected, onCheckedChange = null)
Spacer(Modifier.width(16.dp))
Column {
Text(
text = project.name.ifEmpty { project.remoteId },
style = MaterialTheme.typography.subtitle1
)
Text(text = project.ncUrl, style = MaterialTheme.typography.caption)
}
}
}
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(selected.toList()) },
enabled = selected.isNotEmpty()
) {
Text(stringResource(R.string.settings_restore_deleted_projects_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.simple_cancel)) }
}
)
}
@Composable @Composable
fun SettingsCategory(title: String) { fun SettingsCategory(title: String) {
Text( Text(
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.database.DatabaseUtils
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper import android.database.sqlite.SQLiteOpenHelper
import android.text.TextUtils import android.text.TextUtils
@@ -238,13 +239,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread @WorkerThread
private fun getProjectsCustom(selection: String, selectionArgs: Array<String>, orderBy: String?, db: SQLiteDatabase): List<DBProject> { private fun getProjectsCustom(selection: String, selectionArgs: Array<String>, orderBy: String?, db: SQLiteDatabase): List<DBProject> {
val cursor = db.query(table_projects, columnsProjects, selection, selectionArgs, null, null, orderBy) return queryAll(db, table_projects, columnsProjects, selection, selectionArgs, orderBy, ::getProjectFromCursor)
val projects: MutableList<DBProject> = ArrayList()
while (cursor.moveToNext()) {
projects.add(getProjectFromCursor(cursor))
}
cursor.close()
return projects
} }
@SuppressLint("Range") @SuppressLint("Range")
@@ -305,11 +300,17 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
fun deleteProject(id: Long) { fun deleteProject(id: Long) {
val db = writableDatabase val db = writableDatabase
for (b in getBillsOfProject(id)) { val projectId = arrayOf(id.toString())
deleteBill(b.id) inTransaction {
db.delete(
table_billowers,
"$key_billId IN (SELECT $key_id FROM $table_bills WHERE $key_projectid = ?)",
projectId
)
db.delete(table_bills, "$key_projectid = ?", projectId)
db.delete(table_members, "$key_projectid = ?", projectId)
db.delete(table_projects, "$key_id = ?", projectId)
} }
db.delete(table_members, "$key_projectid = ?", arrayOf(id.toString()))
db.delete(table_projects, "$key_id = ?", arrayOf(id.toString()))
SecureStorage.removePasswordSync(context, "ProjectPassword_$id") SecureStorage.removePasswordSync(context, "ProjectPassword_$id")
} }
@@ -413,14 +414,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread @WorkerThread
private fun getMembersCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBMember> { private fun getMembersCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBMember> {
val db = readableDatabase return queryAll(readableDatabase, table_members, columnsMembers, selection, selectionArgs, orderBy, ::getMemberFromCursor)
val cursor = db.query(table_members, columnsMembers, selection, selectionArgs, null, null, orderBy)
val members: MutableList<DBMember> = ArrayList()
while (cursor.moveToNext()) {
members.add(getMemberFromCursor(cursor))
}
cursor.close()
return members
} }
@SuppressLint("Range") @SuppressLint("Range")
@@ -561,6 +555,14 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
return getBillsCustom("$key_projectid = ?", arrayOf(projId.toString()), "$key_timestamp ASC") return getBillsCustom("$key_projectid = ?", arrayOf(projId.toString()), "$key_timestamp ASC")
} }
@WorkerThread
fun countUnsyncedBills(projId: Long): Int = DatabaseUtils.queryNumEntries(
readableDatabase,
table_bills,
"$key_projectid = ? AND $key_state != ?",
arrayOf(projId.toString(), DBBill.STATE_OK.toString())
).toInt()
fun getBillsOfProjectWithState(projId: Long, state: Int): List<DBBill> { fun getBillsOfProjectWithState(projId: Long, state: Int): List<DBBill> {
return getBillsCustom( return getBillsCustom(
"$key_projectid = ? AND $key_state = ?", "$key_projectid = ? AND $key_state = ?",
@@ -644,29 +646,20 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread @WorkerThread
private fun getBillsCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBBill> { private fun getBillsCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBBill> {
val db = readableDatabase val db = readableDatabase
val cursor = db.query(table_bills, columnsBills, selection, selectionArgs, null, null, orderBy) return db.transactionally {
val bills: MutableList<DBBill> = ArrayList() val bills = queryAll(db, table_bills, columnsBills, selection, selectionArgs, orderBy, ::getBillFromCursor)
while (cursor.moveToNext()) { if (bills.isNotEmpty()) {
bills.add(getBillFromCursor(cursor)) val owersByBill = queryAll(
db, table_billowers, columnsBillowers,
"$key_billId IN (SELECT $key_id FROM $table_bills WHERE $selection)",
selectionArgs, null, ::getBillOwerFromCursor
).groupBy { it.billId }
for (bill in bills) {
bill.billOwers = owersByBill[bill.id].orEmpty()
}
}
bills
} }
cursor.close()
if (bills.isEmpty()) return bills
// Every ower of the matched bills in one query, keyed back to its bill.
//
// Fetching them per bill meant a project with thousands of bills issued thousands of
// queries on every list refresh - and that refresh runs on resume, on switching project,
// and after each sync. The bill ids are re-selected as a subquery rather than bound as
// arguments, which would blow past SQLite's limit on bound variables for a large project.
val owersByBill = getBillOwersCustom(
"$key_billId IN (SELECT $key_id FROM $table_bills WHERE $selection)",
selectionArgs,
null
).groupBy { it.billId }
for (bill in bills) {
bill.billOwers = owersByBill[bill.id].orEmpty()
}
return bills
} }
@SuppressLint("Range") @SuppressLint("Range")
@@ -714,14 +707,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread @WorkerThread
private fun getBillOwersCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBBillOwer> { private fun getBillOwersCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBBillOwer> {
val db = readableDatabase return queryAll(readableDatabase, table_billowers, columnsBillowers, selection, selectionArgs, orderBy, ::getBillOwerFromCursor)
val cursor = db.query(table_billowers, columnsBillowers, selection, selectionArgs, null, null, orderBy)
val billOwers: MutableList<DBBillOwer> = ArrayList()
while (cursor.moveToNext()) {
billOwers.add(getBillOwerFromCursor(cursor))
}
cursor.close()
return billOwers
} }
@SuppressLint("Range") @SuppressLint("Range")
@@ -788,14 +774,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread @WorkerThread
private fun getCategoriesCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBCategory> { private fun getCategoriesCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBCategory> {
val db = readableDatabase return queryAll(readableDatabase, table_categories, columnsCategories, selection, selectionArgs, orderBy, ::getCategoryFromCursor)
val cursor = db.query(table_categories, columnsCategories, selection, selectionArgs, null, null, orderBy)
val categories: MutableList<DBCategory> = ArrayList()
while (cursor.moveToNext()) {
categories.add(getCategoryFromCursor(cursor))
}
cursor.close()
return categories
} }
@SuppressLint("Range") @SuppressLint("Range")
@@ -904,13 +883,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread @WorkerThread
private fun getPaymentModesCustom(selection: String, selectionArgs: Array<String>, orderBy: String?, db: SQLiteDatabase): List<DBPaymentMode> { private fun getPaymentModesCustom(selection: String, selectionArgs: Array<String>, orderBy: String?, db: SQLiteDatabase): List<DBPaymentMode> {
val cursor = db.query(table_payment_modes, columnsPaymentModes, selection, selectionArgs, null, null, orderBy) return queryAll(db, table_payment_modes, columnsPaymentModes, selection, selectionArgs, orderBy, ::getPaymentModeFromCursor)
val paymentModes: MutableList<DBPaymentMode> = ArrayList()
while (cursor.moveToNext()) {
paymentModes.add(getPaymentModeFromCursor(cursor))
}
cursor.close()
return paymentModes
} }
@SuppressLint("Range") @SuppressLint("Range")
@@ -1020,13 +993,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread @WorkerThread
private fun getCurrenciesCustom(selection: String, selectionArgs: Array<String>, orderBy: String?, db: SQLiteDatabase): List<DBCurrency> { private fun getCurrenciesCustom(selection: String, selectionArgs: Array<String>, orderBy: String?, db: SQLiteDatabase): List<DBCurrency> {
val cursor = db.query(table_currencies, columnsCurrencies, selection, selectionArgs, null, null, orderBy) return queryAll(db, table_currencies, columnsCurrencies, selection, selectionArgs, orderBy, ::getCurrencyFromCursor)
val currencies: MutableList<DBCurrency> = ArrayList()
while (cursor.moveToNext()) {
currencies.add(getCurrencyFromCursor(cursor))
}
cursor.close()
return currencies
} }
@SuppressLint("Range") @SuppressLint("Range")
@@ -1074,6 +1041,36 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
// --- Common Helpers --- // --- Common Helpers ---
@WorkerThread
private fun <T> queryAll(
db: SQLiteDatabase,
table: String,
columns: Array<String>,
selection: String?,
selectionArgs: Array<String>?,
orderBy: String?,
fromCursor: (Cursor) -> T
): List<T> = db.transactionally {
val items: MutableList<T> = ArrayList()
db.query(table, columns, selection, selectionArgs, null, null, orderBy).use { cursor ->
while (cursor.moveToNext()) {
items.add(fromCursor(cursor))
}
}
items
}
private fun <T> SQLiteDatabase.transactionally(block: () -> T): T {
beginTransaction()
try {
val result = block()
setTransactionSuccessful()
return result
} finally {
endTransaction()
}
}
fun syncIfRemote(proj: DBProject) { fun syncIfRemote(proj: DBProject) {
if (!proj.isLocal) { if (!proj.isLocal) {
val preferences = PreferenceManager.getDefaultSharedPreferences(context) val preferences = PreferenceManager.getDefaultSharedPreferences(context)
@@ -1146,13 +1143,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread @WorkerThread
private fun getAccountProjectsCustom(selection: String, selectionArgs: Array<String>, orderBy: String?, db: SQLiteDatabase): List<DBAccountProject> { private fun getAccountProjectsCustom(selection: String, selectionArgs: Array<String>, orderBy: String?, db: SQLiteDatabase): List<DBAccountProject> {
val cursor = db.query(table_account_projects, columnsAccountProjects, selection, selectionArgs, null, null, orderBy) return queryAll(db, table_account_projects, columnsAccountProjects, selection, selectionArgs, orderBy, ::getAccountProjectFromCursor)
val accountProjects: MutableList<DBAccountProject> = ArrayList()
while (cursor.moveToNext()) {
accountProjects.add(getAccountProjectFromCursor(cursor))
}
cursor.close()
return accountProjects
} }
@SuppressLint("Range") @SuppressLint("Range")
@@ -6,6 +6,7 @@ import android.content.SharedPreferences
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.util.Log import android.util.Log
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
@@ -14,6 +15,7 @@ import com.nextcloud.android.sso.api.NextcloudAPI
import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException
import com.nextcloud.android.sso.exceptions.TokenMismatchException import com.nextcloud.android.sso.exceptions.TokenMismatchException
import com.nextcloud.android.sso.helper.SingleAccountHelper import com.nextcloud.android.sso.helper.SingleAccountHelper
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -26,6 +28,7 @@ import net.helcel.cowspent.R
import net.helcel.cowspent.android.account.AccountActivity import net.helcel.cowspent.android.account.AccountActivity
import net.helcel.cowspent.android.main.BillsListViewActivity import net.helcel.cowspent.android.main.BillsListViewActivity
import net.helcel.cowspent.android.main.MainConstants import net.helcel.cowspent.android.main.MainConstants
import net.helcel.cowspent.model.DBAccountProject
import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBMember import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.model.DBProject import net.helcel.cowspent.model.DBProject
@@ -176,14 +179,22 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} }
fun execute(): SyncTask { fun execute(): SyncTask {
syncActive = true
deferred = scope.async { deferred = scope.async {
syncActive = true try {
val status = withContext(Dispatchers.IO) { val status = withContext(Dispatchers.IO) {
doWork() doWork()
}
onPostExecute(status)
status
} catch (e: CancellationException) {
syncActive = false
throw e
} catch (e: Exception) {
Log.e(TAG, "Sync failed for ${project.remoteId}", e)
syncActive = false
LoginStatus.CONNECTION_FAILED
} }
onPostExecute(status)
syncActive = false
status
} }
return this return this
} }
@@ -293,12 +304,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
null, null, null, null, null null, null, null, null, null
) )
} }
} catch (e: IOException) { } catch (e: Exception) {
if (e.message == "{\"message\": \"Internal Server Error\"}") { Log.e(TAG, "EDIT MEMBER FAILED for ${mToEdit.name}", e)
Log.d(TAG, "EDIT MEMBER FAILED : it does not exist remotely")
} else {
throw e
}
} }
} }
@@ -1048,6 +1055,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
for (localMember in dbHelper.getMembersOfProject(project.id, null)) { for (localMember in dbHelper.getMembersOfProject(project.id, null)) {
if (remoteMembersByRemoteId.containsKey(localMember.remoteId)) continue if (remoteMembersByRemoteId.containsKey(localMember.remoteId)) continue
if (localMember.state != DBBill.STATE_OK) continue
// A member still named by a bill cannot be removed without orphaning it. // A member still named by a bill cannot be removed without orphaning it.
if (dbHelper.getBillsOfMember(localMember.id).isEmpty() && if (dbHelper.getBillsOfMember(localMember.id).isEmpty() &&
dbHelper.getBillowersOfMember(localMember.id).isEmpty() dbHelper.getBillowersOfMember(localMember.id).isEmpty()
@@ -1468,6 +1477,21 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} }
} }
@WorkerThread
fun deletedAccountProjects(): List<DBAccountProject> {
val forgotten = forgottenAccountProjects(preferences)
if (forgotten.isEmpty()) return emptyList()
return dbHelper.accountProjects.filter { accountProjectKey(it.remoteId, it.ncUrl) in forgotten }
}
fun restoreAccountProjects(projects: List<DBAccountProject>) {
if (projects.isEmpty()) return
val restored = projects.map { accountProjectKey(it.remoteId, it.ncUrl) }.toSet()
preferences.edit {
putStringSet(FORGOTTEN_ACCOUNT_PROJECTS, forgottenAccountProjects(preferences) - restored)
}
}
fun runAccountProjectsSync() { fun runAccountProjectsSync() {
Log.d(TAG, "Account projects sync requested; ${if (syncAccountProjectsActive) "sync active" else "sync NOT active"}) ...") Log.d(TAG, "Account projects sync requested; ${if (syncAccountProjectsActive) "sync active" else "sync NOT active"}) ...")
updateNetworkStatus() updateNetworkStatus()
@@ -1514,11 +1538,18 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
fun execute(): SyncAccountProjectsTask { fun execute(): SyncAccountProjectsTask {
scope.launch { scope.launch {
syncAccountProjectsActive = true syncAccountProjectsActive = true
val status = withContext(Dispatchers.IO) { try {
doWork() val status = withContext(Dispatchers.IO) {
doWork()
}
onPostExecute(status)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "Account projects sync failed", e)
} finally {
syncAccountProjectsActive = false
} }
onPostExecute(status)
syncAccountProjectsActive = false
} }
return this return this
} }
@@ -1547,20 +1578,25 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val response = client.getAccountProjects(useOcsApi) val response = client.getAccountProjects(useOcsApi)
val remoteAccountProjects = response.getAccountProjects(url) val remoteAccountProjects = response.getAccountProjects(url)
val forgotten = forgottenAccountProjects(preferences)
dbHelper.clearAccountProjects() dbHelper.clearAccountProjects()
for (remoteAccountProject in remoteAccountProjects) { for (remoteAccountProject in remoteAccountProjects) {
dbHelper.addAccountProject(remoteAccountProject) dbHelper.addAccountProject(remoteAccountProject)
Log.v(TAG, "received account project $remoteAccountProject") Log.v(TAG, "received account project $remoteAccountProject")
val existingProj = localProjects.find { val existingProj = localProjects.find {
it.remoteId == remoteAccountProject.remoteId && it.remoteId == remoteAccountProject.remoteId &&
it.serverUrl?.replace("/+$".toRegex(), "") == remoteAccountProject.ncUrl.replace("/+$".toRegex(), "") + "/index.php/apps/cospend" it.serverUrl?.replace("/+$".toRegex(), "") == remoteAccountProject.ncUrl.replace("/+$".toRegex(), "") + COSPEND_PATH
} }
if (existingProj == null) { if (existingProj == null) {
if (accountProjectKey(remoteAccountProject.remoteId, remoteAccountProject.ncUrl) in forgotten) {
Log.d(TAG, "skipping ${remoteAccountProject.remoteId}, deleted on this device")
continue
}
val newProj = DBProject(0, val newProj = DBProject(0,
remoteAccountProject.remoteId, remoteAccountProject.remoteId,
"", "",
remoteAccountProject.name, remoteAccountProject.name,
remoteAccountProject.ncUrl.replace("/+$".toRegex(), "") + "/index.php/apps/cospend", remoteAccountProject.ncUrl.replace("/+$".toRegex(), "") + COSPEND_PATH,
"", "",
null, null,
ProjectType.COSPEND, ProjectType.COSPEND,
@@ -1896,6 +1932,34 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false) preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)
} }
/** The path a Cospend project's URL carries on top of its Nextcloud server URL. */
const val COSPEND_PATH = "/index.php/apps/cospend"
private const val FORGOTTEN_ACCOUNT_PROJECTS = "forgottenAccountProjects"
private fun trimSlashes(url: String) = url.replace("/+$".toRegex(), "")
/** Names one project an account offers, by server and remote id, with no local row needed. */
private fun accountProjectKey(remoteId: String, ncUrl: String) = "${trimSlashes(ncUrl)}|$remoteId"
/** The same key for a stored project, or null when it is not one an account can offer. */
private fun accountProjectKey(project: DBProject): String? {
val url = trimSlashes(project.serverUrl.orEmpty())
if (!url.endsWith(COSPEND_PATH)) return null
return accountProjectKey(project.remoteId, url.removeSuffix(COSPEND_PATH))
}
private fun forgottenAccountProjects(preferences: SharedPreferences): Set<String> =
preferences.getStringSet(FORGOTTEN_ACCOUNT_PROJECTS, emptySet()).orEmpty()
fun forgetAccountProject(context: Context, project: DBProject) {
val key = accountProjectKey(project) ?: return
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
preferences.edit {
putStringSet(FORGOTTEN_ACCOUNT_PROJECTS, forgottenAccountProjects(preferences) + key)
}
}
fun getNextcloudAccountServerUrl(context: Context): String { fun getNextcloudAccountServerUrl(context: Context): String {
val preferences = PreferenceManager.getDefaultSharedPreferences(context) val preferences = PreferenceManager.getDefaultSharedPreferences(context)
return if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) { return if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) {
+21
View File
@@ -17,6 +17,7 @@
<string name="simple_back">Back</string> <string name="simple_back">Back</string>
<string name="action_archive">Archive</string> <string name="action_archive">Archive</string>
<string name="action_unarchive">Unarchive</string> <string name="action_unarchive">Unarchive</string>
<string name="action_forget">Delete locally</string>
<string name="action_export">Export</string> <string name="action_export">Export</string>
<string name="action_stats">Stats</string> <string name="action_stats">Stats</string>
<string name="action_settle">Settle</string> <string name="action_settle">Settle</string>
@@ -24,6 +25,8 @@
<string name="action_settings">Settings</string> <string name="action_settings">Settings</string>
<string name="action_label_bills">Label missing categories</string> <string name="action_label_bills">Label missing categories</string>
<string name="action_logout">Logout</string> <string name="action_logout">Logout</string>
<string name="logout_confirm_title">Log out?</string>
<string name="logout_confirm_no_projects">You can sign back in at any time.</string>
<string name="action_connect">Connect</string> <string name="action_connect">Connect</string>
<string name="action_discard">Discard</string> <string name="action_discard">Discard</string>
<string name="action_members">Members</string> <string name="action_members">Members</string>
@@ -136,6 +139,9 @@
<string name="settings_night_mode">Theme</string> <string name="settings_night_mode">Theme</string>
<string name="settings_offline_mode">Offline mode</string> <string name="settings_offline_mode">Offline mode</string>
<string name="settings_offline_mode_summary">Only sync manually.</string> <string name="settings_offline_mode_summary">Only sync manually.</string>
<string name="settings_restore_deleted_projects">Restore deleted projects</string>
<string name="settings_restore_deleted_projects_done">Restoring from the account</string>
<string name="settings_restore_deleted_projects_confirm">Restore</string>
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string> <string name="settings_show_archived">Show archived projects</string>
@@ -290,4 +296,19 @@
<string name="share_intent_title">Project %1$s</string> <string name="share_intent_title">Project %1$s</string>
<string name="share_chooser_title">Share %1$s</string> <string name="share_chooser_title">Share %1$s</string>
<plurals name="settings_restore_deleted_projects_summary">
<item quantity="one">%d project the account offers is kept off this device.</item>
<item quantity="other">%d projects the account offers are kept off this device.</item>
</plurals>
<plurals name="logout_confirm_projects">
<item quantity="one">%d project from this account will be removed from this device. It comes back when you sign in again.</item>
<item quantity="other">%d projects from this account will be removed from this device. They come back when you sign in again.</item>
</plurals>
<plurals name="warning_unsynced_bills">
<item quantity="one">%d bill has not reached the server yet and will be lost.</item>
<item quantity="other">%d bills have not reached the server yet and will be lost.</item>
</plurals>
</resources> </resources>