Warn if logout/delete would impact unsynced data

This commit is contained in:
2026-09-10 01:58:45 +02:00
parent 49a275c53e
commit 405ff5c525
6 changed files with 152 additions and 22 deletions
@@ -109,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() }
) )
} }
@@ -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)) }
}
) )
} }
@@ -6,6 +6,7 @@ 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
@@ -13,11 +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.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))
@@ -43,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)
@@ -90,25 +100,60 @@ class AccountViewModel(application: Application) : AndroidViewModel(application)
} }
} }
private fun forgetProjectsTheAccountProvided() { private fun projectsTheAccountProvided(db: CowspentSQLiteOpenHelper): List<DBProject> {
try { val offered = db.accountProjects
val db = CowspentSQLiteOpenHelper.getInstance(getApplication()) val cospendPath = "/index.php/apps/cospend"
val offered = db.accountProjects return db.projects.filter { project ->
val cospendPath = "/index.php/apps/cospend" offered.any {
for (project in db.projects) { it.remoteId == project.remoteId &&
val matches = offered.any { project.serverUrl?.replace("/+$".toRegex(), "") ==
it.remoteId == project.remoteId && it.ncUrl.replace("/+$".toRegex(), "") + cospendPath
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)
} }
if (matches) db.deleteProject(project.id)
} }
db.clearAccountProjects()
} catch (e: Exception) {
Log.e("AccountViewModel", "Could not remove the account's projects on logout", e)
} }
} }
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() } withContext(Dispatchers.IO) { forgetProjectsTheAccountProvided() }
@@ -408,11 +408,26 @@ 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 {
@@ -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
@@ -554,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 = ?",
+12
View File
@@ -25,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>
@@ -299,4 +301,14 @@
<item quantity="one">%d project the account offers is kept off this device.</item> <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> <item quantity="other">%d projects the account offers are kept off this device.</item>
</plurals> </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>