Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/crowdin.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Sync Crowdin

on:
push:
branches:
- master
paths:
- 'app/src/main/res/values/strings.xml'
- 'app/src/main/res/values-*/strings.xml'
- 'crowdin.yml'
workflow_dispatch:

concurrency:
group: crowdin-sync
cancel-in-progress: false

jobs:
upload:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
name: Upload sources and translations
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read

steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- name: Upload to Crowdin
uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
with:
upload_sources: true
upload_translations: true
import_eq_suggestions: true
auto_approve_imported: false
download_translations: false
env:
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
10 changes: 10 additions & 0 deletions PRIVACY_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ The Service Provider may use the information you provide to send important infor

For a better experience while using the Application, the Service Provider may require you to provide certain personally identifiable information, including but not limited to serranoie99@gmail.com, +526691659043\. The information the Service Provider requests will be retained and used as described in this privacy policy.

**Notification Access (optional)**

Minus offers an optional feature, off by default, that reminds you to log a charge after another app notifies you about one. If you turn it on, you grant Android's notification access permission and choose which apps Minus may look at.

* Minus reads the text of notifications only from the apps you select, and ignores every notification from every other app.
* All matching happens on your device. Raw notification text is never received, stored, or transmitted by the Service Provider, and it is never written to disk.
* If you choose Quick Add, Minus temporarily stores only the extracted amount on your device, so it can prefill the amount field, and clears it once that field has been filled. No other part of the notification is kept.
* The only thing Minus keeps is the amount you choose to save, as a transaction in your own local data. Quick Save also records the name of the app the charge came from; Quick Add does not.
* You can revoke notification access at any time in your device settings, or turn the feature off inside Minus, and no further notifications will be read.

**Third Party Access**

Only aggregated, anonymized data is periodically transmitted to external services to aid the Service Provider in improving the Application and their service. The Service Provider may share your information with third parties in the ways that are described in this privacy statement.
Expand Down
21 changes: 21 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.VIBRATE" />

<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>

<application
android:name=".MinusApplication"
android:allowBackup="true"
Expand Down Expand Up @@ -51,6 +58,20 @@
android:name=".presentation.notification.RecurrentNotificationActionReceiver"
android:exported="false" />

<receiver
android:name=".presentation.notification.scan.ExpenseScanActionReceiver"
android:exported="false" />

<service
android:name=".presentation.notification.scan.ExpenseNotificationListener"
android:exported="true"
android:label="@string/app_name"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>

<receiver
android:name=".presentation.notification.NotificationRescheduleReceiver"
android:enabled="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ interface BudgetRepository {

fun getTransactionsForPeriod(start: LocalDate, end: LocalDate): Flow<List<Transaction>>

suspend fun addTransaction(transaction: Transaction)
/** Inserts [transaction] and returns the id the row was given. */
suspend fun addTransaction(transaction: Transaction): Long

suspend fun addQueuedTransaction(transaction: Transaction)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,9 +234,8 @@ class BudgetRepositoryImpl @Inject constructor(
.map { entities -> entities.map { it.toDomain() } }
}

override suspend fun addTransaction(transaction: Transaction) {
override suspend fun addTransaction(transaction: Transaction): Long =
transactionDao.insert(transaction.toEntity())
}

