Compare commits
10
Commits
d2d713c616
...
fix-sync
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
405ff5c525
|
||
|
|
49a275c53e
|
||
|
|
b7037d8074
|
||
|
|
98b7bcaaf7
|
||
|
|
50c82068a4
|
||
|
|
a83601c0c0
|
||
|
|
5bc83e22ba
|
||
|
|
d6ee50bc34
|
||
|
|
06f31571a3
|
||
|
|
c70bb808c1
|
@@ -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)
|
val title = if (selectedProjectId != 0L) projName else getString(R.string.app_name)
|
||||||
|
|
||||||
setSelectedProject(selectedProjectId)
|
setSelectedProject(selectedProjectId)
|
||||||
viewModel.title = title
|
viewModel.title = title
|
||||||
val query = viewModel.searchQuery.ifEmpty { null }
|
val query = viewModel.searchQuery.ifEmpty { null }
|
||||||
|
|
||||||
val (ljItems, memberCount) = withContext(Dispatchers.IO) {
|
val (ljItems, memberCount) = withContext(Dispatchers.IO) {
|
||||||
val db = CowspentSQLiteOpenHelper.getInstance(applicationContext)
|
val db = CowspentSQLiteOpenHelper.getInstance(applicationContext)
|
||||||
val billList: List<DBBill> = if (projId != 0L) {
|
val billList: List<DBBill> = if (projId != 0L) {
|
||||||
db.searchBills(query, projId)
|
db.searchBills(query, projId)
|
||||||
} else {
|
} else {
|
||||||
ArrayList()
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
val bills = billList.filter {
|
viewModel.showNoProjects = false
|
||||||
val mid = viewModel.selectedMemberId
|
viewModel.showNoMembers = false
|
||||||
mid == null || mid == it.payerId || it.billOwersIds.contains(mid)
|
viewModel.showNoBills = false
|
||||||
}
|
|
||||||
|
when {
|
||||||
viewModel.hasUnlabeledBills = bills.any { it.categoryId == 0L && it.state != DBBill.STATE_DELETED }
|
memberCount == 0 -> {
|
||||||
|
viewModel.showNoMembers = true
|
||||||
val projectMembers = db.getMembersOfProject(projId, null)
|
}
|
||||||
val memberMap = projectMembers.associateBy { it.id }
|
ljItems.isEmpty() -> {
|
||||||
|
viewModel.showNoBills = true
|
||||||
val projectPaymentModes = db.getPaymentModes(projId).associateBy { it.id }
|
viewModel.bills = emptyList()
|
||||||
val projectCategories = db.getCategories(projId).associateBy { it.id }
|
}
|
||||||
|
else -> {
|
||||||
BillFormatter.formatBills(
|
viewModel.bills = ljItems
|
||||||
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.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")
|
||||||
@@ -560,6 +554,14 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
|
|||||||
fun getBillsOfProject(projId: Long): List<DBBill> {
|
fun getBillsOfProject(projId: Long): List<DBBill> {
|
||||||
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(
|
||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user