Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
41 changes: 41 additions & 0 deletions .github/workflows/android-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Android Tests

on:
push:
branches:
- master
pull_request:

permissions:
contents: read

jobs:
unit-tests:
name: Unit tests
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"

- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: "9.5.0"

- name: Run unit tests
working-directory: android_app
run: gradle test

- name: Upload test reports
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-reports
path: android_app/app/build/reports/tests/
128 changes: 128 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
name: Cut Release

# Server-side equivalent of scripts/release.sh --push: bump the version, fold the
# changelog, tag, build the signed APK, and publish the GitHub Release — all from
# the Actions "Run workflow" button (or an API dispatch), no local checkout needed.
#
# The tag is pushed with the workflow token, which deliberately does NOT trigger
# the tag-driven android-release.yml (GitHub suppresses workflow-to-workflow
# triggers); this workflow builds and publishes the release itself instead, so
# the two paths never double-release. Local scripts/release.sh --push keeps
# working unchanged via android-release.yml.

on:
workflow_dispatch:
inputs:
bump:
description: "Version part to bump"
required: true
type: choice
default: patch
options:
- patch
- minor
- major

permissions:
contents: write

concurrency:
group: release
cancel-in-progress: false

jobs:
release:
name: Bump, tag, build, publish
runs-on: ubuntu-latest

steps:
- name: Ensure the workflow runs from master
if: github.ref != 'refs/heads/master'
run: |
echo "Releases must be cut from master (got ${GITHUB_REF})." >&2
exit 1

- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # release.sh checks existing tags

- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"

- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: "9.5.0"

- name: Bump version, fold changelog, tag
id: bump
shell: bash
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
scripts/release.sh "${{ inputs.bump }}" --skip-build
tag="$(git describe --tags --exact-match HEAD)"
echo "tag=${tag}" >> "${GITHUB_OUTPUT}"
# The commit body carries the changelog bullets release.sh extracted.
git log -1 --format=%b > release-notes.md

- name: Restore release keystore
shell: bash
run: |
mkdir -p android_app/release
printf '%s' "${ANDROID_RELEASE_KEYSTORE_BASE64}" | base64 --decode > android_app/release/expense-notification-release.jks
{
printf 'RELEASE_STORE_FILE=release/expense-notification-release.jks\n'
printf 'RELEASE_STORE_PASSWORD=%s\n' "${ANDROID_RELEASE_STORE_PASSWORD}"
printf 'RELEASE_KEY_ALIAS=%s\n' "${ANDROID_RELEASE_KEY_ALIAS}"
printf 'RELEASE_KEY_PASSWORD=%s\n' "${ANDROID_RELEASE_KEY_PASSWORD}"
} > android_app/keystore.properties
env:
ANDROID_RELEASE_KEYSTORE_BASE64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_BASE64 }}
ANDROID_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_STORE_PASSWORD }}
ANDROID_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
ANDROID_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}

- name: Run unit tests
working-directory: android_app
run: gradle test

- name: Build signed release APK
working-directory: android_app
run: gradle assembleRelease

- name: Name release APK
id: apk
shell: bash
run: |
apk_name="ExpenseCapture-${{ steps.bump.outputs.tag }}.apk"
apk_path="android_app/app/build/outputs/apk/release/${apk_name}"
cp android_app/app/build/outputs/apk/release/app-release.apk "${apk_path}"
echo "name=${apk_name}" >> "${GITHUB_OUTPUT}"
echo "path=${apk_path}" >> "${GITHUB_OUTPUT}"

# Only push the version-bump commit and tag once the build has succeeded,
# so a failed build leaves master untouched and the run fully retryable.
- name: Push release commit and tag
shell: bash
run: |
git push origin HEAD:master
git push origin "${{ steps.bump.outputs.tag }}"

- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: ${{ steps.apk.outputs.name }}
path: ${{ steps.apk.outputs.path }}
if-no-files-found: error

- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.bump.outputs.tag }}
body_path: release-notes.md
files: ${{ steps.apk.outputs.path }}
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
# Changelog

## Unreleased
- Fix deleting a bundled parser/output config not actually disabling it: the parser kept a hardcoded copy of every bundled config and fell back to it even when the config was hidden. Bundled assets are now the single source of defaults, so Delete/Restore in the config UI really controls what parses.
- Fix grouped-thousands amounts without decimals: `€1.234` (and `1,234`) now parse as 1234, not 1.23.
- Fix a crash when tapping Fill or Open Expense Manager with the output app not installed; the candidate is no longer marked processed for a fill that never happened.
- Make the `dropZeroAmount` global config switch actually control the zero-amount filter (it was previously always on).
- Fix the config editor corrupting regex patterns that contain a literal backslash-u sequence when a config was opened and saved.
- Ask for confirmation before "Clear local queue" deletes captured notifications.
- Dedupe captures with a SHA-256 body hash instead of a 32-bit hash, removing the (tiny) chance of two different bank SMS colliding into one key and silently dropping an expense.
- Performance: compile every parser regex once per config load instead of on each notification; re-parse stored candidates only when the parser config actually changes (and write the result back) instead of on every list read; run notification parsing/database writes and the review-queue load off the main thread; share one database connection instead of opening one per event.
- Restrict the form-filling accessibility service to the configured output app via the system-side package filter (it previously woke up for events from every app), and expire an abandoned fill after 15 minutes.
- Prune processed/skipped candidates older than 90 days so the queue and database stop growing forever; unreviewed candidates are kept indefinitely.
- Skip a parser rule whose regex does not compile instead of crashing the notification listener.
- Run the unit-test suite in CI on every push and pull request.
- Add a "Cut Release" GitHub Actions workflow: releases can now be triggered from the Actions tab (choose patch/minor/major); it bumps the version, folds the changelog, tags, runs the tests, builds the signed APK, and publishes the GitHub Release — the server-side equivalent of `scripts/release.sh --push`.