override suspend fun addQueuedTransaction(transaction: Transaction) {
queuedTransactionDao.insert(transaction.toQueuedEntity())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ interface SettingsRepository {

suspend fun setNewCategoryTagEnabled(enabled: Boolean)

suspend fun setNotificationScanEnabled(enabled: Boolean)

suspend fun setNotificationScanPackages(packages: Set<String>)

/**
* Adds [packageName] to the scanned apps, or removes it if it is already there, reading and
* writing the stored set in one atomic step so concurrent toggles cannot undo each other.
*/
suspend fun toggleNotificationScanPackage(packageName: String)

suspend fun setTutorialBoxCompleted(completed: Boolean)

suspend fun setAnalyticsTutorialCompleted(completed: Boolean)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ const val CATEGORY_GRID_MODE_KEY_NAME = "category_grid_mode_enabled"
const val EXTRA_NOTE_ENABLED_KEY_NAME = "extra_note_enabled"
const val RESERVE_UPCOMING_CHARGES_KEY_NAME = "reserve_upcoming_charges_enabled"
const val NEW_CATEGORY_TAG_KEY_NAME = "new_category_tag_enabled"
const val NOTIFICATION_SCAN_ENABLED_KEY_NAME = "notification_scan_enabled"
const val NOTIFICATION_SCAN_PACKAGES_KEY_NAME = "notification_scan_packages"
const val EARLY_FINISH_ACTIVE_KEY_NAME = "early_finish_active"
const val PERIOD_END_ALREADY_HANDLED_KEY_NAME = "period_end_already_handled"
const val EARLY_FINISH_ACTUAL_DATE_KEY_NAME = "early_finish_actual_date_millis"
Expand Down Expand Up @@ -99,6 +101,9 @@ private val CATEGORY_PICKER_DIRECT_POPUP_ENABLED =
booleanPreferencesKey(CATEGORY_PICKER_DIRECT_POPUP_KEY_NAME)
private val CATEGORY_GRID_MODE_ENABLED =
booleanPreferencesKey(CATEGORY_GRID_MODE_KEY_NAME)
private val NOTIFICATION_SCAN_ENABLED = booleanPreferencesKey(NOTIFICATION_SCAN_ENABLED_KEY_NAME)
private val NOTIFICATION_SCAN_PACKAGES =
stringSetPreferencesKey(NOTIFICATION_SCAN_PACKAGES_KEY_NAME)
private val EXTRA_NOTE_ENABLED =
booleanPreferencesKey(EXTRA_NOTE_ENABLED_KEY_NAME)
private val RESERVE_UPCOMING_CHARGES_ENABLED =
Expand Down Expand Up @@ -176,6 +181,8 @@ class SettingsRepositoryImpl @Inject constructor(
categoryPickerDirectPopupEnabled = preferences[CATEGORY_PICKER_DIRECT_POPUP_ENABLED] ?: false,
categoryGridModeEnabled = preferences[CATEGORY_GRID_MODE_ENABLED] ?: false,
extraNoteEnabled = preferences[EXTRA_NOTE_ENABLED] ?: false,
notificationScanEnabled = preferences[NOTIFICATION_SCAN_ENABLED] ?: false,
notificationScanPackages = preferences[NOTIFICATION_SCAN_PACKAGES] ?: emptySet(),
reserveUpcomingChargesEnabled = preferences[RESERVE_UPCOMING_CHARGES_ENABLED] ?: false,
newCategoryTagEnabled = preferences[NEW_CATEGORY_TAG_ENABLED] ?: false,
tutorialBoxCompleted = preferences[TUTORIAL_BOX_COMPLETED] ?: false,
Expand Down Expand Up @@ -402,6 +409,29 @@ class SettingsRepositoryImpl @Inject constructor(
}
}

override suspend fun setNotificationScanEnabled(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[NOTIFICATION_SCAN_ENABLED] = enabled
}
}

override suspend fun setNotificationScanPackages(packages: Set<String>) {
dataStore.edit { preferences ->
preferences[NOTIFICATION_SCAN_PACKAGES] = packages
}
}

override suspend fun toggleNotificationScanPackage(packageName: String) {
dataStore.edit { preferences ->
val current = preferences[NOTIFICATION_SCAN_PACKAGES] ?: emptySet()
preferences[NOTIFICATION_SCAN_PACKAGES] = if (packageName in current) {
current - packageName
} else {
current + packageName
}
}
}

override suspend fun setTutorialBoxCompleted(completed: Boolean) {
dataStore.edit { preferences ->
preferences[TUTORIAL_BOX_COMPLETED] = completed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ data class UserSettings(
val extraNoteEnabled: Boolean = false,
val reserveUpcomingChargesEnabled: Boolean = false,
val newCategoryTagEnabled: Boolean = false,
val notificationScanEnabled: Boolean = false,
val notificationScanPackages: Set<String> = emptySet(),
val tutorialBoxCompleted: Boolean = false,
val firstLaunchTutorialStage: FirstLaunchTutorialStage = FirstLaunchTutorialStage.COMPLETED,
val analyticsTutorialCompleted: Boolean = false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package com.serranoie.app.minus.domain.notification

import com.serranoie.app.minus.domain.model.SupportedCurrency
import java.math.BigDecimal

const val PENDING_QUICK_ADD_AMOUNT_KEY_NAME = "pending_quick_add_amount"

object ExpenseNotificationParser {

private const val EVIDENCE_WINDOW = 8
private const val SIGN_WINDOW = 4

private val AMOUNT = Regex(
"""[+\-]?\d{1,3}(?:[.,\u00A0 ]\d{3})+(?:[.,]\d{1,2})?|[+\-]?\d+(?:[.,]\d{1,2})?"""
)

/**
* Symbols that more than one supported currency uses, such as "$" for USD, MXN, ARS, COP and
* CLP. Seeing one of these says nothing about which currency the amount is in, so they are not
* accepted as evidence on their own.
*/
private val SHARED_SYMBOLS: Set<String> by lazy {
SupportedCurrency.ALL
.groupingBy { it.symbol.trim().lowercase() }
.eachCount()
.filterValues { it > 1 }
.keys
}

fun parse(
text: String,
currencyCode: String,
denyWords: List<String>,
): BigDecimal? {
if (text.isBlank()) return null
val haystack = text.lowercase()

if (denyWords.any { it.isNotBlank() && haystack.contains(it.trim().lowercase()) }) return null

val evidence = evidenceRanges(haystack, currencyCode)
if (evidence.isEmpty()) return null

for (match in AMOUNT.findAll(haystack)) {
if (haystack.getOrNull(match.range.last + 1) == '%') continue
val leading = haystack.substring(
(match.range.first - SIGN_WINDOW).coerceAtLeast(0),
match.range.first,
)
if (match.value.startsWith("+") || leading.contains('+')) continue

val from = (match.range.first - EVIDENCE_WINDOW).coerceAtLeast(0)
val to = (match.range.last + EVIDENCE_WINDOW).coerceAtMost(haystack.length - 1)
if (evidence.none { it.first >= from && it.last <= to }) continue

val amount = normalize(match.value) ?: continue
if (amount.compareTo(BigDecimal.ZERO) == 0) continue
return amount
}
return null
}

/**
* Where in [haystack] the configured currency is spelled out. The ISO code always counts, but
* only as a whole code, so "usdx" or "busd" is not evidence of USD. The symbol counts only when
* a single supported currency uses it.
*/
private fun evidenceRanges(haystack: String, currencyCode: String): List<IntRange> {
val code = currencyCode.trim().lowercase()
val ranges = mutableListOf<IntRange>()
if (code.isNotEmpty()) ranges += occurrencesOf(haystack, code)

val symbol = SupportedCurrency.findByCode(currencyCode)?.symbol?.trim()?.lowercase()
if (!symbol.isNullOrEmpty() && symbol != code && symbol !in SHARED_SYMBOLS) {
ranges += occurrencesOf(haystack, symbol)
}
return ranges
}

/**
* Every occurrence of [token] in [haystack] that is not glued to a letter on either end, so an
* alphabetic token is matched as a whole word while digits around it are still allowed
* ("12.00usd" counts, "usdx" does not).
*/
private fun occurrencesOf(haystack: String, token: String): List<IntRange> {
if (token.isEmpty()) return emptyList()
val ranges = mutableListOf<IntRange>()
var index = haystack.indexOf(token)
while (index >= 0) {
val endExclusive = index + token.length
val startIsBounded = !token.first().isLetter() ||
haystack.getOrNull(index - 1)?.isLetter() != true
val endIsBounded = !token.last().isLetter() ||
haystack.getOrNull(endExclusive)?.isLetter() != true
if (startIsBounded && endIsBounded) ranges += index until endExclusive
index = haystack.indexOf(token, index + 1)
}
return ranges
}

private fun normalize(token: String): BigDecimal? {
val cleaned = token.trimStart('+', '-').filterNot { it.isWhitespace() || it == '\u00A0' }
val lastSeparator = cleaned.indexOfLast { it == '.' || it == ',' }
return runCatching {
if (lastSeparator < 0) {
BigDecimal(cleaned)
} else {
val fraction = cleaned.substring(lastSeparator + 1)
if (fraction.length in 1..2) {
val whole = cleaned.take(lastSeparator).filter { it.isDigit() }
BigDecimal("${whole.ifEmpty { "0" }}.$fraction")
} else {
BigDecimal(cleaned.filter { it.isDigit() })
}
}
}.getOrNull()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import javax.inject.Inject
class AddTransactionUseCase @Inject constructor(
private val budgetRepository: BudgetRepository
) {
suspend operator fun invoke(transaction: Transaction) {
/** Returns the id the inserted row was given. */
suspend operator fun invoke(transaction: Transaction): Long =
budgetRepository.addTransaction(transaction)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.preferredFrameRate
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.NamedNavArgument
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavGraphBuilder
Expand All @@ -40,6 +43,8 @@ import com.serranoie.app.minus.presentation.ui.settings.appearance.AppearanceOpt
import com.serranoie.app.minus.presentation.ui.settings.bugreport.BugReportScreen
import com.serranoie.app.minus.presentation.ui.settings.features.CsvSyncGuideScreen
import com.serranoie.app.minus.presentation.ui.settings.features.FeatureLabScreen
import com.serranoie.app.minus.presentation.ui.settings.features.NotificationScanScreen
import com.serranoie.app.minus.presentation.ui.settings.features.NotificationScanViewModel
import com.serranoie.app.minus.presentation.ui.subscriptions.SubscriptionsScreen
import logcat.logcat

Expand Down Expand Up @@ -231,6 +236,30 @@ fun AppNavGraph(
onNavigateToCsvSyncGuide = {
navController.navigate(Screen.CsvSyncGuide.route)
},
onNavigateToNotificationScan = {
navController.navigate(Screen.NotificationScan.route)
},
onBack = { navController.popBackStack() },
)
}

screen(Screen.NotificationScan.route, popDirection, animated) {
val viewModel: NotificationScanViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val lifecycleOwner = LocalLifecycleOwner.current

LaunchedEffect(lifecycleOwner) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.refreshAccess()
}
}

NotificationScanScreen(
state = uiState,
onEnabledToggle = viewModel::onEnabledToggle,
onAppToggle = viewModel::onAppToggle,
onQueryChange = viewModel::onQueryChange,
onOpenAccessSettings = viewModel::openAccessSettings,
onBack = { navController.popBackStack() },
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,6 @@ sealed class Screen(val route: String) {
data object FeatureLab : Screen("feature_lab")

data object CsvSyncGuide : Screen("csv_sync_guide")

data object NotificationScan : Screen("notification_scan")
}
Loading
Loading