Warn if logout/delete would impact unsynced data
This commit is contained in:
@@ -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 db = CowspentSQLiteOpenHelper.getInstance(getApplication())
|
|
||||||
val offered = db.accountProjects
|
val offered = db.accountProjects
|
||||||
val cospendPath = "/index.php/apps/cospend"
|
val cospendPath = "/index.php/apps/cospend"
|
||||||
for (project in db.projects) {
|
return db.projects.filter { project ->
|
||||||
val matches = offered.any {
|
offered.any {
|
||||||
it.remoteId == project.remoteId &&
|
it.remoteId == project.remoteId &&
|
||||||
project.serverUrl?.replace("/+$".toRegex(), "") ==
|
project.serverUrl?.replace("/+$".toRegex(), "") ==
|
||||||
it.ncUrl.replace("/+$".toRegex(), "") + cospendPath
|
it.ncUrl.replace("/+$".toRegex(), "") + cospendPath
|
||||||
}
|
}
|
||||||
if (matches) db.deleteProject(project.id)
|
|
||||||
}
|
}
|
||||||
db.clearAccountProjects()
|
}
|
||||||
|
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) {
|
} catch (e: Exception) {
|
||||||
Log.e("AccountViewModel", "Could not remove the account's projects on logout", e)
|
// 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() }
|
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 = ?",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user