12 Commits
14 changed files with 826 additions and 431 deletions
@@ -1,7 +1,6 @@
package net.helcel.cowspent.android.account package net.helcel.cowspent.android.account
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Build import android.os.Build
@@ -38,6 +37,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.helcel.cowspent.R import net.helcel.cowspent.R
import net.helcel.cowspent.android.main.MainConstants import net.helcel.cowspent.android.main.MainConstants
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.persistence.CowspentServerSyncHelper
import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.CospendClientUtil import net.helcel.cowspent.util.CospendClientUtil
import net.helcel.cowspent.util.CospendClientUtil.LoginStatus import net.helcel.cowspent.util.CospendClientUtil.LoginStatus
@@ -108,8 +109,7 @@ class AccountActivity : AppCompatActivity() {
viewModel.useSso = false viewModel.useSso = false
preferences.edit { putBoolean(SETTINGS_USE_SSO, false) } preferences.edit { putBoolean(SETTINGS_USE_SSO, false) }
} }
}, }
onLogout = { viewModel.logout() }
) )
} }
@@ -147,7 +147,7 @@ class AccountActivity : AppCompatActivity() {
allowFileAccess = false allowFileAccess = false
javaScriptEnabled = true javaScriptEnabled = true
domStorageEnabled = true domStorageEnabled = true
userAgentString = getWebLoginUserAgent(context) userAgentString = getWebLoginUserAgent()
} }
webViewClient = object : WebViewClient() { webViewClient = object : WebViewClient() {
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
@@ -188,6 +188,12 @@ class AccountActivity : AppCompatActivity() {
} }
} }
private fun startInitialAccountSync() {
if (!CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) return
CowspentSQLiteOpenHelper.getInstance(applicationContext)
.cowspentServerSyncHelper.runAccountProjectsSync()
}
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
@@ -207,6 +213,8 @@ class AccountActivity : AppCompatActivity() {
viewModel.serverUrl = ssoAccount.url viewModel.serverUrl = ssoAccount.url
viewModel.username = ssoAccount.userId viewModel.username = ssoAccount.userId
startInitialAccountSync()
val resultData = Intent() val resultData = Intent()
resultData.putExtra(MainConstants.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED) resultData.putExtra(MainConstants.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED)
setResult(RESULT_OK, resultData) setResult(RESULT_OK, resultData)
@@ -234,13 +242,9 @@ class AccountActivity : AppCompatActivity() {
} }
} }
private fun getWebLoginUserAgent(context: Context): String { private fun getWebLoginUserAgent(): String {
val defaultUA = try { val manufacturer = Build.MANUFACTURER.replaceFirstChar { it.titlecase(Locale.ROOT) }
android.webkit.WebSettings.getDefaultUserAgent(context) return "$manufacturer ${Build.MODEL} (Cowspent/Android)"
} catch (_: Exception) {
Build.MANUFACTURER + " " + Build.MODEL
}
return "$defaultUA Cowspent/Android"
} }
private fun parseAndLoginFromWebView(dataString: String) { private fun parseAndLoginFromWebView(dataString: String) {
@@ -312,6 +316,8 @@ class AccountActivity : AppCompatActivity() {
remove(SETTINGS_KEY_LAST_MODIFIED) remove(SETTINGS_KEY_LAST_MODIFIED)
} }
startInitialAccountSync()
val data = Intent() val data = Intent()
data.putExtra(MainConstants.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED) data.putExtra(MainConstants.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED)
setResult(RESULT_OK, data) setResult(RESULT_OK, data)
@@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -28,8 +29,7 @@ fun AccountScreen(
viewModel: AccountViewModel, viewModel: AccountViewModel,
onBack: () -> Unit, onBack: () -> Unit,
onConnect: () -> Unit, onConnect: () -> Unit,
onSsoClick: (Boolean) -> Unit, onSsoClick: (Boolean) -> Unit
onLogout: () -> Unit
) { ) {
AccountScreenContent( AccountScreenContent(
isLoggedIn = viewModel.isLoggedIn, isLoggedIn = viewModel.isLoggedIn,
@@ -51,7 +51,57 @@ fun AccountScreen(
onBack = onBack, onBack = onBack,
onConnect = onConnect, onConnect = onConnect,
onSsoClick = onSsoClick, onSsoClick = onSsoClick,
onLogout = onLogout onLogout = { viewModel.requestLogout() }
)
viewModel.logoutImpact?.let { impact ->
LogoutConfirmationDialog(
impact = impact,
onDismiss = { viewModel.cancelLogout() },
onConfirm = { viewModel.confirmLogout() }
)
}
}
@Composable
fun LogoutConfirmationDialog(
impact: AccountViewModel.LogoutImpact,
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.logout_confirm_title)) },
text = {
Column {
Text(
if (impact.projects > 0) {
pluralStringResource(
R.plurals.logout_confirm_projects, impact.projects, impact.projects
)
} else {
stringResource(R.string.logout_confirm_no_projects)
}
)
if (impact.unsyncedBills > 0) {
Spacer(Modifier.height(12.dp))
Text(
text = pluralStringResource(
R.plurals.warning_unsynced_bills,
impact.unsyncedBills,
impact.unsyncedBills
),
fontWeight = FontWeight.Bold
)
}
}
},
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_logout)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.simple_cancel)) }
}
) )
} }
@@ -2,9 +2,11 @@ package net.helcel.cowspent.android.account
import android.app.Application import android.app.Application
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.annotation.VisibleForTesting
import androidx.core.content.edit import androidx.core.content.edit
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -12,10 +14,17 @@ import androidx.preference.PreferenceManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.util.CospendClientUtil import net.helcel.cowspent.util.CospendClientUtil
import net.helcel.cowspent.util.SecureStorage import net.helcel.cowspent.util.SecureStorage
class AccountViewModel(application: Application) : AndroidViewModel(application) { class AccountViewModel(application: Application) : AndroidViewModel(application) {
private companion object {
const val TAG = "AccountViewModel"
}
private val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(application) private val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(application)
var useSso by mutableStateOf(preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) var useSso by mutableStateOf(preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false))
@@ -41,6 +50,9 @@ class AccountViewModel(application: Application) : AndroidViewModel(application)
var showWebView by mutableStateOf(false) var showWebView by mutableStateOf(false)
var isLoggedIn by mutableStateOf(false) var isLoggedIn by mutableStateOf(false)
/** Non-null while the logout confirmation is up, carrying what it would cost. */
var logoutImpact by mutableStateOf<LogoutImpact?>(null)
private set private set
var isValidatingLogin by mutableStateOf(false) var isValidatingLogin by mutableStateOf(false)
@@ -88,8 +100,63 @@ class AccountViewModel(application: Application) : AndroidViewModel(application)
} }
} }
private fun projectsTheAccountProvided(db: CowspentSQLiteOpenHelper): List<DBProject> {
val offered = db.accountProjects
val cospendPath = "/index.php/apps/cospend"
return db.projects.filter { project ->
offered.any {
it.remoteId == project.remoteId &&
project.serverUrl?.replace("/+$".toRegex(), "") ==
it.ncUrl.replace("/+$".toRegex(), "") + cospendPath
}
}
}
data class LogoutImpact(val projects: Int, val unsyncedBills: Int)
private fun measureLogoutImpact(): LogoutImpact {
val db = CowspentSQLiteOpenHelper.getInstance(getApplication())
val projects = projectsTheAccountProvided(db)
val unsynced = projects.sumOf { db.countUnsyncedBills(it.id) }
return LogoutImpact(projects.size, unsynced)
}
fun requestLogout() {
viewModelScope.launch {
logoutImpact = withContext(Dispatchers.IO) {
try {
measureLogoutImpact()
} catch (e: Exception) {
// Never let a failed count block signing out - just ask without the detail.
Log.e(TAG, "Could not measure what logging out would remove", e)
LogoutImpact(0, 0)
}
}
}
}
fun cancelLogout() {
logoutImpact = null
}
fun confirmLogout() {
logoutImpact = null
logout()
}
private fun forgetProjectsTheAccountProvided() {
try {
val db = CowspentSQLiteOpenHelper.getInstance(getApplication())
projectsTheAccountProvided(db).forEach { db.deleteProject(it.id) }
db.clearAccountProjects()
} catch (e: Exception) {
Log.e(TAG, "Could not remove the account's projects on logout", e)
}
}
@VisibleForTesting
fun logout() { fun logout() {
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { forgetProjectsTheAccountProvided() }
SecureStorage.removePassword(getApplication(), AccountActivity.SETTINGS_PASSWORD) SecureStorage.removePassword(getApplication(), AccountActivity.SETTINGS_PASSWORD)
} }
preferences.edit { preferences.edit {
@@ -219,6 +219,7 @@ fun CategoryList(
onDelete = { onDelete(category) } onDelete = { onDelete(category) }
) )
} }
item { LabelListBottomSpacer() }
} }
} }
@@ -238,9 +239,19 @@ fun PaymentModeList(
onDelete = { onDelete(pm) } onDelete = { onDelete(pm) }
) )
} }
item { LabelListBottomSpacer() }
} }
} }
/**
* Keeps the last row clear of the add button, which otherwise sits on top of its delete icon and
* swallows the tap - with nothing below to scroll to, that row simply cannot be deleted.
*/
@Composable
private fun LabelListBottomSpacer() {
Spacer(modifier = Modifier.height(64.dp).fillMaxWidth())
}
@Composable @Composable
fun LabelItem( fun LabelItem(
name: String, name: String,
@@ -132,6 +132,10 @@ fun BillsListScreen(
onProjectAction(projectOptionsProjectId, 1) onProjectAction(projectOptionsProjectId, 1)
viewModel.showProjectOptionsDialogByProjectId = null viewModel.showProjectOptionsDialogByProjectId = null
}, },
onForgetProject = {
onProjectAction(projectOptionsProjectId, 9)
viewModel.showProjectOptionsDialogByProjectId = null
},
onManageMembers = { onManageMembers = {
onProjectAction(projectOptionsProjectId, 2) onProjectAction(projectOptionsProjectId, 2)
viewModel.showProjectOptionsDialogByProjectId = null viewModel.showProjectOptionsDialogByProjectId = null
@@ -408,11 +412,9 @@ fun BillsListScreen(
.pullRefresh(pullRefreshState)) { .pullRefresh(pullRefreshState)) {
when { when {
viewModel.showNoProjects -> EmptyProjectsState(onAccountSwitcherClick, onAddProjectClick) viewModel.showNoProjects -> EmptyProjectsState(onAccountSwitcherClick, onAddProjectClick)
(viewModel.isRefreshing || viewModel.isLoadingBills) && viewModel.bills.isEmpty() ->
LoadingBillsState()
viewModel.showNoMembers -> EmptyMembersState() viewModel.showNoMembers -> EmptyMembersState()
// A large project takes a long time on its first sync. Until that finishes there
// is nothing stored for it yet, and reporting that as "no bills" tells the user
// the project is empty when it is still downloading.
viewModel.isRefreshing && viewModel.bills.isEmpty() -> LoadingBillsState()
viewModel.showNoBills -> EmptyBillsState() viewModel.showNoBills -> EmptyBillsState()
viewModel.bills.isEmpty() -> EmptyState() viewModel.bills.isEmpty() -> EmptyState()
else -> { else -> {
@@ -270,6 +270,7 @@ class BillsListViewActivity :
8 -> { 8 -> {
startActivity(net.helcel.cowspent.android.label.LabelManagementActivity.createIntent(this@BillsListViewActivity, pid)) startActivity(net.helcel.cowspent.android.label.LabelManagementActivity.createIntent(this@BillsListViewActivity, pid))
} }
9 -> onRemoveProjectClick(pid)
} }
}, },
onAccountSwitcherClick = { onAccountSwitcherClick = {
@@ -334,6 +335,7 @@ class BillsListViewActivity :
super.onResume() super.onResume()
val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext) val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
val selectedProjectId = preferences.getLong("selected_project", 0) val selectedProjectId = preferences.getLong("selected_project", 0)
setupDrawerProjects()
if (selectedProjectId != 0L) { if (selectedProjectId != 0L) {
refreshLists() refreshLists()
} }
@@ -341,6 +343,10 @@ class BillsListViewActivity :
synchronize(SyncTrigger.APP_OPEN) synchronize(SyncTrigger.APP_OPEN)
lifecycleScope.launch {
syncNewlyDiscoveredProjects(withContext(Dispatchers.IO) { db.projects })
}
registerBroadcastReceiver() registerBroadcastReceiver()
updateAvatarInDrawer(CowspentServerSyncHelper.isNextcloudAccountConfigured(this)) updateAvatarInDrawer(CowspentServerSyncHelper.isNextcloudAccountConfigured(this))
isActivityVisible = true isActivityVisible = true
@@ -377,6 +383,8 @@ class BillsListViewActivity :
fun onProjectClick(projectId: Long) { fun onProjectClick(projectId: Long) {
if (viewModel.selectedProjectId != projectId) { if (viewModel.selectedProjectId != projectId) {
viewModel.selectedMemberId = null viewModel.selectedMemberId = null
viewModel.bills = emptyList()
viewModel.isLoadingBills = true
} }
setSelectedProject(projectId) setSelectedProject(projectId)
navigationSelection = Category(null, null) navigationSelection = Category(null, null)
@@ -400,16 +408,34 @@ class BillsListViewActivity :
private fun onRemoveProjectClick(projectId: Long) { private fun onRemoveProjectClick(projectId: Long) {
if (projectId == 0L) return if (projectId == 0L) return
lifecycleScope.launch { lifecycleScope.launch {
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch val (proj, unsyncedBills) = withContext(Dispatchers.IO) {
val p = db.getProject(projectId) ?: return@withContext null
p to db.countUnsyncedBills(projectId)
} ?: return@launch
val message = buildString {
if (!proj.isLocal) append(getString(R.string.dialog_confirm_remove_project_msg))
if (unsyncedBills > 0) {
if (isNotEmpty()) append("\n\n")
append(
resources.getQuantityString(
R.plurals.warning_unsynced_bills, unsyncedBills, unsyncedBills
)
)
}
}
viewModel.showDialog( viewModel.showDialog(
title = getString(R.string.title_confirm), title = getString(R.string.title_confirm),
message = if (!proj.isLocal) getString(R.string.dialog_confirm_remove_project_msg) else null, message = message.ifEmpty { null },
positiveText = getString(R.string.simple_yes), positiveText = getString(R.string.simple_yes),
onConfirm = { onConfirm = {
lifecycleScope.launch { lifecycleScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
db.deleteProject(projectId) db.deleteProject(projectId)
// Otherwise the next account sync sees a project the account offers
// with no local row, and creates it again.
CowspentServerSyncHelper.forgetAccountProject(applicationContext, proj)
PreferenceManager.getDefaultSharedPreferences(applicationContext) PreferenceManager.getDefaultSharedPreferences(applicationContext)
.edit { remove(lastProjectSyncKey(projectId)) } .edit { remove(lastProjectSyncKey(projectId)) }
val dbProjects = db.projects val dbProjects = db.projects
@@ -621,76 +647,80 @@ class BillsListViewActivity :
val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0) val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0)
lifecycleScope.launch { lifecycleScope.launch {
val (projId, projName) = withContext(Dispatchers.IO) { try {
if (selectedProjectId != 0L) { val (projId, projName) = withContext(Dispatchers.IO) {
db.getProject(selectedProjectId)?.let { if (selectedProjectId != 0L) {
it.id to (if (it.name == "null" || it.name.isEmpty()) it.remoteId else it.name) db.getProject(selectedProjectId)?.let {
} ?: (0L to "") it.id to (if (it.name == "null" || it.name.isEmpty()) it.remoteId else it.name)
} else { } ?: (0L to "")
0L to "" } else {
0L to ""
}
} }
}
val title = if (selectedProjectId != 0L) projName else getString(R.string.app_name) 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)) {
+222 -230
View File
@@ -1,266 +1,258 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!--
Untranslated: these are the English strings, kept here so a translation can be
contributed by simply replacing the values. Strings marked translatable="false" in
values/strings.xml (app and product names, preference keys) are deliberately absent
and must not be added.
-->
<!-- Actions --> <!-- Actions -->
<string name="action_new_bill">New bill</string> <string name="action_new_bill">Новый расход</string>
<string name="action_add_project">Add project</string> <string name="action_add_project">Добавить проект</string>
<string name="action_save">Save</string> <string name="action_save">Сохранить</string>
<string name="action_edit">Edit</string> <string name="action_edit">Редактировать</string>
<string name="action_share">Share</string> <string name="action_share">Поделиться</string>
<string name="action_search">Search</string> <string name="action_search">Поиск</string>
<string name="action_open_menu">Open menu</string> <string name="action_open_menu">Открыть меню</string>
<string name="action_close_search">Close search</string> <string name="action_close_search">Закрыть поиск</string>
<string name="action_clear_search">Clear search</string> <string name="action_clear_search">Очистить поиск</string>
<string name="action_delete">Delete</string> <string name="action_delete">Удалить</string>
<string name="simple_back">Back</string> <string name="simple_back">Назад</string>
<string name="action_archive">Archive</string> <string name="action_archive">Архивировать</string>
<string name="action_unarchive">Unarchive</string> <string name="action_unarchive">Разархивировать</string>
<string name="action_export">Export</string> <string name="action_export">Экспорт</string>
<string name="action_stats">Stats</string> <string name="action_stats">Статистика</string>
<string name="action_settle">Settle</string> <string name="action_settle">Свести баланс</string>
<string name="action_scan_qrcode">Scan QR Code</string> <string name="action_scan_qrcode">Отсканировать QR-код</string>
<string name="action_settings">Settings</string> <string name="action_settings">Настройки</string>
<string name="action_label_bills">Label missing categories</string> <string name="action_label_bills">Указать отсутствующие категории</string>
<string name="action_logout">Logout</string> <string name="action_logout">Выйти</string>
<string name="action_connect">Connect</string> <string name="action_connect">Подключиться</string>
<string name="action_discard">Discard</string> <string name="action_discard">Отменить</string>
<string name="action_members">Members</string> <string name="action_members">Участники</string>
<string name="action_labels">Labels</string> <string name="action_labels">Метки</string>
<string name="action_currencies">Currencies</string> <string name="action_currencies">Валюты</string>
<!-- Titles --> <!-- Titles -->
<string name="title_stats">Statistics</string> <string name="title_stats">Статистика</string>
<string name="title_edit_project">Edit project</string> <string name="title_edit_project">Редактировать проект</string>
<string name="title_label_bills">Label Bills</string> <string name="title_label_bills">Указать категории</string>
<string name="title_labels">Manage Labels</string> <string name="title_labels">Управление метками</string>
<string name="title_about">About</string> <string name="title_about">О программе</string>
<string name="title_settle">Settle Project</string> <string name="title_settle">Сведение баланса</string>
<string name="title_share">Share Project</string> <string name="title_share">Поделиться проектом</string>
<string name="title_add_project">Add Project</string> <string name="title_add_project">Добавить проект</string>
<string name="title_add_category">Add Category</string> <string name="title_add_category">Добавить категорию</string>
<string name="title_add_payment_mode">Add Payment Mode</string> <string name="title_add_payment_mode">Добавить способ оплаты</string>
<string name="title_account">Nextcloud Account</string> <string name="title_account">Учётная запись Nextcloud</string>
<string name="title_share_web">Web link</string> <string name="title_share_web">Веб-ссылка</string>
<string name="title_share_qr">Cowspent link</string> <string name="title_share_qr">Ссылка Cowspent</string>
<string name="title_confirm">Are you sure?</string> <string name="title_confirm">Вы уверены?</string>
<!-- Labels and Fields --> <!-- Labels and Fields -->
<string name="label_all_bills">All bills</string> <string name="label_all_bills">Все расходы</string>
<string name="label_categories">Categories</string> <string name="label_categories">Категории</string>
<string name="label_payment_modes">Payment Modes</string> <string name="label_payment_modes">Способы оплаты</string>
<string name="label_name">Name</string> <string name="label_name">Название</string>
<string name="label_icon">Icon / Emoji</string> <string name="label_icon">Значок / Смайлик</string>
<string name="label_color">Color</string> <string name="label_color">Цвет</string>
<string name="label_weight">Weight</string> <string name="label_weight">Вес</string>
<string name="label_activated">Activated</string> <string name="label_activated">Активировано</string>
<string name="label_password">Password</string> <string name="label_password">Пароль</string>
<string name="label_email">E-mail</string> <string name="label_email">Электронная почта</string>
<string name="label_url">Server address</string> <string name="label_url">Адрес сервера</string>
<string name="label_username">Username</string> <string name="label_username">Имя пользователя</string>
<string name="label_comment">Comment</string> <string name="label_comment">Комментарий</string>
<string name="label_what">What?</string> <string name="label_what">Что?</string>
<string name="label_payer">Who paid?</string> <string name="label_payer">Кто оплатил?</string>
<string name="label_owers">For whom?</string> <string name="label_owers">Для кого?</string>
<string name="label_repeat">Repeat every</string> <string name="label_repeat">Повторять каждые</string>
<string name="label_mode">Mode</string> <string name="label_mode">Режим</string>
<string name="label_category">Category</string> <string name="label_category">Категория</string>
<string name="label_project_id">Project ID/name</string> <string name="label_project_id">Идентификатор/название проекта</string>
<string name="label_project_title">Project title</string> <string name="label_project_title">Название проекта</string>
<string name="label_use_sso">Use Nextcloud App Account</string> <string name="label_use_sso">Использовать учётную запись приложения Nextcloud</string>
<!-- Dialogs and Messages --> <!-- Dialogs and Messages -->
<string name="dialog_unsaved_changes_title">Unsaved changes</string> <string name="dialog_unsaved_changes_title">Несохраненные изменения</string>
<string name="dialog_unsaved_changes_msg">Save changes before leaving?</string> <string name="dialog_unsaved_changes_msg">Сохранить изменения перед выходом?</string>
<string name="dialog_confirm_remove_project_msg">The remote project will not be deleted.</string> <string name="dialog_confirm_remove_project_msg">Проект на сервере не будет удален.</string>
<string name="dialog_sync_error_title">Sync error</string> <string name="dialog_sync_error_title">Ошибка синхронизации</string>
<string name="dialog_sync_error_msg">Sync failed for %1$s.\n\n%2$s</string> <string name="dialog_sync_error_msg">Не удалось синхронизировать %1$s.\n\n%2$s</string>
<string name="dialog_balanced_msg">Expenses are already balanced.</string> <string name="dialog_balanced_msg">Расходы уже сбалансированы.</string>
<string name="msg_project_added">Project %1$s added</string> <string name="msg_project_added">Проект %1$s добавлен</string>
<string name="msg_bill_labeled_done">All bills labeled</string> <string name="msg_bill_labeled_done">Все расходы помечены</string>
<string name="msg_no_suggestions">No suggestions</string> <string name="msg_no_suggestions">Нет предложений</string>
<string name="msg_auth_warning">Requires Cospend v0.3.4+.</string> <string name="msg_auth_warning">Требуется Cospend v0.3.4+.</string>
<string name="msg_link_copied">Link copied to clipboard</string> <string name="msg_link_copied">Ссылка скопирована в буфер обмена</string>
<string name="msg_share_qr">Scan QR code or share the link to join.</string> <string name="msg_share_qr">Отсканируйте QR-код или поделитесь ссылкой, чтобы присоединиться.</string>
<string name="msg_share_web">Link for web browser access.</string> <string name="msg_share_web">Ссылка для доступа через веб-браузер.</string>
<string name="msg_share_qr_warn">Share this link with a Cowspent user.</string> <string name="msg_share_qr_warn">Поделитесь этой ссылкой с пользователем Cowspent.</string>
<string name="msg_settle_intro">Settlement for %1$s:</string> <string name="msg_settle_intro">Расчет по %1$s:</string>
<string name="msg_settle_sentence">%1$s owes %3$.2f to %2$s</string> <string name="msg_settle_sentence">%1$s должен %2$s %3$.2f</string>
<string name="msg_stats_intro">Stats for %1$s:</string> <string name="msg_stats_intro">Статистика для %1$s:</string>
<string name="msg_stats_header">Member (Paid | Spent | Balance)</string> <string name="msg_stats_header">Участник (Оплачено | Потрачено | Баланс)</string>
<string name="msg_logged_in_as">Logged in as %1$s</string> <string name="msg_logged_in_as">Вошёл под именем %1$s</string>
<!-- Errors --> <!-- Errors -->
<string name="error_generic">Error</string> <string name="error_generic">Ошибка</string>
<string name="error_loading">Loading</string> <string name="error_loading">Загрузка</string>
<string name="error_no_projects">No projects found</string> <string name="error_no_projects">Проекты не найдены</string>
<string name="error_no_members">No members found</string> <string name="error_no_members">Участники не найдены</string>
<string name="error_no_bills">No bills found</string> <string name="error_no_bills">Расходы не найдены</string>
<string name="error_no_member">At least one member required</string> <string name="error_no_member">Требуется как минимум один участник</string>
<string name="error_maintenance_mode">Server is in maintenance mode</string> <string name="error_maintenance_mode">Сервер находится в режиме технического обслуживания</string>
<string name="error_400">400 Bad request</string> <string name="error_400">400 Неверный запрос</string>
<string name="error_401">401 Unauthorized</string> <string name="error_401">401 Неавторизованный доступ</string>
<string name="error_403">403 Forbidden</string> <string name="error_403">403 Доступ запрещен</string>
<string name="error_404">404 Not Found</string> <string name="error_404">404 Не найдено</string>
<string name="error_sync">Sync failed: %1$s</string> <string name="error_sync">Сбой синхронизации: %1$s</string>
<string name="error_invalid_login">Invalid login: %1$s</string> <string name="error_invalid_login">Неверный логин: %1$s</string>
<string name="error_auth">Wrong username or password</string> <string name="error_auth">Неверное имя пользователя или пароль</string>
<string name="error_json">Invalid server response</string> <string name="error_json">Неверный ответ сервера</string>
<string name="error_req_failed">Request failed</string> <string name="error_req_failed">Ошибка запроса</string>
<string name="error_invalid_email">Invalid e-mail</string> <string name="error_invalid_email">Неверный адрес электронной почты</string>
<string name="error_invalid_project_id">Invalid project ID</string> <string name="error_invalid_project_id">Неверный идентификатор проекта</string>
<string name="error_invalid_project_name">Invalid project title</string> <string name="error_invalid_project_name">Неверное название проекта</string>
<string name="error_invalid_bill_name">Invalid bill name</string> <string name="error_invalid_bill_name">Неверное название расхода</string>
<string name="error_invalid_bill_date">Invalid bill date</string> <string name="error_invalid_bill_date">Неверная дата расхода</string>
<string name="error_invalid_bill_payer">Payer required</string> <string name="error_invalid_bill_payer">Требуется указать плательщика</string>
<string name="error_invalid_bill_owers">Owers required</string> <string name="error_invalid_bill_owers">Требуется указать участников</string>
<string name="error_no_network">No network connection</string> <string name="error_no_network">Отсутствует подключение к сети</string>
<string name="error_server">Server error</string> <string name="error_server">Ошибка сервера</string>
<string name="error_io">Server connection broken</string> <string name="error_io">Сбой соединения с сервером</string>
<string name="error_share_impossible">Cannot share this project</string> <string name="error_share_impossible">Невозможно поделиться этим проектом</string>
<!-- Drawer / Common UI --> <!-- Drawer / Common UI -->
<string name="drawer_no_account">Connect to Nextcloud account</string> <string name="drawer_no_account">Подключиться к учетной записи Nextcloud</string>
<string name="drawer_last_sync">Last sync: %1$02d:%2$02d</string> <string name="drawer_last_sync">Последняя синхронизация: %1$02d:%2$02d</string>
<string name="simple_cancel">Cancel</string> <string name="simple_cancel">Отменить</string>
<string name="simple_ok" tools:ignore="ButtonCase,Typos">Ok</string> <string name="simple_ok" tools:ignore="ButtonCase,Typos">ОК</string>
<string name="simple_yes">Yes</string> <string name="simple_yes">Да</string>
<string name="simple_no">No</string> <string name="simple_no">Нет</string>
<string name="simple_close">Close</string> <string name="simple_close">Закрыть</string>
<!-- Settings --> <!-- Settings -->
<string name="settings_appearance">Appearance</string> <string name="settings_appearance">Внешний вид</string>
<string name="settings_network">Network</string> <string name="settings_network">Сеть</string>
<string name="settings_other">Other</string> <string name="settings_other">Прочее</string>
<string name="settings_night_mode">Theme</string> <string name="settings_night_mode">Тема</string>
<string name="settings_offline_mode">Offline mode</string> <string name="settings_offline_mode">Офлайн-режим</string>
<string name="settings_offline_mode_summary">Only sync manually.</string> <string name="settings_offline_mode_summary">Синхронизация только вручную</string>
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Пользовательский цвет</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Выбор цвета</string>
<string name="settings_show_archived">Show archived projects</string> <string name="settings_show_archived">Показывать заархивированные проекты</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string> <string name="settings_fill_new_bill_from_last">Автозаполнение на основе последнего расхода</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string> <string name="settings_fill_new_bill_from_last_summary">Предварительное заполнение полей "Плательщик", "Категория", "Способ оплаты" и "Для кого" на основе последнего расхода, созданного в проекте.</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 час</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 день</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string> <string name="settings_url_warn_http">ПРЕДУПРЕЖДЕНИЕ: "http" небезопасен. Используйте "https".</string>
<string name="settings_colorpicker_title">Choose Color</string> <string name="settings_colorpicker_title">Выбрать цвет</string>
<string name="pref_value_color_system">Системная</string>
<string name="pref_value_color_system">System</string> <string name="pref_value_color_manual">Вручную</string>
<string name="pref_value_color_manual">Manual</string> <string name="pref_value_theme_light">Светлая</string>
<string name="pref_value_theme_light">Light</string> <string name="pref_value_theme_dark">Тёмная</string>
<string name="pref_value_theme_dark">Dark</string> <string name="pref_value_theme_system">По системе</string>
<string name="pref_value_theme_system">Follow system</string>
<!-- Enums and Lists --> <!-- Enums and Lists -->
<string name="repeat_no">No repeat</string> <string name="repeat_no">Без повторения</string>
<string name="repeat_day">Daily</string> <string name="repeat_day">Ежедневно</string>
<string name="repeat_week">Weekly</string> <string name="repeat_week">Еженедельно</string>
<string name="repeat_fortnight">Fortnightly</string> <string name="repeat_fortnight">Раз в две недели</string>
<string name="repeat_month">Monthly</string> <string name="repeat_month">Ежемесячно</string>
<string name="repeat_year">Yearly</string> <string name="repeat_year">Ежегодно</string>
<string name="payment_mode_none">None</string> <string name="payment_mode_none">Нет</string>
<string name="payment_mode_all">All</string> <string name="payment_mode_all">Все</string>
<string name="payment_mode_credit_card">Credit card</string> <string name="payment_mode_credit_card">Кредитная карта</string>
<string name="payment_mode_cash">Cash</string> <string name="payment_mode_cash">Наличные</string>
<string name="payment_mode_check">Check</string> <string name="payment_mode_check">Чек</string>
<string name="payment_mode_online">Online</string> <string name="payment_mode_online">Онлайн</string>
<string name="payment_mode_transfer">Transfer</string> <string name="payment_mode_transfer">Перевод</string>
<string name="category_none">None</string> <string name="category_none">Нет</string>
<string name="category_all">All</string> <string name="category_all">Все</string>
<string name="category_all_except_reimbursement">All except reimbursement</string> <string name="category_all_except_reimbursement">Все, кроме возмещения</string>
<string name="category_groceries">Grocery</string> <string name="category_groceries">Продукты</string>
<string name="category_leisure">Bar/Party</string> <string name="category_leisure">Бар/Вечеринка</string>
<string name="category_rent">Rent</string> <string name="category_rent">Аренда</string>
<string name="category_bills">Bill</string> <string name="category_bills">Счета</string>
<string name="category_excursion">Excursion/Culture</string> <string name="category_excursion">Экскурсии/Культура</string>
<string name="category_health">Health</string> <string name="category_health">Здоровье</string>
<string name="category_shopping">Shopping</string> <string name="category_shopping">Покупки</string>
<string name="category_reimbursement">Reimbursement</string> <string name="category_reimbursement">Возмещение расходов</string>
<string name="category_restaurant">Restaurant</string> <string name="category_restaurant">Ресторан</string>
<string name="category_accomodation">Accommodation</string> <string name="category_accomodation">Проживание</string>
<string name="category_transport">Transport</string> <string name="category_transport">Транспорт</string>
<string name="category_sport">Sport</string> <string name="category_sport">Спорт</string>
<!-- Project specific --> <!-- Project specific -->
<string name="new_project_action">What</string> <string name="new_project_action">Что</string>
<string name="new_project_where">Where</string> <string name="new_project_where">Где</string>
<string name="where_local">Local only</string> <string name="where_local">Только локально</string>
<string name="todo_join">Join existing project</string> <string name="todo_join">Присоединиться к существующему проекту</string>
<string name="todo_create">Create new project</string> <string name="todo_create">Создать новый проект</string>
<string name="import_tooltip">Import from file</string> <string name="import_tooltip">Импортировать из файла</string>
<string name="choose_project_management_action">Project</string> <string name="choose_project_management_action">Проект</string>
<string name="project_added_success">Project added successfully.</string> <string name="project_added_success">Проект добавлен успешно.</string>
<string name="no_projects_text">You have no projects yet.</string> <string name="no_projects_text">У вас пока нет проектов.</string>
<string name="configure_account_choice">Configure Nextcloud account</string> <string name="configure_account_choice">Настроить учётную запись Nextcloud</string>
<string name="add_project_choice">Add project manually</string> <string name="add_project_choice">Добавить проект вручную</string>
<string name="no_members_text">No members in this project.</string> <string name="no_members_text">В этом проекте нет участников.</string>
<string name="no_bills_text">No bills found.</string> <string name="no_bills_text">Расходов не найдено.</string>
<string name="member_already_exists">Member already exists.</string> <string name="member_already_exists">Участник уже существует.</string>
<string name="activity_dialog_title">Project: %1$s</string> <string name="activity_dialog_title">Проект: %1$s</string>
<string name="remove_project_confirmation">Project %1$s removed.</string> <string name="remove_project_confirmation">Проект %1$s удален.</string>
<string name="file_saved_success">File saved: %1$s</string> <string name="file_saved_success">Файл сохранен: %1$s</string>
<string name="import_error_header">Import failed at row %d</string> <string name="import_error_header">Ошибка импорта в строке %d</string>
<string name="import_error_date">Invalid date format at row %d</string> <string name="import_error_date">Недопустимый формат даты в строке %d</string>
<string name="import_error_owers">Invalid owers at row %d</string> <string name="import_error_owers">Недопустимые участники в строке %d</string>
<string name="add_member_dialog_title">Add Member</string> <string name="add_member_dialog_title">Добавить участника</string>
<string name="edit_member_dialog_title">Edit Member</string> <string name="edit_member_dialog_title">Редактировать участника</string>
<string name="member_edit_delete">Delete</string> <string name="member_edit_delete">Удалить</string>
<string name="project_edition_no_change">No changes to save.</string> <string name="project_edition_no_change">Изменений для сохранения нет.</string>
<!-- Settlement --> <!-- Settlement -->
<string name="center_none">None (Optimal)</string> <string name="center_none">Нет (оптимально)</string>
<string name="settle_who">Who pays</string> <string name="settle_who">Кто платит</string>
<string name="settle_to_whom">To whom</string> <string name="settle_to_whom">Кому</string>
<string name="settle_how_much">Amount</string> <string name="settle_how_much">Сумма</string>
<string name="simple_settle_share">Share</string> <string name="simple_settle_share">Поделиться</string>
<string name="simple_create_bills">Create bills</string> <string name="simple_create_bills">Создать расходы</string>
<string name="settle_bill_what">Settlement</string> <string name="settle_bill_what">Расход</string>
<!-- Currencies --> <!-- Currencies -->
<string name="currency_dialog_title">Choose Currency (%s)</string> <string name="currency_dialog_title">Выбрать валюту (%s)</string>
<string name="setting_none">None</string> <string name="setting_none">Нет</string>
<string name="setting_all">All</string> <string name="setting_all">Все</string>
<string name="currency_saved_success">Currency settings saved.</string> <string name="currency_saved_success">Настройки валюты сохранены.</string>
<string name="main_currency">Main Currency</string> <string name="main_currency">Основная валюта</string>
<!-- Statistics --> <!-- Statistics -->
<string name="label_bills_suggested">Suggested Categories</string> <string name="label_bills_suggested">Рекомендуемые категории</string>
<string name="label_bills_skip">Skip</string> <string name="label_bills_skip">Пропустить</string>
<string name="stats_date_min">From</string> <string name="stats_date_min">С</string>
<string name="stats_date_max">To</string> <string name="stats_date_max">По</string>
<string name="stats_who">Member</string> <string name="stats_who">Участник</string>
<string name="stats_paid">Paid</string> <string name="stats_paid">Оплачено</string>
<string name="stats_spent">Spent</string> <string name="stats_spent">Потрачено</string>
<string name="stats_balance">Balance</string> <string name="stats_balance">Баланс</string>
<string name="total">Total: %1$s</string> <string name="total">Итого: %1$s</string>
<!-- Errors Extra --> <!-- Errors Extra -->
<string name="error_project_connect_check">Connection failed: %1$s</string> <string name="error_project_connect_check">Ошибка подключения: %1$s</string>
<string name="error_create_remote_project_helper">Creation failed: %1$s</string> <string name="error_create_remote_project_helper">Ошибка создания: %1$s</string>
<string name="error_edit_remote_project_helper">Error updating remote project: %1$s</string> <string name="error_edit_remote_project_helper">Ошибка обновления удаленного проекта: %1$s</string>
<string name="remote_project_operation_no_network">Network unavailable for remote operation.</string> <string name="remote_project_operation_no_network">Отсутствует подключение к сети для операций на сервере.</string>
<string name="error_scanning_bill_qr_code">Failed to parse QR code.</string> <string name="error_scanning_bill_qr_code">Не удалось распознать QR-код.</string>
<string name="error_token_mismatch">Authentication token mismatch. Please log in again.</string> <string name="error_token_mismatch">Ошибка токена аутентификации. Пожалуйста, войдите заново.</string>
<string name="insufficient_access_level">You don\'t have permission to perform this action.</string> <string name="insufficient_access_level">У вас нет прав на выполнение этого действия.</string>
<string name="delete_label_confirmation_title">Delete Label</string> <string name="delete_label_confirmation_title">Удалить метку</string>
<string name="delete_label_confirmation_message">Are you sure you want to delete this label?</string> <string name="delete_label_confirmation_message">Вы действительно хотите удалить эту метку?</string>
<!-- About --> <!-- About -->
<string name="about_version">Version %1$s</string> <string name="about_version">Версия %1$s</string>
<string name="about_maintainer_title">Maintainer</string> <string name="about_maintainer_title">Сопровождающий</string>
<string name="about_license_title">License</string> <string name="about_license_title">Лицензия</string>
<string name="about_source_title">Source code</string> <string name="about_source_title">Исходный код</string>
<!-- New constants for backward compatibility or shared use --> <!-- New constants for backward compatibility or shared use -->
<string name="share_intent_title">Project %1$s</string> <string name="share_intent_title">Проект %1$s</string>
<string name="share_chooser_title">Share %1$s</string> <string name="share_chooser_title">Поделиться %1$s</string>
</resources> </resources>
+21
View File
@@ -17,6 +17,7 @@
<string name="simple_back">Back</string> <string name="simple_back">Back</string>
<string name="action_archive">Archive</string> <string name="action_archive">Archive</string>
<string name="action_unarchive">Unarchive</string> <string name="action_unarchive">Unarchive</string>
<string name="action_forget">Delete locally</string>
<string name="action_export">Export</string> <string name="action_export">Export</string>
<string name="action_stats">Stats</string> <string name="action_stats">Stats</string>
<string name="action_settle">Settle</string> <string name="action_settle">Settle</string>
@@ -24,6 +25,8 @@
<string name="action_settings">Settings</string> <string name="action_settings">Settings</string>
<string name="action_label_bills">Label missing categories</string> <string name="action_label_bills">Label missing categories</string>
<string name="action_logout">Logout</string> <string name="action_logout">Logout</string>
<string name="logout_confirm_title">Log out?</string>
<string name="logout_confirm_no_projects">You can sign back in at any time.</string>
<string name="action_connect">Connect</string> <string name="action_connect">Connect</string>
<string name="action_discard">Discard</string> <string name="action_discard">Discard</string>
<string name="action_members">Members</string> <string name="action_members">Members</string>
@@ -136,6 +139,9 @@
<string name="settings_night_mode">Theme</string> <string name="settings_night_mode">Theme</string>
<string name="settings_offline_mode">Offline mode</string> <string name="settings_offline_mode">Offline mode</string>
<string name="settings_offline_mode_summary">Only sync manually.</string> <string name="settings_offline_mode_summary">Only sync manually.</string>
<string name="settings_restore_deleted_projects">Restore deleted projects</string>
<string name="settings_restore_deleted_projects_done">Restoring from the account</string>
<string name="settings_restore_deleted_projects_confirm">Restore</string>
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string> <string name="settings_show_archived">Show archived projects</string>
@@ -290,4 +296,19 @@
<string name="share_intent_title">Project %1$s</string> <string name="share_intent_title">Project %1$s</string>
<string name="share_chooser_title">Share %1$s</string> <string name="share_chooser_title">Share %1$s</string>
<plurals name="settings_restore_deleted_projects_summary">
<item quantity="one">%d project the account offers is kept off this device.</item>
<item quantity="other">%d projects the account offers are kept off this device.</item>
</plurals>
<plurals name="logout_confirm_projects">
<item quantity="one">%d project from this account will be removed from this device. It comes back when you sign in again.</item>
<item quantity="other">%d projects from this account will be removed from this device. They come back when you sign in again.</item>
</plurals>
<plurals name="warning_unsynced_bills">
<item quantity="one">%d bill has not reached the server yet and will be lost.</item>
<item quantity="other">%d bills have not reached the server yet and will be lost.</item>
</plurals>
</resources> </resources>