Bill Sync
This commit is contained in:
@@ -331,7 +331,7 @@ class EditBillActivity : AppCompatActivity() {
|
|||||||
bill.amount == viewModel.getFinalAmount() &&
|
bill.amount == viewModel.getFinalAmount() &&
|
||||||
bill.payerId == viewModel.payerId &&
|
bill.payerId == viewModel.payerId &&
|
||||||
bill.comment == viewModel.getFinalComment() &&
|
bill.comment == viewModel.getFinalComment() &&
|
||||||
bill.repeat == viewModel.repeat &&
|
(bill.repeat ?: DBBill.NON_REPEATED) == viewModel.repeat &&
|
||||||
bill.categoryId == viewModel.categoryId &&
|
bill.categoryId == viewModel.categoryId &&
|
||||||
bill.paymentModeId == viewModel.paymentModeId &&
|
bill.paymentModeId == viewModel.paymentModeId &&
|
||||||
!owersChanged)
|
!owersChanged)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.view.ActionMode
|
import androidx.appcompat.view.ActionMode
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -53,7 +54,9 @@ import net.helcel.cowspent.util.CospendClientUtil
|
|||||||
import net.helcel.cowspent.util.ExportUtil
|
import net.helcel.cowspent.util.ExportUtil
|
||||||
import net.helcel.cowspent.util.ICallback
|
import net.helcel.cowspent.util.ICallback
|
||||||
import net.helcel.cowspent.util.IRefreshBillsListCallback
|
import net.helcel.cowspent.util.IRefreshBillsListCallback
|
||||||
|
import net.helcel.cowspent.util.SyncSettings
|
||||||
import net.helcel.cowspent.util.SupportUtil
|
import net.helcel.cowspent.util.SupportUtil
|
||||||
|
import net.helcel.cowspent.util.VersatileProjectSyncClient
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
@@ -70,6 +73,9 @@ class BillsListViewActivity :
|
|||||||
|
|
||||||
private val TAG = BillsListViewActivity::class.java.simpleName
|
private val TAG = BillsListViewActivity::class.java.simpleName
|
||||||
|
|
||||||
|
// Opening a project only re-syncs it if it has not synced within this window.
|
||||||
|
private const val SELECTED_PROJECT_SYNC_INTERVAL_MS = 60 * 1000L
|
||||||
|
|
||||||
private const val SAVED_STATE_NAVIGATION_SELECTION = "navigationSelection"
|
private const val SAVED_STATE_NAVIGATION_SELECTION = "navigationSelection"
|
||||||
private const val SAVED_STATE_NAVIGATION_OPEN = "navigationOpen"
|
private const val SAVED_STATE_NAVIGATION_OPEN = "navigationOpen"
|
||||||
|
|
||||||
@@ -276,7 +282,7 @@ class BillsListViewActivity :
|
|||||||
labelBillsLauncher.launch(LabelBillsActivity.createIntent(this, selectedProjectId))
|
labelBillsLauncher.launch(LabelBillsActivity.createIntent(this, selectedProjectId))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRefresh = { synchronize(true) }
|
onRefresh = { synchronize(SyncTrigger.MANUAL) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,10 +336,7 @@ class BillsListViewActivity :
|
|||||||
}
|
}
|
||||||
viewModel.isRefreshing = false
|
viewModel.isRefreshing = false
|
||||||
|
|
||||||
if (db.cowspentServerSyncHelper.isSyncPossible) {
|
synchronize(SyncTrigger.APP_OPEN)
|
||||||
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
|
|
||||||
synchronize()
|
|
||||||
}
|
|
||||||
|
|
||||||
registerBroadcastReceiver()
|
registerBroadcastReceiver()
|
||||||
updateAvatarInDrawer(CowspentServerSyncHelper.isNextcloudAccountConfigured(this))
|
updateAvatarInDrawer(CowspentServerSyncHelper.isNextcloudAccountConfigured(this))
|
||||||
@@ -347,6 +350,9 @@ class BillsListViewActivity :
|
|||||||
} catch (_: RuntimeException) {
|
} catch (_: RuntimeException) {
|
||||||
if (DEBUG) Log.d(TAG, "RECEIVER PROBLEM, let's ignore it...")
|
if (DEBUG) Log.d(TAG, "RECEIVER PROBLEM, let's ignore it...")
|
||||||
}
|
}
|
||||||
|
// The helper outlives the activity, and it only drains its pull callbacks when a task
|
||||||
|
// starts; drop ours so a paused activity is not retained by it.
|
||||||
|
db.cowspentServerSyncHelper.removeCallbackPull(syncCallBack)
|
||||||
isActivityVisible = false
|
isActivityVisible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,7 +379,7 @@ class BillsListViewActivity :
|
|||||||
navigationSelection = Category(null, null)
|
navigationSelection = Category(null, null)
|
||||||
refreshLists(true)
|
refreshLists(true)
|
||||||
|
|
||||||
synchronize()
|
synchronize(SyncTrigger.PROJECT_OPEN)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onManageProjectClick(projectId: Long) {
|
fun onManageProjectClick(projectId: Long) {
|
||||||
@@ -406,7 +412,7 @@ class BillsListViewActivity :
|
|||||||
}
|
}
|
||||||
setupDrawerProjects()
|
setupDrawerProjects()
|
||||||
refreshLists()
|
refreshLists()
|
||||||
synchronize()
|
synchronize(SyncTrigger.PROJECT_OPEN)
|
||||||
val projectNameString = proj.name.ifEmpty { proj.remoteId }
|
val projectNameString = proj.name.ifEmpty { proj.remoteId }
|
||||||
showToast(this@BillsListViewActivity, getString(R.string.remove_project_confirmation, projectNameString))
|
showToast(this@BillsListViewActivity, getString(R.string.remove_project_confirmation, projectNameString))
|
||||||
}
|
}
|
||||||
@@ -421,20 +427,25 @@ class BillsListViewActivity :
|
|||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch
|
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch
|
||||||
val isArchiving = !proj.isArchived
|
val isArchiving = !proj.isArchived
|
||||||
|
|
||||||
val newArchivedTs = if (isArchiving) System.currentTimeMillis() / 1000 else 0L
|
val localArchivedTs = if (isArchiving) System.currentTimeMillis() / 1000 else 0L
|
||||||
|
val remoteArchivedTs = if (isArchiving) {
|
||||||
|
localArchivedTs
|
||||||
|
} else {
|
||||||
|
VersatileProjectSyncClient.REMOTE_ARCHIVED_TS_UNSET
|
||||||
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
db.updateProject(
|
db.updateProject(
|
||||||
projId = projectId,
|
projId = projectId,
|
||||||
newArchivedTs = newArchivedTs
|
newArchivedTs = localArchivedTs
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!proj.isLocal) {
|
if (!proj.isLocal) {
|
||||||
db.cowspentServerSyncHelper.editRemoteProject(
|
db.cowspentServerSyncHelper.editRemoteProject(
|
||||||
projId = projectId,
|
projId = projectId,
|
||||||
newArchivedTs = newArchivedTs,
|
newArchivedTs = remoteArchivedTs,
|
||||||
callback = object : ICallback {
|
callback = object : ICallback {
|
||||||
override fun onFinish() {}
|
override fun onFinish() {}
|
||||||
override fun onFinish(result: String, message: String) {
|
override fun onFinish(result: String, message: String) {
|
||||||
@@ -746,28 +757,60 @@ class BillsListViewActivity :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun synchronize(manual: Boolean = false) {
|
/**
|
||||||
|
* What prompted a sync. Only [APP_OPEN] may refresh the account and every project, and only
|
||||||
|
* then when the SyncOnOpen interval has elapsed; the other triggers touch the selected
|
||||||
|
* project alone.
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
internal enum class SyncTrigger { APP_OPEN, PROJECT_OPEN, MANUAL }
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun synchronize(trigger: SyncTrigger) {
|
||||||
val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||||
val offlineMode = preferences.getBoolean(getString(R.string.pref_key_offline_mode), false)
|
// isSyncPossible is networkConnected && !offlineMode, so offline mode is already covered
|
||||||
if (offlineMode && !manual) {
|
// here - including for a manual refresh, which cannot currently override it.
|
||||||
return
|
if (!db.cowspentServerSyncHelper.isSyncPossible) return
|
||||||
}
|
|
||||||
|
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.
|
||||||
|
val intervalMinutes = SyncSettings.intervalMinutes(applicationContext)
|
||||||
|
val lastAccountSync = preferences.getLong(getString(R.string.pref_key_last_account_sync_timestamp), 0L)
|
||||||
|
val accountSyncDue = trigger == SyncTrigger.APP_OPEN &&
|
||||||
|
now - lastAccountSync > intervalMinutes * 60 * 1000L
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val remoteProjects = withContext(Dispatchers.IO) { db.projects }
|
||||||
|
.filter { !it.isLocal && !it.isArchived }
|
||||||
|
|
||||||
if (db.cowspentServerSyncHelper.isSyncPossible) {
|
|
||||||
viewModel.isRefreshing = true
|
viewModel.isRefreshing = true
|
||||||
val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0)
|
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
|
||||||
if (selectedProjectId != 0L) {
|
|
||||||
lifecycleScope.launch {
|
val started = if (accountSyncDue) {
|
||||||
val proj = withContext(Dispatchers.IO) { db.getProject(selectedProjectId) }
|
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
|
||||||
if (proj != null && !proj.isLocal) {
|
db.cowspentServerSyncHelper.runAccountProjectsSync()
|
||||||
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
|
|
||||||
db.cowspentServerSyncHelper.scheduleSync(false, selectedProjectId, manual)
|
|
||||||
} else viewModel.isRefreshing = false
|
|
||||||
}
|
}
|
||||||
} else viewModel.isRefreshing = false
|
remoteProjects.count {
|
||||||
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
|
db.cowspentServerSyncHelper.scheduleSync(false, it, false) != null
|
||||||
db.cowspentServerSyncHelper.runAccountProjectsSync()
|
}
|
||||||
|
} else {
|
||||||
|
val selectedProj = remoteProjects.find { it.id == selectedProjectId }
|
||||||
|
val lastSync = (selectedProj?.lastSyncedTimestamp ?: 0L) * 1000L
|
||||||
|
val due = trigger == SyncTrigger.MANUAL ||
|
||||||
|
now - lastSync > SELECTED_PROJECT_SYNC_INTERVAL_MS
|
||||||
|
if (selectedProj != null && due &&
|
||||||
|
db.cowspentServerSyncHelper.scheduleSync(
|
||||||
|
false, selectedProj, trigger == SyncTrigger.MANUAL
|
||||||
|
) != null
|
||||||
|
) 1 else 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only a task that actually started reports back through syncCallBack, so clear
|
||||||
|
// the indicator here when none did - nothing else would.
|
||||||
|
if (started == 0) viewModel.isRefreshing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -810,7 +853,7 @@ class BillsListViewActivity :
|
|||||||
refreshLists()
|
refreshLists()
|
||||||
}
|
}
|
||||||
MainConstants.BROADCAST_SYNC_PROJECT -> {
|
MainConstants.BROADCAST_SYNC_PROJECT -> {
|
||||||
synchronize()
|
synchronize(SyncTrigger.PROJECT_OPEN)
|
||||||
}
|
}
|
||||||
MainConstants.BROADCAST_NETWORK_AVAILABLE -> {
|
MainConstants.BROADCAST_NETWORK_AVAILABLE -> {
|
||||||
}
|
}
|
||||||
@@ -838,7 +881,7 @@ class BillsListViewActivity :
|
|||||||
refreshLists()
|
refreshLists()
|
||||||
if (db.cowspentServerSyncHelper.isSyncPossible) {
|
if (db.cowspentServerSyncHelper.isSyncPossible) {
|
||||||
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
|
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
|
||||||
synchronize()
|
synchronize(SyncTrigger.PROJECT_OPEN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import androidx.compose.material.IconButton
|
|||||||
import androidx.compose.material.MaterialTheme
|
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.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
|
||||||
@@ -49,6 +50,7 @@ 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
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import net.helcel.cowspent.R
|
import net.helcel.cowspent.R
|
||||||
@@ -57,6 +59,7 @@ 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
|
||||||
import net.helcel.cowspent.util.Cowspent
|
import net.helcel.cowspent.util.Cowspent
|
||||||
|
import net.helcel.cowspent.util.SyncSettings
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
@@ -81,7 +84,9 @@ fun SettingsScreen(
|
|||||||
val keyOfflineMode = stringResource(R.string.pref_key_offline_mode)
|
val keyOfflineMode = stringResource(R.string.pref_key_offline_mode)
|
||||||
val keyShowArchived = stringResource(R.string.pref_key_show_archived)
|
val keyShowArchived = stringResource(R.string.pref_key_show_archived)
|
||||||
val keyBetaFeatures = stringResource(R.string.pref_key_beta_features)
|
val keyBetaFeatures = stringResource(R.string.pref_key_beta_features)
|
||||||
|
val keyAutoSyncOnOpen = stringResource(R.string.pref_key_auto_sync_on_open)
|
||||||
val keyFillNewBillFromLast = stringResource(R.string.pref_key_fill_new_bill_from_last)
|
val keyFillNewBillFromLast = stringResource(R.string.pref_key_fill_new_bill_from_last)
|
||||||
|
val keyLastAccountSync = stringResource(R.string.pref_key_last_account_sync_timestamp)
|
||||||
|
|
||||||
val isNextcloudConfigured = CowspentServerSyncHelper.isNextcloudAccountConfigured(context)
|
val isNextcloudConfigured = CowspentServerSyncHelper.isNextcloudAccountConfigured(context)
|
||||||
|
|
||||||
@@ -127,6 +132,18 @@ fun SettingsScreen(
|
|||||||
var fillNewBillFromLast by remember(keyFillNewBillFromLast) {
|
var fillNewBillFromLast by remember(keyFillNewBillFromLast) {
|
||||||
mutableStateOf(sharedPreferences.getBoolean(keyFillNewBillFromLast, false))
|
mutableStateOf(sharedPreferences.getBoolean(keyFillNewBillFromLast, false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val syncIntervals = SyncSettings.INTERVAL_CHOICES_MINUTES
|
||||||
|
val syncIntervalLabels = listOf(
|
||||||
|
stringResource(R.string.pref_value_sync_1m),
|
||||||
|
stringResource(R.string.pref_value_sync_10m),
|
||||||
|
stringResource(R.string.pref_value_sync_1h),
|
||||||
|
stringResource(R.string.pref_value_sync_1d)
|
||||||
|
)
|
||||||
|
|
||||||
|
var syncInterval by remember(keyAutoSyncOnOpen) {
|
||||||
|
mutableIntStateOf(SyncSettings.intervalMinutes(context))
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -286,6 +303,24 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SettingsSliderPreference(
|
||||||
|
title = stringResource(R.string.settings_auto_sync_on_open),
|
||||||
|
summary = stringResource(R.string.settings_auto_sync_on_open_summary),
|
||||||
|
icon = Icons.Default.Sync,
|
||||||
|
value = syncInterval,
|
||||||
|
values = syncIntervals,
|
||||||
|
labels = syncIntervalLabels,
|
||||||
|
onValueChange = { newInterval ->
|
||||||
|
// Slider reports every drag delta, not just the snapped steps.
|
||||||
|
if (newInterval != syncInterval) {
|
||||||
|
syncInterval = newInterval
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putInt(keyAutoSyncOnOpen, newInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsPreference(
|
SettingsPreference(
|
||||||
@@ -464,6 +499,47 @@ fun SettingsColorPreference(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsSliderPreference(
|
||||||
|
title: String,
|
||||||
|
summary: String? = null,
|
||||||
|
icon: Any? = null,
|
||||||
|
value: Int,
|
||||||
|
values: List<Int>,
|
||||||
|
labels: List<String>,
|
||||||
|
onValueChange: (Int) -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp, 8.dp)
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
SettingsIcon(icon)
|
||||||
|
Spacer(modifier = Modifier.width(32.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.subtitle1)
|
||||||
|
if (summary != null) {
|
||||||
|
Text(text = summary, style = MaterialTheme.typography.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentIndex = values.indexOf(value).coerceAtLeast(0)
|
||||||
|
val steps = (values.size - 2).coerceAtLeast(0)
|
||||||
|
|
||||||
|
Column(modifier = Modifier.padding(start = 56.dp, top = 8.dp)) {
|
||||||
|
Slider(
|
||||||
|
value = currentIndex.toFloat(),
|
||||||
|
onValueChange = { onValueChange(values[it.roundToInt()]) },
|
||||||
|
valueRange = 0f..(values.size - 1).toFloat(),
|
||||||
|
steps = steps
|
||||||
|
)
|
||||||
|
Text(text = labels[currentIndex], style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsIcon(icon: Any?) {
|
fun SettingsIcon(icon: Any?) {
|
||||||
Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.database.Cursor
|
|||||||
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
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import net.helcel.cowspent.R
|
import net.helcel.cowspent.R
|
||||||
@@ -390,8 +391,8 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
|
|||||||
|
|
||||||
fun getActivatedMembersOfProject(projId: Long): List<DBMember> {
|
fun getActivatedMembersOfProject(projId: Long): List<DBMember> {
|
||||||
return getMembersCustom(
|
return getMembersCustom(
|
||||||
"$key_projectid = ? AND $key_activated = 1",
|
"$key_projectid = ? AND $key_activated = 1 AND $key_state != ?",
|
||||||
arrayOf(projId.toString()),
|
arrayOf(projId.toString(), DBBill.STATE_DELETED.toString()),
|
||||||
"$key_name ASC"
|
"$key_name ASC"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -570,70 +571,54 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
|
|||||||
fun searchBills(query: CharSequence?, projectId: Long): List<DBBill> {
|
fun searchBills(query: CharSequence?, projectId: Long): List<DBBill> {
|
||||||
val andWhere: MutableList<String> = ArrayList()
|
val andWhere: MutableList<String> = ArrayList()
|
||||||
val args: MutableList<String> = ArrayList()
|
val args: MutableList<String> = ArrayList()
|
||||||
andWhere.add("($key_projectid = $projectId)")
|
andWhere.add("($key_projectid = ?)")
|
||||||
|
args.add(projectId.toString())
|
||||||
andWhere.add("($key_state != ${DBBill.STATE_DELETED})")
|
andWhere.add("($key_state != ${DBBill.STATE_DELETED})")
|
||||||
if (query != null) {
|
|
||||||
args.add("%$query%")
|
val terms = query?.toString()?.split("\\s+".toRegex())?.filter { it.isNotEmpty() }.orEmpty()
|
||||||
var whereStr = "($key_what LIKE ?"
|
if (terms.isNotEmpty()) {
|
||||||
if (SupportUtil.isDouble(query.toString())) {
|
val memberIdsByName = getMembersOfProject(projectId, null)
|
||||||
whereStr += " OR ($key_amount <= (? + 10) AND $key_amount >= (? - 10))"
|
.associateBy({ it.name.lowercase(Locale.ROOT) }, { it.id })
|
||||||
args.add(query.toString())
|
// Every clause appends its arguments as it is built, so args stay in the same order
|
||||||
args.add(query.toString())
|
// as the placeholders they bind to.
|
||||||
|
for (term in terms) {
|
||||||
|
andWhere.add(memberClause(term, memberIdsByName, args) ?: textClause(term, args))
|
||||||
}
|
}
|
||||||
val members = getMembersOfProject(projectId, null)
|
|
||||||
val memberNames: MutableList<String> = ArrayList()
|
|
||||||
val memberIds: MutableList<Long> = ArrayList()
|
|
||||||
for (m in members) {
|
|
||||||
memberNames.add(m.name.lowercase(Locale.ROOT))
|
|
||||||
memberIds.add(m.id)
|
|
||||||
}
|
|
||||||
val queryStr = query.toString()
|
|
||||||
val words = queryStr.split("\\s+".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
|
||||||
var nameSql = ""
|
|
||||||
for (word in words) {
|
|
||||||
if (word.startsWith("+")) {
|
|
||||||
val nameQuery = word.replace("^\\+".toRegex(), "")
|
|
||||||
val memberIndex = memberNames.indexOf(nameQuery.lowercase(Locale.ROOT))
|
|
||||||
if (memberIndex != -1) {
|
|
||||||
val searchMemberId = memberIds[memberIndex]
|
|
||||||
nameSql += "($key_payer_id=?) AND "
|
|
||||||
args.add(searchMemberId.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (word.startsWith("-")) {
|
|
||||||
val nameQuery = word.replace("^-".toRegex(), "")
|
|
||||||
val memberIndex = memberNames.indexOf(nameQuery.lowercase(Locale.ROOT))
|
|
||||||
if (memberIndex != -1) {
|
|
||||||
val searchMemberId = memberIds[memberIndex]
|
|
||||||
val joinOwer = "select $table_bills.$key_id from $table_bills inner join $table_billowers " +
|
|
||||||
"where $key_member_id=? and $table_bills.$key_id=$table_billowers.$key_billId"
|
|
||||||
nameSql += "($key_id IN ($joinOwer)) AND "
|
|
||||||
args.add(searchMemberId.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (word.startsWith("@")) {
|
|
||||||
val nameQuery = word.replace("^@".toRegex(), "")
|
|
||||||
val memberIndex = memberNames.indexOf(nameQuery.lowercase(Locale.ROOT))
|
|
||||||
if (memberIndex != -1) {
|
|
||||||
val searchMemberId = memberIds[memberIndex]
|
|
||||||
nameSql += "( ($key_payer_id=?) OR "
|
|
||||||
args.add(searchMemberId.toString())
|
|
||||||
val joinOwer = "select $table_bills.$key_id from $table_bills inner join $table_billowers " +
|
|
||||||
"where $key_member_id=? and $table_bills.$key_id=$table_billowers.$key_billId"
|
|
||||||
nameSql += "($key_id IN ($joinOwer)) ) AND "
|
|
||||||
args.add(searchMemberId.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (nameSql != "") {
|
|
||||||
nameSql = nameSql.replace(" AND $".toRegex(), "")
|
|
||||||
whereStr += " OR ($nameSql)"
|
|
||||||
}
|
|
||||||
whereStr += ")"
|
|
||||||
andWhere.add(whereStr)
|
|
||||||
}
|
}
|
||||||
val order = "$key_timestamp DESC"
|
return getBillsCustom(TextUtils.join(" AND ", andWhere), args.toTypedArray(), "$key_timestamp DESC")
|
||||||
return getBillsCustom(TextUtils.join(" AND ", andWhere), args.toTypedArray(), order)
|
}
|
||||||
|
|
||||||
|
/** Clause for a `+name`/`-name`/`@name` term, or null when the term names no member. */
|
||||||
|
private fun memberClause(term: String, memberIdsByName: Map<String, Long>, args: MutableList<String>): String? {
|
||||||
|
val prefix = term.first()
|
||||||
|
if (prefix != '+' && prefix != '-' && prefix != '@') return null
|
||||||
|
val memberId = memberIdsByName[term.substring(1).lowercase(Locale.ROOT)] ?: return null
|
||||||
|
val owedByMember = "SELECT $table_bills.$key_id FROM $table_bills INNER JOIN $table_billowers " +
|
||||||
|
"WHERE $key_member_id = ? AND $table_bills.$key_id = $table_billowers.$key_billId"
|
||||||
|
args.add(memberId.toString())
|
||||||
|
return when (prefix) {
|
||||||
|
'+' -> "($key_payer_id = ?)"
|
||||||
|
'-' -> "($key_id IN ($owedByMember))"
|
||||||
|
else -> {
|
||||||
|
args.add(memberId.toString())
|
||||||
|
"(($key_payer_id = ?) OR ($key_id IN ($owedByMember)))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clause matching a free text term against the description, the comment and the amount. */
|
||||||
|
private fun textClause(term: String, args: MutableList<String>): String {
|
||||||
|
val needle = "%" + term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
||||||
|
args.add(needle)
|
||||||
|
args.add(needle)
|
||||||
|
var clause = "(($key_what LIKE ? ESCAPE '\\') OR ($key_comment LIKE ? ESCAPE '\\')"
|
||||||
|
if (SupportUtil.isDouble(term)) {
|
||||||
|
clause += " OR ($key_amount <= (? + $AMOUNT_SEARCH_TOLERANCE) AND " +
|
||||||
|
"$key_amount >= (? - $AMOUNT_SEARCH_TOLERANCE))"
|
||||||
|
args.add(term)
|
||||||
|
args.add(term)
|
||||||
|
}
|
||||||
|
return "$clause)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
@@ -1240,6 +1225,9 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
|
|||||||
)
|
)
|
||||||
private const val default_order = "$key_id DESC"
|
private const val default_order = "$key_id DESC"
|
||||||
|
|
||||||
|
/** Half width of the window a numeric search term matches, in project currency. */
|
||||||
|
private const val AMOUNT_SEARCH_TOLERANCE = 10
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var instance: CowspentSQLiteOpenHelper? = null
|
private var instance: CowspentSQLiteOpenHelper? = null
|
||||||
|
|
||||||
@@ -1249,5 +1237,15 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
|
|||||||
instance ?: CowspentSQLiteOpenHelper(context.applicationContext).also { instance = it }
|
instance ?: CowspentSQLiteOpenHelper(context.applicationContext).also { instance = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
fun setInstance(helper: CowspentSQLiteOpenHelper?) {
|
||||||
|
instance = helper
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
fun resetInstance() {
|
||||||
|
instance = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.content.SharedPreferences
|
|||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
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
|
||||||
@@ -90,14 +91,38 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun addCallbackPull(callback: ICallback) {
|
fun addCallbackPull(callback: ICallback) {
|
||||||
callbacksPull.add(callback)
|
// Callers register on every resume but the list is only drained when a task actually
|
||||||
|
// starts, so refuse duplicates rather than letting the same callback pile up.
|
||||||
|
if (!callbacksPull.contains(callback)) {
|
||||||
|
callbacksPull.add(callback)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun scheduleSync(onlyLocalChanges: Boolean, projId: Long, forceFullSync: Boolean = false): SyncTask? {
|
fun removeCallbackPull(callback: ICallback) {
|
||||||
|
callbacksPull.remove(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scheduleSync(onlyLocalChanges: Boolean, projId: Long, forceFullSync: Boolean = false): SyncTask? =
|
||||||
|
scheduleSync(onlyLocalChanges, projId, forceFullSync) { dbHelper.getProject(projId) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overload for callers that already hold the project. Resolving one by id costs a query plus
|
||||||
|
* a blocking DataStore read and an AEAD decrypt in getProjectFromCursor, which adds up when
|
||||||
|
* scheduling a sync for every project at app open.
|
||||||
|
*/
|
||||||
|
fun scheduleSync(onlyLocalChanges: Boolean, project: DBProject, forceFullSync: Boolean = false): SyncTask? =
|
||||||
|
scheduleSync(onlyLocalChanges, project.id, forceFullSync) { project }
|
||||||
|
|
||||||
|
private fun scheduleSync(
|
||||||
|
onlyLocalChanges: Boolean,
|
||||||
|
projId: Long,
|
||||||
|
forceFullSync: Boolean,
|
||||||
|
resolveProject: () -> DBProject?
|
||||||
|
): SyncTask? {
|
||||||
Log.d(TAG, "Sync requested (${if (onlyLocalChanges) "onlyLocalChanges" else "full"}; ${if (syncActive) "sync active" else "sync NOT active"}; forceFullSync=$forceFullSync) ...")
|
Log.d(TAG, "Sync requested (${if (onlyLocalChanges) "onlyLocalChanges" else "full"}; ${if (syncActive) "sync active" else "sync NOT active"}; forceFullSync=$forceFullSync) ...")
|
||||||
updateNetworkStatus()
|
updateNetworkStatus()
|
||||||
if (isSyncPossible && (!syncActive || onlyLocalChanges)) {
|
if (isSyncPossible && (!syncActive || onlyLocalChanges)) {
|
||||||
val project = dbHelper.getProject(projId)
|
val project = resolveProject()
|
||||||
if (project != null) {
|
if (project != null) {
|
||||||
Log.d(TAG, "... starting now")
|
Log.d(TAG, "... starting now")
|
||||||
val syncTask = SyncTask(onlyLocalChanges, project, forceFullSync)
|
val syncTask = SyncTask(onlyLocalChanges, project, forceFullSync)
|
||||||
@@ -1027,8 +1052,21 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
|
|||||||
}
|
}
|
||||||
var status = LoginStatus.OK
|
var status = LoginStatus.OK
|
||||||
try {
|
try {
|
||||||
|
// Pass current project values if the new ones are null to ensure a complete project object is sent to the server
|
||||||
|
val currentProj = project!!
|
||||||
|
val finalName = (newName ?: currentProj.name).let { if (it.isBlank() || it == "null") currentProj.remoteId else it }
|
||||||
|
// Stay null when the project has no main currency, so the PUT omits currencyName
|
||||||
|
// instead of silently setting the server-side currency.
|
||||||
|
val finalCurrency = (newMainCurrencyName ?: currentProj.currencyName)
|
||||||
|
?.takeUnless { it.isBlank() || it == "null" }
|
||||||
|
|
||||||
val response = client!!.editRemoteProject(
|
val response = client!!.editRemoteProject(
|
||||||
project!!, newName, newEmail, newPassword, newMainCurrencyName, newArchivedTs
|
currentProj,
|
||||||
|
finalName,
|
||||||
|
newEmail ?: currentProj.email,
|
||||||
|
newPassword,
|
||||||
|
finalCurrency,
|
||||||
|
newArchivedTs
|
||||||
)
|
)
|
||||||
if (BillsListViewActivity.DEBUG) {
|
if (BillsListViewActivity.DEBUG) {
|
||||||
Log.i(TAG, "RESPONSE edit remote project : ${response.stringContent}")
|
Log.i(TAG, "RESPONSE edit remote project : ${response.stringContent}")
|
||||||
@@ -1418,6 +1456,11 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun onPostExecute(status: LoginStatus) {
|
private fun onPostExecute(status: LoginStatus) {
|
||||||
|
if (status == LoginStatus.OK) {
|
||||||
|
preferences.edit {
|
||||||
|
putLong(appContext.getString(R.string.pref_key_last_account_sync_timestamp), System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
}
|
||||||
if (status != LoginStatus.OK) {
|
if (status != LoginStatus.OK) {
|
||||||
var errorString = appContext.getString(R.string.error_sync, appContext.getString(status.str)) + "\n\n"
|
var errorString = appContext.getString(R.string.error_sync, appContext.getString(status.str)) + "\n\n"
|
||||||
for (errorMessage in errorMessages) {
|
for (errorMessage in errorMessages) {
|
||||||
@@ -1673,6 +1716,12 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
|
|||||||
return instance!!
|
return instance!!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
fun resetInstance() {
|
||||||
|
instance = null
|
||||||
|
projectIdsToSync.clear()
|
||||||
|
}
|
||||||
|
|
||||||
fun isNextcloudAccountConfigured(context: Context): Boolean {
|
fun isNextcloudAccountConfigured(context: Context): Boolean {
|
||||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
return !preferences.getString(AccountActivity.SETTINGS_URL, AccountActivity.DEFAULT_SETTINGS).isNullOrEmpty() ||
|
return !preferences.getString(AccountActivity.SETTINGS_URL, AccountActivity.DEFAULT_SETTINGS).isNullOrEmpty() ||
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package net.helcel.cowspent.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import net.helcel.cowspent.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SyncOnOpen preference: how often opening the app refreshes the account and every project.
|
||||||
|
*
|
||||||
|
* The choices and the default live here rather than in the settings screen so that the screen and
|
||||||
|
* the sync trigger cannot disagree about what is in effect.
|
||||||
|
*/
|
||||||
|
object SyncSettings {
|
||||||
|
|
||||||
|
/** Steps offered by the slider, in minutes. */
|
||||||
|
val INTERVAL_CHOICES_MINUTES = listOf(1, 10, 60, 1440)
|
||||||
|
|
||||||
|
const val DEFAULT_INTERVAL_MINUTES = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The configured interval in minutes. A stored value that is not one of the offered steps —
|
||||||
|
* from a restored backup, or a build that changed the steps — falls back to the default
|
||||||
|
* rather than being displayed as the first step while a different value drives the sync.
|
||||||
|
*/
|
||||||
|
fun intervalMinutes(context: Context): Int {
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
val stored = prefs.getInt(
|
||||||
|
context.getString(R.string.pref_key_auto_sync_on_open),
|
||||||
|
DEFAULT_INTERVAL_MINUTES
|
||||||
|
)
|
||||||
|
return if (stored in INTERVAL_CHOICES_MINUTES) stored else DEFAULT_INTERVAL_MINUTES
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1528,5 +1528,8 @@ class VersatileProjectSyncClient(
|
|||||||
const val METHOD_POST = "POST"
|
const val METHOD_POST = "POST"
|
||||||
const val METHOD_PUT = "PUT"
|
const val METHOD_PUT = "PUT"
|
||||||
const val METHOD_DELETE = "DELETE"
|
const val METHOD_DELETE = "DELETE"
|
||||||
|
|
||||||
|
const val REMOTE_ARCHIVED_TS_NOW = 0L
|
||||||
|
const val REMOTE_ARCHIVED_TS_UNSET = -1L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,12 @@
|
|||||||
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
|
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
|
||||||
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</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="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_auto_sync_on_open">Sync interval</string>
|
||||||
|
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
|
||||||
|
<string name="pref_value_sync_1m">1 minute</string>
|
||||||
|
<string name="pref_value_sync_10m">10 minutes</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_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
|
||||||
<string name="settings_colorpicker_title">Choose Color</string>
|
<string name="settings_colorpicker_title">Choose Color</string>
|
||||||
|
|
||||||
@@ -163,6 +169,8 @@
|
|||||||
<string name="pref_key_offline_mode" translatable="false">offlineMode</string>
|
<string name="pref_key_offline_mode" translatable="false">offlineMode</string>
|
||||||
<string name="pref_key_show_archived" translatable="false">showArchived</string>
|
<string name="pref_key_show_archived" translatable="false">showArchived</string>
|
||||||
<string name="pref_key_beta_features" translatable="false">betaFeatures</string>
|
<string name="pref_key_beta_features" translatable="false">betaFeatures</string>
|
||||||
|
<string name="pref_key_auto_sync_on_open" translatable="false">autoSyncOnOpen</string>
|
||||||
|
<string name="pref_key_last_account_sync_timestamp" translatable="false">lastAccountSyncTimestamp</string>
|
||||||
<string name="pref_key_fill_new_bill_from_last" translatable="false">fillNewBillFromLast</string>
|
<string name="pref_key_fill_new_bill_from_last" translatable="false">fillNewBillFromLast</string>
|
||||||
<string name="pref_value_night_mode_no" translatable="false">1</string>
|
<string name="pref_value_night_mode_no" translatable="false">1</string>
|
||||||
<string name="pref_value_night_mode_yes" translatable="false">2</string>
|
<string name="pref_value_night_mode_yes" translatable="false">2</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user