## v1.0.1 - 2026-06-30
- Fix bank SMS after the first being silently dropped: messaging apps (e.g. Textra) post every SMS from one sender under a single conversation notification, so every Bank of Cyprus SMS shared one notification key and collided on the queue's unique-key constraint after the first capture. The dedupe key now folds in the message body, so each distinct SMS is queued while re-scanning the same still-active notification still dedupes. Card-app notifications (Revolut, Google Wallet) keep a unique key per transaction, so identical charges still queue separately.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,9 @@ abstract class BaseActivity extends Activity {
/** Wraps a vertical content column in a scroller with status/navigation-bar insets applied. */
protected ScrollView scrollRoot(LinearLayout root) {
Window window = getWindow();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
window.setStatusBarColor(COLOR_TEAL);
window.setNavigationBarColor(Color.WHITE);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
window.getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR);
}
window.setStatusBarColor(COLOR_TEAL);
window.setNavigationBarColor(Color.WHITE);
window.getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR);
ScrollView scroll = new ScrollView(this);
scroll.setFillViewport(true);
scroll.setBackgroundColor(COLOR_BG);
Expand Down Expand Up @@ -112,27 +108,25 @@ protected ScrollView scrollRoot(LinearLayout root) {
header.setPadding(dp(26), fallbackTop + dp(20), dp(26), dp(20));
root.setPadding(horizontalPadding, contentTopPadding, horizontalPadding, dp(16) + fallbackBottom);
scroll.setClipToPadding(false);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
scroll.setOnApplyWindowInsetsListener((view, insets) -> {
int top = fallbackTop;
int bottom = fallbackBottom;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Insets systemBars = insets.getInsets(WindowInsets.Type.systemBars());
top = Math.max(top, systemBars.top);
bottom = Math.max(bottom, systemBars.bottom);
} else {
top = Math.max(top, insets.getSystemWindowInsetTop());
bottom = Math.max(bottom, insets.getSystemWindowInsetBottom());
}
header.setPadding(dp(26), top + dp(20), dp(26), dp(20));
root.setPadding(
horizontalPadding,
contentTopPadding,
horizontalPadding,
dp(16) + bottom);
return insets;
});
}
scroll.setOnApplyWindowInsetsListener((view, insets) -> {
int top = fallbackTop;
int bottom = fallbackBottom;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Insets systemBars = insets.getInsets(WindowInsets.Type.systemBars());
top = Math.max(top, systemBars.top);
bottom = Math.max(bottom, systemBars.bottom);
} else {
top = Math.max(top, insets.getSystemWindowInsetTop());
bottom = Math.max(bottom, insets.getSystemWindowInsetBottom());
}
header.setPadding(dp(26), top + dp(20), dp(26), dp(20));
root.setPadding(
horizontalPadding,
contentTopPadding,
horizontalPadding,
dp(16) + bottom);
return insets;
});
outer.addView(header, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT));
Expand Down Expand Up @@ -192,10 +186,8 @@ protected Button button(String label) {
button.setMinHeight(dp(46));
button.setPadding(dp(18), 0, dp(18), 0);
button.setBackground(rounded(COLOR_TEAL, COLOR_TEAL, 5));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
button.setElevation(0f);
button.setStateListAnimator(null);
}
button.setElevation(0f);
button.setStateListAnimator(null);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT);
Expand Down Expand Up @@ -308,4 +300,32 @@ protected void requestIgnoreBatteryOptimizations() {
protected static String emptyDash(String text) {
return text == null || text.isEmpty() ? "-" : text;
}

protected boolean isNotificationListenerEnabled() {
String enabled = Settings.Secure.getString(getContentResolver(), "enabled_notification_listeners");
return containsComponent(enabled, ExpenseNotificationListener.class.getName());
}

protected boolean isAccessibilityServiceEnabled() {
String enabled = Settings.Secure.getString(getContentResolver(), "enabled_accessibility_services");
return "1".equals(Settings.Secure.getString(getContentResolver(), "accessibility_enabled")) &&
containsComponent(enabled, ExpenseEntryAccessibilityService.class.getName());
}

// The Settings.Secure lists are ':'-separated flattened components; match each
// entry exactly (long and short form) rather than by substring, so another
// package whose name merely contains ours can't count as enabled.
private boolean containsComponent(String enabled, String className) {
if (enabled == null || enabled.isEmpty()) {
return false;
}
String flattenedComponent = getPackageName() + "/" + className;
String shortComponent = getPackageName() + "/" + className.replace(getPackageName() + ".", ".");
for (String component : enabled.split(":")) {
if (component.equals(flattenedComponent) || component.equals(shortComponent)) {
return true;
}
}
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
import java.math.BigDecimal;

final class Candidate {
static final String STATUS_NEW = "NEW";
static final String STATUS_SKIPPED = "SKIPPED";
static final String STATUS_PROCESSED = "PROCESSED";
static final String TYPE_EXPENSE = "EXPENSE";
static final String TYPE_INCOME = "INCOME";

long id;
String notificationKey;
String packageName;
Expand All @@ -17,12 +23,12 @@ final class Candidate {
String suggestedCategory;
String suggestedPaymentMethod;
String note = "";
String transactionType = "EXPENSE";
String transactionType = TYPE_EXPENSE;
long postedAt;
String status;

boolean isIncome() {
return "INCOME".equals(transactionType);
return TYPE_INCOME.equals(transactionType);
}

boolean hasAmount() {
Expand Down
Loading
Loading