12 Commits
14 changed files with 826 additions and 431 deletions
@@ -1,7 +1,6 @@
package net.helcel.cowspent.android.account
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Build
@@ -38,6 +37,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.R
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.util.CospendClientUtil
import net.helcel.cowspent.util.CospendClientUtil.LoginStatus
@@ -108,8 +109,7 @@ class AccountActivity : AppCompatActivity() {
viewModel.useSso = false
preferences.edit { putBoolean(SETTINGS_USE_SSO, false) }
}
},
onLogout = { viewModel.logout() }
}
)
}
@@ -147,7 +147,7 @@ class AccountActivity : AppCompatActivity() {
allowFileAccess = false
javaScriptEnabled = true
domStorageEnabled = true
userAgentString = getWebLoginUserAgent(context)
userAgentString = getWebLoginUserAgent()
}
webViewClient = object : WebViewClient() {
@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")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
@@ -207,6 +213,8 @@ class AccountActivity : AppCompatActivity() {
viewModel.serverUrl = ssoAccount.url
viewModel.username = ssoAccount.userId
startInitialAccountSync()
val resultData = Intent()
resultData.putExtra(MainConstants.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED)
setResult(RESULT_OK, resultData)
@@ -234,13 +242,9 @@ class AccountActivity : AppCompatActivity() {
}
}
private fun getWebLoginUserAgent(context: Context): String {
val defaultUA = try {
android.webkit.WebSettings.getDefaultUserAgent(context)
} catch (_: Exception) {
Build.MANUFACTURER + " " + Build.MODEL
}
return "$defaultUA Cowspent/Android"
private fun getWebLoginUserAgent(): String {
val manufacturer = Build.MANUFACTURER.replaceFirstChar { it.titlecase(Locale.ROOT) }
return "$manufacturer ${Build.MODEL} (Cowspent/Android)"
}
private fun parseAndLoginFromWebView(dataString: String) {
@@ -312,6 +316,8 @@ class AccountActivity : AppCompatActivity() {
remove(SETTINGS_KEY_LAST_MODIFIED)
}
startInitialAccountSync()
val data = Intent()
data.putExtra(MainConstants.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED)
setResult(RESULT_OK, data)
@@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -28,8 +29,7 @@ fun AccountScreen(
viewModel: AccountViewModel,
onBack: () -> Unit,
onConnect: () -> Unit,
onSsoClick: (Boolean) -> Unit,
onLogout: () -> Unit
onSsoClick: (Boolean) -> Unit
) {
AccountScreenContent(
isLoggedIn = viewModel.isLoggedIn,
@@ -51,7 +51,57 @@ fun AccountScreen(
onBack = onBack,
onConnect = onConnect,
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.content.SharedPreferences
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.annotation.VisibleForTesting
import androidx.core.content.edit
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
@@ -12,10 +14,17 @@ import androidx.preference.PreferenceManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
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.SecureStorage
class AccountViewModel(application: Application) : AndroidViewModel(application) {
private companion object {
const val TAG = "AccountViewModel"
}
private val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(application)
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 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
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() {
viewModelScope.launch {
withContext(Dispatchers.IO) { forgetProjectsTheAccountProvided() }
SecureStorage.removePassword(getApplication(), AccountActivity.SETTINGS_PASSWORD)
}
preferences.edit {
@@ -219,6 +219,7 @@ fun CategoryList(
onDelete = { onDelete(category) }
)
}
item { LabelListBottomSpacer() }
}
}
@@ -238,9 +239,19 @@ fun PaymentModeList(
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
fun LabelItem(
name: String,
@@ -132,6 +132,10 @@ fun BillsListScreen(
onProjectAction(projectOptionsProjectId, 1)
viewModel.showProjectOptionsDialogByProjectId = null
},
onForgetProject = {
onProjectAction(projectOptionsProjectId, 9)
viewModel.showProjectOptionsDialogByProjectId = null
},
onManageMembers = {
onProjectAction(projectOptionsProjectId, 2)
viewModel.showProjectOptionsDialogByProjectId = null
@@ -408,11 +412,9 @@ fun BillsListScreen(
.pullRefresh(pullRefreshState)) {
when {
viewModel.showNoProjects -> EmptyProjectsState(onAccountSwitcherClick, onAddProjectClick)
(viewModel.isRefreshing || viewModel.isLoadingBills) && viewModel.bills.isEmpty() ->
LoadingBillsState()
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.bills.isEmpty() -> EmptyState()
else -> {
@@ -270,6 +270,7 @@ class BillsListViewActivity :
8 -> {
startActivity(net.helcel.cowspent.android.label.LabelManagementActivity.createIntent(this@BillsListViewActivity, pid))
}
9 -> onRemoveProjectClick(pid)
}
},
onAccountSwitcherClick = {
@@ -334,6 +335,7 @@ class BillsListViewActivity :
super.onResume()
val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
val selectedProjectId = preferences.getLong("selected_project", 0)
setupDrawerProjects()
if (selectedProjectId != 0L) {
refreshLists()
}
@@ -341,6 +343,10 @@ class BillsListViewActivity :
synchronize(SyncTrigger.APP_OPEN)
lifecycleScope.launch {
syncNewlyDiscoveredProjects(withContext(Dispatchers.IO) { db.projects })
}
registerBroadcastReceiver()
updateAvatarInDrawer(CowspentServerSyncHelper.isNextcloudAccountConfigured(this))
isActivityVisible = true
@@ -377,6 +383,8 @@ class BillsListViewActivity :
fun onProjectClick(projectId: Long) {
if (viewModel.selectedProjectId != projectId) {
viewModel.selectedMemberId = null
viewModel.bills = emptyList()
viewModel.isLoadingBills = true
}
setSelectedProject(projectId)
navigationSelection = Category(null, null)
@@ -400,16 +408,34 @@ class BillsListViewActivity :
private fun onRemoveProjectClick(projectId: Long) {
if (projectId == 0L) return
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(
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),
onConfirm = {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
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)
.edit { remove(lastProjectSyncKey(projectId)) }
val dbProjects = db.projects
@@ -621,6 +647,7 @@ class BillsListViewActivity :
val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0)
lifecycleScope.launch {
try {
val (projId, projName) = withContext(Dispatchers.IO) {
if (selectedProjectId != 0L) {
db.getProject(selectedProjectId)?.let {
@@ -692,6 +719,9 @@ class BillsListViewActivity :
viewModel.bills = ljItems
}
}
} finally {
viewModel.isLoadingBills = false
}
}
}
@@ -780,16 +810,19 @@ class BillsListViewActivity :
val selectedProjectId = preferences.getLong("selected_project", 0)
val now = System.currentTimeMillis()
// The account and all-projects refresh belongs to opening the app, throttled by the
// SyncOnOpen interval so that resuming within the interval does not repeat it.
// The account tells the app which projects exist at all, so a project joined elsewhere only
// 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 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
val accountSyncDue = openPass || trigger == SyncTrigger.MANUAL
lifecycleScope.launch {
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
// 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
@@ -799,7 +832,11 @@ class BillsListViewActivity :
viewModel.isRefreshing = true
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
// 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
@@ -807,12 +844,13 @@ class BillsListViewActivity :
preferences.edit {
putLong(getString(R.string.pref_key_last_account_sync_timestamp), now)
}
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
db.cowspentServerSyncHelper.runAccountProjectsSync()
remoteProjects.filter { !it.isArchived }.count {
val full = neverSynced(it)
val scheduled = db.cowspentServerSyncHelper.scheduleSync(false, it, full) != null
if (scheduled) {
markProjectSynced(preferences, it.id, now)
if (full) markFullySynced(preferences, it.id, now)
}
remoteProjects.count {
val scheduled = db.cowspentServerSyncHelper.scheduleSync(false, it, false) != null
if (scheduled) markProjectSynced(preferences, it.id, now)
scheduled
}
} else {
@@ -824,13 +862,11 @@ class BillsListViewActivity :
// 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
// stays in charge there.
val neverCompleted = selectedProj != null &&
selectedProj.type != ProjectType.IHATEMONEY &&
(selectedProj.lastSyncedTimestamp ?: 0L) == 0L
val neverCompleted = selectedProj != null && neverSynced(selectedProj)
val due = trigger == SyncTrigger.MANUAL || neverCompleted ||
now - lastSync > SELECTED_PROJECT_SYNC_INTERVAL_MS
val fullSync = trigger == SyncTrigger.MANUAL &&
!partialRefreshPreferred(preferences, selectedProjectId, now)
val fullSync = neverCompleted || (trigger == SyncTrigger.MANUAL &&
!partialRefreshPreferred(preferences, selectedProjectId, now))
if (selectedProj != null && due &&
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 lastFullSyncKey(projectId: Long) = "lastFullSyncTimestamp_$projectId"
@@ -948,6 +1002,7 @@ class BillsListViewActivity :
synchronize(SyncTrigger.PROJECT_OPEN)
}
}
syncNewlyDiscoveredProjects(dbProjects)
}
}
}
@@ -20,6 +20,7 @@ class BillsListViewModel : ViewModel() {
var selectedMemberId by mutableStateOf<Long?>(null)
var bills by mutableStateOf<List<Item>>(emptyList())
var isRefreshing by mutableStateOf(false)
var isLoadingBills by mutableStateOf(false)
var searchQuery by mutableStateOf("")
var title by mutableStateOf("")
var accountName by mutableStateOf("")
@@ -24,6 +24,7 @@ import net.helcel.cowspent.model.ProjectType
fun ProjectOptionsDialogContent(
onEditProject: () -> Unit,
onRemoveProject: () -> Unit,
onForgetProject: () -> Unit = {},
onManageMembers: () -> Unit,
onManageCurrencies: () -> 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 archiveIcon = if (isArchived) Icons.Default.Unarchive else Icons.Default.Archive
row1.add(ProjectOption(archiveLabel, archiveIcon, onRemoveProject))
if (isArchived) {
row1.add(
ProjectOption(
stringResource(R.string.action_forget),
Icons.Default.Delete,
onForgetProject
)
)
}
} else {
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.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -15,8 +16,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.app.NavUtils
import androidx.preference.PreferenceManager
import net.helcel.cowspent.R
import net.helcel.cowspent.android.about.AboutActivity
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.util.ColorUtils
@@ -25,6 +31,25 @@ import net.helcel.cowspent.util.ColorUtils
*/
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?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -59,13 +84,14 @@ class PreferencesActivity : AppCompatActivity() {
SettingsScreen(
onBack = { NavUtils.navigateUpFromSameTask(this) },
onAccountSettingsClick = {
startActivity(Intent(this, AccountActivity::class.java))
accountLauncher.launch(Intent(this, AccountActivity::class.java))
},
onAboutClick = {
startActivity(Intent(this, AboutActivity::class.java))
},
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.Scaffold
import androidx.compose.material.Slider
import androidx.compose.material.Checkbox
import androidx.compose.material.Switch
import androidx.compose.material.SwitchDefaults
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.Info
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material.icons.filled.Sync
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
@@ -56,6 +61,9 @@ import androidx.core.content.edit
import androidx.preference.PreferenceManager
import net.helcel.cowspent.R
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.CowspentServerSyncHelper
import net.helcel.cowspent.util.ColorUtils
@@ -68,7 +76,8 @@ fun SettingsScreen(
onAccountSettingsClick: () -> Unit,
onAboutClick: () -> Unit,
onColorSelected: (Int) -> Unit,
onNightModeChanged: (String) -> Unit = {}
onNightModeChanged: (String) -> Unit = {},
onRestoreDeletedProjects: (List<DBAccountProject>) -> Unit = {}
) {
val context = LocalContext.current
val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) }
@@ -92,6 +101,16 @@ fun SettingsScreen(
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
var nightMode by remember(keyNightMode) {
mutableStateOf(sharedPreferences.getString(keyNightMode, "-1") ?: "-1")
@@ -257,6 +276,32 @@ fun SettingsScreen(
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
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
fun SettingsCategory(title: String) {
Text(
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.database.DatabaseUtils
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.text.TextUtils
@@ -238,13 +239,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread
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)
val projects: MutableList<DBProject> = ArrayList()
while (cursor.moveToNext()) {
projects.add(getProjectFromCursor(cursor))
}
cursor.close()
return projects
return queryAll(db, table_projects, columnsProjects, selection, selectionArgs, orderBy, ::getProjectFromCursor)
}
@SuppressLint("Range")
@@ -305,11 +300,17 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
fun deleteProject(id: Long) {
val db = writableDatabase
for (b in getBillsOfProject(id)) {
deleteBill(b.id)
val projectId = arrayOf(id.toString())
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")
}
@@ -413,14 +414,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread
private fun getMembersCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBMember> {
val db = readableDatabase
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
return queryAll(readableDatabase, table_members, columnsMembers, selection, selectionArgs, orderBy, ::getMemberFromCursor)
}
@SuppressLint("Range")
@@ -561,6 +555,14 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
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> {
return getBillsCustom(
"$key_projectid = ? AND $key_state = ?",
@@ -644,29 +646,20 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread
private fun getBillsCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBBill> {
val db = readableDatabase
val cursor = db.query(table_bills, columnsBills, selection, selectionArgs, null, null, orderBy)
val bills: MutableList<DBBill> = ArrayList()
while (cursor.moveToNext()) {
bills.add(getBillFromCursor(cursor))
}
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(
return db.transactionally {
val bills = queryAll(db, table_bills, columnsBills, selection, selectionArgs, orderBy, ::getBillFromCursor)
if (bills.isNotEmpty()) {
val owersByBill = queryAll(
db, table_billowers, columnsBillowers,
"$key_billId IN (SELECT $key_id FROM $table_bills WHERE $selection)",
selectionArgs,
null
selectionArgs, null, ::getBillOwerFromCursor
).groupBy { it.billId }
for (bill in bills) {
bill.billOwers = owersByBill[bill.id].orEmpty()
}
return bills
}
bills
}
}
@SuppressLint("Range")
@@ -714,14 +707,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread
private fun getBillOwersCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBBillOwer> {
val db = readableDatabase
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
return queryAll(readableDatabase, table_billowers, columnsBillowers, selection, selectionArgs, orderBy, ::getBillOwerFromCursor)
}
@SuppressLint("Range")
@@ -788,14 +774,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread
private fun getCategoriesCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBCategory> {
val db = readableDatabase
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
return queryAll(readableDatabase, table_categories, columnsCategories, selection, selectionArgs, orderBy, ::getCategoryFromCursor)
}
@SuppressLint("Range")
@@ -904,13 +883,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread
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)
val paymentModes: MutableList<DBPaymentMode> = ArrayList()
while (cursor.moveToNext()) {
paymentModes.add(getPaymentModeFromCursor(cursor))
}
cursor.close()
return paymentModes
return queryAll(db, table_payment_modes, columnsPaymentModes, selection, selectionArgs, orderBy, ::getPaymentModeFromCursor)
}
@SuppressLint("Range")
@@ -1020,13 +993,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread
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)
val currencies: MutableList<DBCurrency> = ArrayList()
while (cursor.moveToNext()) {
currencies.add(getCurrencyFromCursor(cursor))
}
cursor.close()
return currencies
return queryAll(db, table_currencies, columnsCurrencies, selection, selectionArgs, orderBy, ::getCurrencyFromCursor)
}
@SuppressLint("Range")
@@ -1074,6 +1041,36 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
// --- 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) {
if (!proj.isLocal) {
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
@@ -1146,13 +1143,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread
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)
val accountProjects: MutableList<DBAccountProject> = ArrayList()
while (cursor.moveToNext()) {
accountProjects.add(getAccountProjectFromCursor(cursor))
}
cursor.close()
return accountProjects
return queryAll(db, table_account_projects, columnsAccountProjects, selection, selectionArgs, orderBy, ::getAccountProjectFromCursor)
}
@SuppressLint("Range")
@@ -6,6 +6,7 @@ import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import androidx.core.content.edit
import androidx.core.graphics.toColorInt
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.TokenMismatchException
import com.nextcloud.android.sso.helper.SingleAccountHelper
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
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.main.BillsListViewActivity
import net.helcel.cowspent.android.main.MainConstants
import net.helcel.cowspent.model.DBAccountProject
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.model.DBProject
@@ -176,14 +179,22 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
}
fun execute(): SyncTask {
deferred = scope.async {
syncActive = true
deferred = scope.async {
try {
val status = withContext(Dispatchers.IO) {
doWork()
}
onPostExecute(status)
syncActive = false
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
}
}
return this
}
@@ -293,12 +304,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
null, null, null, null, null
)
}
} catch (e: IOException) {
if (e.message == "{\"message\": \"Internal Server Error\"}") {
Log.d(TAG, "EDIT MEMBER FAILED : it does not exist remotely")
} else {
throw e
}
} catch (e: Exception) {
Log.e(TAG, "EDIT MEMBER FAILED for ${mToEdit.name}", e)
}
}
@@ -1048,6 +1055,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
for (localMember in dbHelper.getMembersOfProject(project.id, null)) {
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.
if (dbHelper.getBillsOfMember(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() {
Log.d(TAG, "Account projects sync requested; ${if (syncAccountProjectsActive) "sync active" else "sync NOT active"}) ...")
updateNetworkStatus()
@@ -1514,12 +1538,19 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
fun execute(): SyncAccountProjectsTask {
scope.launch {
syncAccountProjectsActive = true
try {
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
}
}
return this
}
@@ -1547,20 +1578,25 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val response = client.getAccountProjects(useOcsApi)
val remoteAccountProjects = response.getAccountProjects(url)
val forgotten = forgottenAccountProjects(preferences)
dbHelper.clearAccountProjects()
for (remoteAccountProject in remoteAccountProjects) {
dbHelper.addAccountProject(remoteAccountProject)
Log.v(TAG, "received account project $remoteAccountProject")
val existingProj = localProjects.find {
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 (accountProjectKey(remoteAccountProject.remoteId, remoteAccountProject.ncUrl) in forgotten) {
Log.d(TAG, "skipping ${remoteAccountProject.remoteId}, deleted on this device")
continue
}
val newProj = DBProject(0,
remoteAccountProject.remoteId,
"",
remoteAccountProject.name,
remoteAccountProject.ncUrl.replace("/+$".toRegex(), "") + "/index.php/apps/cospend",
remoteAccountProject.ncUrl.replace("/+$".toRegex(), "") + COSPEND_PATH,
"",
null,
ProjectType.COSPEND,
@@ -1896,6 +1932,34 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
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 {
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
return if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) {
+222 -230
View File
@@ -1,266 +1,258 @@
<?xml version="1.0" encoding="utf-8"?>
<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 -->
<string name="action_new_bill">New bill</string>
<string name="action_add_project">Add project</string>
<string name="action_save">Save</string>
<string name="action_edit">Edit</string>
<string name="action_share">Share</string>
<string name="action_search">Search</string>
<string name="action_open_menu">Open menu</string>
<string name="action_close_search">Close search</string>
<string name="action_clear_search">Clear search</string>
<string name="action_delete">Delete</string>
<string name="simple_back">Back</string>
<string name="action_archive">Archive</string>
<string name="action_unarchive">Unarchive</string>
<string name="action_export">Export</string>
<string name="action_stats">Stats</string>
<string name="action_settle">Settle</string>
<string name="action_scan_qrcode">Scan QR Code</string>
<string name="action_settings">Settings</string>
<string name="action_label_bills">Label missing categories</string>
<string name="action_logout">Logout</string>
<string name="action_connect">Connect</string>
<string name="action_discard">Discard</string>
<string name="action_members">Members</string>
<string name="action_labels">Labels</string>
<string name="action_currencies">Currencies</string>
<string name="action_new_bill">Новый расход</string>
<string name="action_add_project">Добавить проект</string>
<string name="action_save">Сохранить</string>
<string name="action_edit">Редактировать</string>
<string name="action_share">Поделиться</string>
<string name="action_search">Поиск</string>
<string name="action_open_menu">Открыть меню</string>
<string name="action_close_search">Закрыть поиск</string>
<string name="action_clear_search">Очистить поиск</string>
<string name="action_delete">Удалить</string>
<string name="simple_back">Назад</string>
<string name="action_archive">Архивировать</string>
<string name="action_unarchive">Разархивировать</string>
<string name="action_export">Экспорт</string>
<string name="action_stats">Статистика</string>
<string name="action_settle">Свести баланс</string>
<string name="action_scan_qrcode">Отсканировать QR-код</string>
<string name="action_settings">Настройки</string>
<string name="action_label_bills">Указать отсутствующие категории</string>
<string name="action_logout">Выйти</string>
<string name="action_connect">Подключиться</string>
<string name="action_discard">Отменить</string>
<string name="action_members">Участники</string>
<string name="action_labels">Метки</string>
<string name="action_currencies">Валюты</string>
<!-- Titles -->
<string name="title_stats">Statistics</string>
<string name="title_edit_project">Edit project</string>
<string name="title_label_bills">Label Bills</string>
<string name="title_labels">Manage Labels</string>
<string name="title_about">About</string>
<string name="title_settle">Settle Project</string>
<string name="title_share">Share Project</string>
<string name="title_add_project">Add Project</string>
<string name="title_add_category">Add Category</string>
<string name="title_add_payment_mode">Add Payment Mode</string>
<string name="title_account">Nextcloud Account</string>
<string name="title_share_web">Web link</string>
<string name="title_share_qr">Cowspent link</string>
<string name="title_confirm">Are you sure?</string>
<string name="title_stats">Статистика</string>
<string name="title_edit_project">Редактировать проект</string>
<string name="title_label_bills">Указать категории</string>
<string name="title_labels">Управление метками</string>
<string name="title_about">О программе</string>
<string name="title_settle">Сведение баланса</string>
<string name="title_share">Поделиться проектом</string>
<string name="title_add_project">Добавить проект</string>
<string name="title_add_category">Добавить категорию</string>
<string name="title_add_payment_mode">Добавить способ оплаты</string>
<string name="title_account">Учётная запись Nextcloud</string>
<string name="title_share_web">Веб-ссылка</string>
<string name="title_share_qr">Ссылка Cowspent</string>
<string name="title_confirm">Вы уверены?</string>
<!-- Labels and Fields -->
<string name="label_all_bills">All bills</string>
<string name="label_categories">Categories</string>
<string name="label_payment_modes">Payment Modes</string>
<string name="label_name">Name</string>
<string name="label_icon">Icon / Emoji</string>
<string name="label_color">Color</string>
<string name="label_weight">Weight</string>
<string name="label_activated">Activated</string>
<string name="label_password">Password</string>
<string name="label_email">E-mail</string>
<string name="label_url">Server address</string>
<string name="label_username">Username</string>
<string name="label_comment">Comment</string>
<string name="label_what">What?</string>
<string name="label_payer">Who paid?</string>
<string name="label_owers">For whom?</string>
<string name="label_repeat">Repeat every</string>
<string name="label_mode">Mode</string>
<string name="label_category">Category</string>
<string name="label_project_id">Project ID/name</string>
<string name="label_project_title">Project title</string>
<string name="label_use_sso">Use Nextcloud App Account</string>
<string name="label_all_bills">Все расходы</string>
<string name="label_categories">Категории</string>
<string name="label_payment_modes">Способы оплаты</string>
<string name="label_name">Название</string>
<string name="label_icon">Значок / Смайлик</string>
<string name="label_color">Цвет</string>
<string name="label_weight">Вес</string>
<string name="label_activated">Активировано</string>
<string name="label_password">Пароль</string>
<string name="label_email">Электронная почта</string>
<string name="label_url">Адрес сервера</string>
<string name="label_username">Имя пользователя</string>
<string name="label_comment">Комментарий</string>
<string name="label_what">Что?</string>
<string name="label_payer">Кто оплатил?</string>
<string name="label_owers">Для кого?</string>
<string name="label_repeat">Повторять каждые</string>
<string name="label_mode">Режим</string>
<string name="label_category">Категория</string>
<string name="label_project_id">Идентификатор/название проекта</string>
<string name="label_project_title">Название проекта</string>
<string name="label_use_sso">Использовать учётную запись приложения Nextcloud</string>
<!-- Dialogs and Messages -->
<string name="dialog_unsaved_changes_title">Unsaved changes</string>
<string name="dialog_unsaved_changes_msg">Save changes before leaving?</string>
<string name="dialog_confirm_remove_project_msg">The remote project will not be deleted.</string>
<string name="dialog_sync_error_title">Sync error</string>
<string name="dialog_sync_error_msg">Sync failed for %1$s.\n\n%2$s</string>
<string name="dialog_balanced_msg">Expenses are already balanced.</string>
<string name="msg_project_added">Project %1$s added</string>
<string name="msg_bill_labeled_done">All bills labeled</string>
<string name="msg_no_suggestions">No suggestions</string>
<string name="msg_auth_warning">Requires Cospend v0.3.4+.</string>
<string name="msg_link_copied">Link copied to clipboard</string>
<string name="msg_share_qr">Scan QR code or share the link to join.</string>
<string name="msg_share_web">Link for web browser access.</string>
<string name="msg_share_qr_warn">Share this link with a Cowspent user.</string>
<string name="msg_settle_intro">Settlement for %1$s:</string>
<string name="msg_settle_sentence">%1$s owes %3$.2f to %2$s</string>
<string name="msg_stats_intro">Stats for %1$s:</string>
<string name="msg_stats_header">Member (Paid | Spent | Balance)</string>
<string name="msg_logged_in_as">Logged in as %1$s</string>
<string name="dialog_unsaved_changes_title">Несохраненные изменения</string>
<string name="dialog_unsaved_changes_msg">Сохранить изменения перед выходом?</string>
<string name="dialog_confirm_remove_project_msg">Проект на сервере не будет удален.</string>
<string name="dialog_sync_error_title">Ошибка синхронизации</string>
<string name="dialog_sync_error_msg">Не удалось синхронизировать %1$s.\n\n%2$s</string>
<string name="dialog_balanced_msg">Расходы уже сбалансированы.</string>
<string name="msg_project_added">Проект %1$s добавлен</string>
<string name="msg_bill_labeled_done">Все расходы помечены</string>
<string name="msg_no_suggestions">Нет предложений</string>
<string name="msg_auth_warning">Требуется Cospend v0.3.4+.</string>
<string name="msg_link_copied">Ссылка скопирована в буфер обмена</string>
<string name="msg_share_qr">Отсканируйте QR-код или поделитесь ссылкой, чтобы присоединиться.</string>
<string name="msg_share_web">Ссылка для доступа через веб-браузер.</string>
<string name="msg_share_qr_warn">Поделитесь этой ссылкой с пользователем Cowspent.</string>
<string name="msg_settle_intro">Расчет по %1$s:</string>
<string name="msg_settle_sentence">%1$s должен %2$s %3$.2f</string>
<string name="msg_stats_intro">Статистика для %1$s:</string>
<string name="msg_stats_header">Участник (Оплачено | Потрачено | Баланс)</string>
<string name="msg_logged_in_as">Вошёл под именем %1$s</string>
<!-- Errors -->
<string name="error_generic">Error</string>
<string name="error_loading">Loading</string>
<string name="error_no_projects">No projects found</string>
<string name="error_no_members">No members found</string>
<string name="error_no_bills">No bills found</string>
<string name="error_no_member">At least one member required</string>
<string name="error_maintenance_mode">Server is in maintenance mode</string>
<string name="error_400">400 Bad request</string>
<string name="error_401">401 Unauthorized</string>
<string name="error_403">403 Forbidden</string>
<string name="error_404">404 Not Found</string>
<string name="error_sync">Sync failed: %1$s</string>
<string name="error_invalid_login">Invalid login: %1$s</string>
<string name="error_auth">Wrong username or password</string>
<string name="error_json">Invalid server response</string>
<string name="error_req_failed">Request failed</string>
<string name="error_invalid_email">Invalid e-mail</string>
<string name="error_invalid_project_id">Invalid project ID</string>
<string name="error_invalid_project_name">Invalid project title</string>
<string name="error_invalid_bill_name">Invalid bill name</string>
<string name="error_invalid_bill_date">Invalid bill date</string>
<string name="error_invalid_bill_payer">Payer required</string>
<string name="error_invalid_bill_owers">Owers required</string>
<string name="error_no_network">No network connection</string>
<string name="error_server">Server error</string>
<string name="error_io">Server connection broken</string>
<string name="error_share_impossible">Cannot share this project</string>
<string name="error_generic">Ошибка</string>
<string name="error_loading">Загрузка</string>
<string name="error_no_projects">Проекты не найдены</string>
<string name="error_no_members">Участники не найдены</string>
<string name="error_no_bills">Расходы не найдены</string>
<string name="error_no_member">Требуется как минимум один участник</string>
<string name="error_maintenance_mode">Сервер находится в режиме технического обслуживания</string>
<string name="error_400">400 Неверный запрос</string>
<string name="error_401">401 Неавторизованный доступ</string>
<string name="error_403">403 Доступ запрещен</string>
<string name="error_404">404 Не найдено</string>
<string name="error_sync">Сбой синхронизации: %1$s</string>
<string name="error_invalid_login">Неверный логин: %1$s</string>
<string name="error_auth">Неверное имя пользователя или пароль</string>
<string name="error_json">Неверный ответ сервера</string>
<string name="error_req_failed">Ошибка запроса</string>
<string name="error_invalid_email">Неверный адрес электронной почты</string>
<string name="error_invalid_project_id">Неверный идентификатор проекта</string>
<string name="error_invalid_project_name">Неверное название проекта</string>
<string name="error_invalid_bill_name">Неверное название расхода</string>
<string name="error_invalid_bill_date">Неверная дата расхода</string>
<string name="error_invalid_bill_payer">Требуется указать плательщика</string>
<string name="error_invalid_bill_owers">Требуется указать участников</string>
<string name="error_no_network">Отсутствует подключение к сети</string>
<string name="error_server">Ошибка сервера</string>
<string name="error_io">Сбой соединения с сервером</string>
<string name="error_share_impossible">Невозможно поделиться этим проектом</string>
<!-- Drawer / Common UI -->
<string name="drawer_no_account">Connect to Nextcloud account</string>
<string name="drawer_last_sync">Last sync: %1$02d:%2$02d</string>
<string name="simple_cancel">Cancel</string>
<string name="simple_ok" tools:ignore="ButtonCase,Typos">Ok</string>
<string name="simple_yes">Yes</string>
<string name="simple_no">No</string>
<string name="simple_close">Close</string>
<string name="drawer_no_account">Подключиться к учетной записи Nextcloud</string>
<string name="drawer_last_sync">Последняя синхронизация: %1$02d:%2$02d</string>
<string name="simple_cancel">Отменить</string>
<string name="simple_ok" tools:ignore="ButtonCase,Typos">ОК</string>
<string name="simple_yes">Да</string>
<string name="simple_no">Нет</string>
<string name="simple_close">Закрыть</string>
<!-- Settings -->
<string name="settings_appearance">Appearance</string>
<string name="settings_network">Network</string>
<string name="settings_other">Other</string>
<string name="settings_night_mode">Theme</string>
<string name="settings_offline_mode">Offline mode</string>
<string name="settings_offline_mode_summary">Only sync manually.</string>
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</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="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
<string name="settings_colorpicker_title">Choose Color</string>
<string name="pref_value_color_system">System</string>
<string name="pref_value_color_manual">Manual</string>
<string name="pref_value_theme_light">Light</string>
<string name="pref_value_theme_dark">Dark</string>
<string name="pref_value_theme_system">Follow system</string>
<string name="settings_appearance">Внешний вид</string>
<string name="settings_network">Сеть</string>
<string name="settings_other">Прочее</string>
<string name="settings_night_mode">Тема</string>
<string name="settings_offline_mode">Офлайн-режим</string>
<string name="settings_offline_mode_summary">Синхронизация только вручную</string>
<string name="settings_color_custom">Пользовательский цвет</string>
<string name="settings_color_mode">Выбор цвета</string>
<string name="settings_show_archived">Показывать заархивированные проекты</string>
<string name="settings_fill_new_bill_from_last">Автозаполнение на основе последнего расхода</string>
<string name="settings_fill_new_bill_from_last_summary">Предварительное заполнение полей "Плательщик", "Категория", "Способ оплаты" и "Для кого" на основе последнего расхода, созданного в проекте.</string>
<string name="pref_value_sync_1h">1 час</string>
<string name="pref_value_sync_1d">1 день</string>
<string name="settings_url_warn_http">ПРЕДУПРЕЖДЕНИЕ: "http" небезопасен. Используйте "https".</string>
<string name="settings_colorpicker_title">Выбрать цвет</string>
<string name="pref_value_color_system">Системная</string>
<string name="pref_value_color_manual">Вручную</string>
<string name="pref_value_theme_light">Светлая</string>
<string name="pref_value_theme_dark">Тёмная</string>
<string name="pref_value_theme_system">По системе</string>
<!-- Enums and Lists -->
<string name="repeat_no">No repeat</string>
<string name="repeat_day">Daily</string>
<string name="repeat_week">Weekly</string>
<string name="repeat_fortnight">Fortnightly</string>
<string name="repeat_month">Monthly</string>
<string name="repeat_year">Yearly</string>
<string name="repeat_no">Без повторения</string>
<string name="repeat_day">Ежедневно</string>
<string name="repeat_week">Еженедельно</string>
<string name="repeat_fortnight">Раз в две недели</string>
<string name="repeat_month">Ежемесячно</string>
<string name="repeat_year">Ежегодно</string>
<string name="payment_mode_none">None</string>
<string name="payment_mode_all">All</string>
<string name="payment_mode_credit_card">Credit card</string>
<string name="payment_mode_cash">Cash</string>
<string name="payment_mode_check">Check</string>
<string name="payment_mode_online">Online</string>
<string name="payment_mode_transfer">Transfer</string>
<string name="payment_mode_none">Нет</string>
<string name="payment_mode_all">Все</string>
<string name="payment_mode_credit_card">Кредитная карта</string>
<string name="payment_mode_cash">Наличные</string>
<string name="payment_mode_check">Чек</string>
<string name="payment_mode_online">Онлайн</string>
<string name="payment_mode_transfer">Перевод</string>
<string name="category_none">None</string>
<string name="category_all">All</string>
<string name="category_all_except_reimbursement">All except reimbursement</string>
<string name="category_groceries">Grocery</string>
<string name="category_leisure">Bar/Party</string>
<string name="category_rent">Rent</string>
<string name="category_bills">Bill</string>
<string name="category_excursion">Excursion/Culture</string>
<string name="category_health">Health</string>
<string name="category_shopping">Shopping</string>
<string name="category_reimbursement">Reimbursement</string>
<string name="category_restaurant">Restaurant</string>
<string name="category_accomodation">Accommodation</string>
<string name="category_transport">Transport</string>
<string name="category_sport">Sport</string>
<string name="category_none">Нет</string>
<string name="category_all">Все</string>
<string name="category_all_except_reimbursement">Все, кроме возмещения</string>
<string name="category_groceries">Продукты</string>
<string name="category_leisure">Бар/Вечеринка</string>
<string name="category_rent">Аренда</string>
<string name="category_bills">Счета</string>
<string name="category_excursion">Экскурсии/Культура</string>
<string name="category_health">Здоровье</string>
<string name="category_shopping">Покупки</string>
<string name="category_reimbursement">Возмещение расходов</string>
<string name="category_restaurant">Ресторан</string>
<string name="category_accomodation">Проживание</string>
<string name="category_transport">Транспорт</string>
<string name="category_sport">Спорт</string>
<!-- Project specific -->
<string name="new_project_action">What</string>
<string name="new_project_where">Where</string>
<string name="where_local">Local only</string>
<string name="todo_join">Join existing project</string>
<string name="todo_create">Create new project</string>
<string name="import_tooltip">Import from file</string>
<string name="choose_project_management_action">Project</string>
<string name="project_added_success">Project added successfully.</string>
<string name="no_projects_text">You have no projects yet.</string>
<string name="configure_account_choice">Configure Nextcloud account</string>
<string name="add_project_choice">Add project manually</string>
<string name="no_members_text">No members in this project.</string>
<string name="no_bills_text">No bills found.</string>
<string name="member_already_exists">Member already exists.</string>
<string name="activity_dialog_title">Project: %1$s</string>
<string name="remove_project_confirmation">Project %1$s removed.</string>
<string name="file_saved_success">File saved: %1$s</string>
<string name="import_error_header">Import failed at row %d</string>
<string name="import_error_date">Invalid date format at row %d</string>
<string name="import_error_owers">Invalid owers at row %d</string>
<string name="add_member_dialog_title">Add Member</string>
<string name="edit_member_dialog_title">Edit Member</string>
<string name="member_edit_delete">Delete</string>
<string name="project_edition_no_change">No changes to save.</string>
<string name="new_project_action">Что</string>
<string name="new_project_where">Где</string>
<string name="where_local">Только локально</string>
<string name="todo_join">Присоединиться к существующему проекту</string>
<string name="todo_create">Создать новый проект</string>
<string name="import_tooltip">Импортировать из файла</string>
<string name="choose_project_management_action">Проект</string>
<string name="project_added_success">Проект добавлен успешно.</string>
<string name="no_projects_text">У вас пока нет проектов.</string>
<string name="configure_account_choice">Настроить учётную запись Nextcloud</string>
<string name="add_project_choice">Добавить проект вручную</string>
<string name="no_members_text">В этом проекте нет участников.</string>
<string name="no_bills_text">Расходов не найдено.</string>
<string name="member_already_exists">Участник уже существует.</string>
<string name="activity_dialog_title">Проект: %1$s</string>
<string name="remove_project_confirmation">Проект %1$s удален.</string>
<string name="file_saved_success">Файл сохранен: %1$s</string>
<string name="import_error_header">Ошибка импорта в строке %d</string>
<string name="import_error_date">Недопустимый формат даты в строке %d</string>
<string name="import_error_owers">Недопустимые участники в строке %d</string>
<string name="add_member_dialog_title">Добавить участника</string>
<string name="edit_member_dialog_title">Редактировать участника</string>
<string name="member_edit_delete">Удалить</string>
<string name="project_edition_no_change">Изменений для сохранения нет.</string>
<!-- Settlement -->
<string name="center_none">None (Optimal)</string>
<string name="settle_who">Who pays</string>
<string name="settle_to_whom">To whom</string>
<string name="settle_how_much">Amount</string>
<string name="simple_settle_share">Share</string>
<string name="simple_create_bills">Create bills</string>
<string name="settle_bill_what">Settlement</string>
<string name="center_none">Нет (оптимально)</string>
<string name="settle_who">Кто платит</string>
<string name="settle_to_whom">Кому</string>
<string name="settle_how_much">Сумма</string>
<string name="simple_settle_share">Поделиться</string>
<string name="simple_create_bills">Создать расходы</string>
<string name="settle_bill_what">Расход</string>
<!-- Currencies -->
<string name="currency_dialog_title">Choose Currency (%s)</string>
<string name="setting_none">None</string>
<string name="setting_all">All</string>
<string name="currency_saved_success">Currency settings saved.</string>
<string name="main_currency">Main Currency</string>
<string name="currency_dialog_title">Выбрать валюту (%s)</string>
<string name="setting_none">Нет</string>
<string name="setting_all">Все</string>
<string name="currency_saved_success">Настройки валюты сохранены.</string>
<string name="main_currency">Основная валюта</string>
<!-- Statistics -->
<string name="label_bills_suggested">Suggested Categories</string>
<string name="label_bills_skip">Skip</string>
<string name="stats_date_min">From</string>
<string name="stats_date_max">To</string>
<string name="stats_who">Member</string>
<string name="stats_paid">Paid</string>
<string name="stats_spent">Spent</string>
<string name="stats_balance">Balance</string>
<string name="total">Total: %1$s</string>
<string name="label_bills_suggested">Рекомендуемые категории</string>
<string name="label_bills_skip">Пропустить</string>
<string name="stats_date_min">С</string>
<string name="stats_date_max">По</string>
<string name="stats_who">Участник</string>
<string name="stats_paid">Оплачено</string>
<string name="stats_spent">Потрачено</string>
<string name="stats_balance">Баланс</string>
<string name="total">Итого: %1$s</string>
<!-- Errors Extra -->
<string name="error_project_connect_check">Connection failed: %1$s</string>
<string name="error_create_remote_project_helper">Creation failed: %1$s</string>
<string name="error_edit_remote_project_helper">Error updating remote project: %1$s</string>
<string name="remote_project_operation_no_network">Network unavailable for remote operation.</string>
<string name="error_scanning_bill_qr_code">Failed to parse QR code.</string>
<string name="error_token_mismatch">Authentication token mismatch. Please log in again.</string>
<string name="insufficient_access_level">You don\'t have permission to perform this action.</string>
<string name="delete_label_confirmation_title">Delete Label</string>
<string name="delete_label_confirmation_message">Are you sure you want to delete this label?</string>
<string name="error_project_connect_check">Ошибка подключения: %1$s</string>
<string name="error_create_remote_project_helper">Ошибка создания: %1$s</string>
<string name="error_edit_remote_project_helper">Ошибка обновления удаленного проекта: %1$s</string>
<string name="remote_project_operation_no_network">Отсутствует подключение к сети для операций на сервере.</string>
<string name="error_scanning_bill_qr_code">Не удалось распознать QR-код.</string>
<string name="error_token_mismatch">Ошибка токена аутентификации. Пожалуйста, войдите заново.</string>
<string name="insufficient_access_level">У вас нет прав на выполнение этого действия.</string>
<string name="delete_label_confirmation_title">Удалить метку</string>
<string name="delete_label_confirmation_message">Вы действительно хотите удалить эту метку?</string>
<!-- About -->
<string name="about_version">Version %1$s</string>
<string name="about_maintainer_title">Maintainer</string>
<string name="about_license_title">License</string>
<string name="about_source_title">Source code</string>
<string name="about_version">Версия %1$s</string>
<string name="about_maintainer_title">Сопровождающий</string>
<string name="about_license_title">Лицензия</string>
<string name="about_source_title">Исходный код</string>
<!-- New constants for backward compatibility or shared use -->
<string name="share_intent_title">Project %1$s</string>
<string name="share_chooser_title">Share %1$s</string>
<string name="share_intent_title">Проект %1$s</string>
<string name="share_chooser_title">Поделиться %1$s</string>
</resources>
+21
View File
@@ -17,6 +17,7 @@
<string name="simple_back">Back</string>
<string name="action_archive">Archive</string>
<string name="action_unarchive">Unarchive</string>
<string name="action_forget">Delete locally</string>
<string name="action_export">Export</string>
<string name="action_stats">Stats</string>
<string name="action_settle">Settle</string>
@@ -24,6 +25,8 @@
<string name="action_settings">Settings</string>
<string name="action_label_bills">Label missing categories</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_discard">Discard</string>
<string name="action_members">Members</string>
@@ -136,6 +139,9 @@
<string name="settings_night_mode">Theme</string>
<string name="settings_offline_mode">Offline mode</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_mode">Color Selection</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_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>