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
20 changes: 20 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Normalise line endings in the repository, check out native on Windows.
* text=auto

# Scripts and wrappers that must keep LF whatever the platform.
gradlew text eol=lf
*.sh text eol=lf

# Windows batch files need CRLF.
*.bat text eol=crlf

# Binary assets, never diffed or line-ending converted.
*.jar binary
*.ttf binary
*.png binary
*.apk binary
*.aab binary
*.keystore binary

# Fonts are third party, keep them out of the language statistics.
Rannah/app/src/main/res/font/* linguist-vendored
18 changes: 18 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
version: 2
updates:
# The Gradle build, including the version catalog in gradle/libs.versions.toml.
- package-ecosystem: gradle
directory: /Rannah
schedule:
interval: monthly
open-pull-requests-limit: 5
commit-message:
prefix: build

- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
open-pull-requests-limit: 3
commit-message:
prefix: ci
45 changes: 45 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: CI

on:
push:
branches: [master]
pull_request:
workflow_dispatch:

permissions:
contents: read

defaults:
run:
working-directory: Rannah

jobs:
build:
name: Unit tests, lint and debug build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
cache: gradle

- name: Unit tests
run: ./gradlew testDebugUnitTest --no-daemon

- name: Android Lint
run: ./gradlew lintDebug --no-daemon

- name: Debug build
run: ./gradlew assembleDebug --no-daemon

- name: Upload reports on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: reports
path: |
Rannah/app/build/reports/
retention-days: 7
129 changes: 102 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,55 +1,130 @@
<div align="center">

<picture>
<source media="(prefers-color-scheme: dark)" srcset="Rannah/docs/assets/rannah-mark-dark.png">
<img src="Rannah/docs/assets/rannah-mark.png" alt="رَنّة" width="96">
<source media="(prefers-color-scheme: dark)" srcset="Rannah/docs/assets/header-dark.svg">
<img src="Rannah/docs/assets/header-light.svg" alt="Rannah: Arabic-first reminders for Android" width="880">
</picture>

# رَنّة

لكل موعد رَنّة
<p>
<a href="https://github.com/Mod578/Rannah/actions/workflows/ci.yml"><img src="https://github.com/Mod578/Rannah/actions/workflows/ci.yml/badge.svg" alt="CI status"></a>
<a href="https://github.com/Mod578/Rannah/releases/latest"><img src="https://img.shields.io/github/v/release/Mod578/Rannah?label=release" alt="Latest release"></a>
<img src="https://img.shields.io/badge/Android-8.0%2B-3DDC84?logo=android&logoColor=white" alt="Android 8.0 and above">
</p>

</div>

**رَنّة** is an Arabic reminders app for Android. It handles the three things a reminder actually needs: ring at the right moment, survive a reboot, and let you answer it once. One-time, daily and recurring reminders, snooze, completion confirmation, and Hijri dates next to Gregorian.

It works entirely offline. There is no account, no analytics, and the app declares no internet permission at all.

**Status:** released and in use. Version 1.1.0 is on the [releases page](https://github.com/Mod578/Rannah/releases/latest), with an APK, an app bundle and a `SHA256SUMS.txt` to check them against.

## Install

1. Download `rannah-1.1.0.apk` from the [latest release](https://github.com/Mod578/Rannah/releases/latest).
2. Open it and allow installation from this source when Android asks.
3. Grant the notification and exact alarm permissions on first launch, otherwise reminders cannot ring on time.

Requires Android 8.0 or newer.

## Features

- One-time, daily, weekly, monthly and yearly reminders
- Snooze by your default duration, or by a duration you pick for a single occurrence
- Skip today's occurrence without breaking the series
- Confirm completion with a deliberate slide, so a half-awake tap never records a task as done
- Pause and resume a recurring reminder
- Hijri dates alongside Gregorian, with a user adjustment offset
- Arabic natural language entry: «ذكرني كل يوم الساعة ٩ بالدواء» becomes a scheduled daily reminder
- Home screen widget showing the next reminder
- Fully Arabic, right to left throughout

## How it works

The database is the source of truth and every alarm is derivable from it. Nothing about a reminder lives only in `AlarmManager`, so a reboot, a process death, a clock change or an app update can rebuild the whole schedule without losing anything.

`ReminderScheduler` owns the lifecycle, and every action through it is idempotent, so a replayed broadcast or a double tap cannot give one occurrence two outcomes.

Full notes in [ARCHITECTURE.md](Rannah/docs/ARCHITECTURE.md).

## Build from source

Requires JDK 17 and the Android SDK at API level 35.

```bash
cd Rannah
./gradlew testDebugUnitTest # unit tests
./gradlew lintDebug # Android Lint
./gradlew assembleDebug # installable debug build
```

The APK lands in `app/build/outputs/apk/debug/`. Release builds read their signing credentials from a properties file outside the repository, and still assemble unsigned when that file is absent.

<details>
<summary><b>Project layout</b></summary>

```
Rannah/
├── app/src/main/java/com/bal/reminders/
│ ├── ui/ Compose screens and ViewModels
│ ├── domain/ models, recurrence, occurrence state
│ ├── data/ Room database, DAO, repository, settings
│ ├── scheduling/ lifecycle owner, alarms, notifications, receivers
│ ├── parser/ Arabic text to a schedule
│ ├── format/ Gregorian and Hijri formatting
│ └── widget/ home screen widget
├── app/src/test/ JVM unit tests
├── app/schemas/ exported Room schemas, used by the migration tests
└── docs/ architecture and privacy
```

</details>

## Tech stack

![Kotlin](https://img.shields.io/badge/Kotlin-7F52FF?style=flat-square&logo=kotlin&logoColor=white)
![Jetpack Compose](https://img.shields.io/badge/Compose-Material%203-4285F4?style=flat-square&logo=jetpackcompose&logoColor=white)
![Room](https://img.shields.io/badge/Room-3DDC84?style=flat-square&logo=android&logoColor=white)
![Hilt](https://img.shields.io/badge/Hilt-2C4F7C?style=flat-square&logo=android&logoColor=white)
![Gradle](https://img.shields.io/badge/Gradle-02303A?style=flat-square&logo=gradle&logoColor=white)

## Privacy

No account, no ads, no analytics, no tracking. Reminder data is stored locally on the device.

One honest caveat: Android's own backup may copy the app's data to your Google account, depending on your device settings. The [privacy statement](Rannah/docs/PRIVACY.md) says so plainly rather than claiming everything stays on the device.

<details>
<summary><b>بالعربية</b></summary>

<div dir="rtl">

رَنّة تطبيق عربي للتذكيرات يتيح إنشاء تذكير لمرة واحدة أو يومي أو متكرر، مع التأجيل وتأكيد الإنجاز وإدارة موعد اليوم بوضوح.
**رَنّة** تطبيق عربي للتذكيرات: لمرة واحدة، أو يومي، أو متكرر، مع التأجيل وتأكيد الإنجاز وإدارة موعد اليوم بوضوح.

## المزايا
**المزايا**

- تذكير لمرة واحدة أو يومي أو متكرر
- تأجيل بمدة افتراضية، أو بمدة تختارها لموعد واحد
- تخطي موعد اليوم مع استمرار التكرار
- تأكيد الإنجاز بالسحب
- إيقاف التذكير المتكرر واستئنافه
- عرض التاريخ الميلادي والهجري
- إدخال بالعربية الطبيعية: «ذكرني كل يوم الساعة ٩ بالدواء»
- واجهة عربية بالكامل
- حفظ البيانات محليًا على الجهاز

## التنزيل

[تنزيل أحدث إصدار](https://github.com/Mod578/Rannah/releases/latest)

ملف `APK` هو الملف المستخدم للتثبيت المباشر على أندرويد. قد يطلب النظام السماح بالتثبيت من هذا المصدر.
**التنزيل**

## الخصوصية
[أحدث إصدار](https://github.com/Mod578/Rannah/releases/latest). ملف `APK` للتثبيت المباشر على أندرويد 8.0 أو أحدث، وقد يطلب النظام السماح بالتثبيت من هذا المصدر.

- بلا حساب
- بلا إعلانات
- بلا تحليلات أو تتبع
- تُحفظ بيانات التذكيرات محليًا على الجهاز
- قد يشمل النسخ الاحتياطي في أندرويد بيانات التطبيق وفق إعدادات الجهاز
**الخصوصية**

[بيان الخصوصية](Rannah/docs/PRIVACY.md)
بلا حساب، وبلا إعلانات، وبلا تحليلات أو تتبع. تُحفظ بيانات التذكيرات محليًا، وقد يشمل النسخ الاحتياطي في أندرويد بيانات التطبيق وفق إعدادات الجهاز. التفاصيل في [بيان الخصوصية](Rannah/docs/PRIVACY.md).

## المتطلبات

أندرويد 8.0 أو أحدث.

## المطوّر
</div>

محمد المطيري
</details>

[LinkedIn](https://www.linkedin.com/in/mutiri) · [البريد الإلكتروني](mailto:mutirieng@gmail.com)
## Author

</div>
Mohammed Almutairi. [LinkedIn](https://www.linkedin.com/in/mutiri) · [mutirieng@gmail.com](mailto:mutirieng@gmail.com)
4 changes: 2 additions & 2 deletions Rannah/app/src/main/java/com/bal/reminders/BalApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ class BalApp : Application(), Configuration.Provider {
super.onCreate()
notificationPresenter.ensureChannels()
ReconcileWorker.ensureScheduled(this)
// Restore alarms whenever the process starts covers the case where a
// force-stop wiped them and the user just reopened the app and clean up
// Restore alarms whenever the process starts, covers the case where a
// force-stop wiped them and the user just reopened the app, and clean up
// one-time reminders that were completed on a previous day.
appScope.launch {
scheduler.pruneFinished()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ import kotlinx.coroutines.delay
* The full-screen alarm surface, shown over the lock screen while the ringer
* sounds. It offers exactly two answers: «تأجيل» postpones and closes; «تم»
* reveals a deliberate slide-to-confirm, and only completing that slide records
* the occurrence as done. Nothing here a background tap, the back gesture,
* leaving the screen ever counts as completion.
* the occurrence as done. Nothing here (a background tap, the back gesture,
* leaving the screen) ever counts as completion.
*/
@AndroidEntryPoint
class AlarmActivity : ComponentActivity() {
Expand Down Expand Up @@ -191,7 +191,7 @@ private fun AlarmScreen(viewModel: AlarmViewModel) {
}
}
// The label reads the setting, so it is always describing what the
// button will actually do including right after the user changed it.
// button will actually do, including right after the user changed it.
val snoozeLabel = context.resources.getQuantityString(
R.plurals.notification_snooze_minutes,
state.defaultSnoozeMinutes,
Expand All @@ -206,7 +206,7 @@ private fun AlarmScreen(viewModel: AlarmViewModel) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// While the ring sounds, the bell swings from its loop a calm
// While the ring sounds, the bell swings from its loop, a calm
// pendulum, the one place motion says "this is happening now".
val swing by rememberInfiniteTransition(label = "swing").animateFloat(
initialValue = -9f,
Expand Down Expand Up @@ -296,7 +296,7 @@ private fun AlarmScreen(viewModel: AlarmViewModel) {
)
}
Spacer(Modifier.height(8.dp))
// «مدة أخرى»: one quiet, ordinary, focusable button not a
// «مدة أخرى»: one quiet, ordinary, focusable button, not a
// long-press, which TalkBack and switch access cannot reach and
// nobody discovers. Everything it offers lives one layer down,
// so the ringing screen keeps its two large answers.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ class AlarmViewModel @Inject constructor(
confirming.value = false
}

/** «تأجيل»: the global default, applied now not a number frozen into the reminder. */
/** «تأجيل»: the global default, applied now, not a number frozen into the reminder. */
fun snooze() = resolveOnce { id, occurrence ->
scheduler.snooze(id, occurrence, SnoozeRequest.Default)
}
Expand Down
2 changes: 1 addition & 1 deletion Rannah/app/src/main/java/com/bal/reminders/data/Mappers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ private const val TYPE_HIJRI_MONTHLY_LEGACY = "hijri_monthly"

/**
* رَنّة dropped categories, priorities, alert modes, follow-up, per-reminder
* alarm options andsince 1.1 the per-reminder snooze length. The columns
* alarm options and, since 1.1, the per-reminder snooze length. The columns
* survive so old databases migrate losslessly (and a downgrade would still
* read); every new row writes these neutral defaults, and nothing in the app
* reads them back. Hijri schedule columns are also preserved so reminders saved
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ abstract class BalDatabase : RoomDatabase() {
* v4: drops `stopMarksCompleted`.
*
* The column let «إيقاف الصوت» record a completion. That made one verb
* mean two things silence a ringer, and assert that an obligation in
* the world was met and which one it meant depended on a switch buried
* mean two things: silence a ringer, and assert that an obligation in
* the world was met, and which one it meant depended on a switch buried
* in customization. رَنّة now has exactly one rule: stopping a sound
* stops a sound. Completion is always its own deliberate act.
*
Expand Down Expand Up @@ -183,13 +183,13 @@ abstract class BalDatabase : RoomDatabase() {
/**
* v6: one answer per occurrence, and one global snooze length.
*
* The table shape does not change this migration is entirely about
* The table shape does not change: this migration is entirely about
* data that older builds could produce and 1.1 no longer can:
*
* - An occurrence could hold **both** a `completed` and a `skipped`
* record. The unique index is per (reminder, occurrence, status), so
* SQLite always allowed the pair, and two surfaces racing could write
* it. «تم» is the stronger claimit asserts the task happened so a
* it. «تم» is the stronger claim: it asserts the task happened, so a
* contradicting `skipped` row is dropped and the completion stands.
* - A `missed` record alongside an answer said two things about one
* occurrence. The answer is the later and truer one; the `missed` row
Expand Down
12 changes: 6 additions & 6 deletions Rannah/app/src/main/java/com/bal/reminders/data/db/Entities.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,19 @@ data class ReminderEntity(
val priority: Int,
/** one of: once, daily, weekly, monthly, yearly */
val recurrenceType: String,
/** gregorian or hijri the calendar the once/monthly/yearly dates live in */
/** gregorian or hijri: the calendar the once/monthly/yearly dates live in */
@ColumnInfo(defaultValue = "gregorian") val calendar: String = "gregorian",
/** trigger time as minutes from midnight */
val timeMinutes: Int,
/** ISO local date Gregorian one-time only */
/** ISO local date: Gregorian one-time only */
val date: String?,
/** Hijri year Hijri one-time only */
/** Hijri year: Hijri one-time only */
val year: Int?,
/** month 1..12 yearly and Hijri one-time */
/** month 1..12: yearly and Hijri one-time */
val month: Int?,
/** bitmask, bit (isoDayOfWeek - 1) only for weekly */
/** bitmask, bit (isoDayOfWeek - 1), only for weekly */
val daysOfWeek: Int,
/** 1..31 monthly, yearly and Hijri one-time */
/** 1..31: monthly, yearly and Hijri one-time */
val dayOfMonth: Int?,
val enabled: Boolean,
/** standard or alarm */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ interface ReminderDao {
/**
* Records «تم» or «تخطي اليوم» for one occurrence, and refuses if that
* occurrence already has *either* answer. The unique index alone cannot
* express this it is per (reminder, occurrence, status), so a COMPLETED and
* a SKIPPED row for the same occurrence are both legal to SQLite and a
* express this: it is per (reminder, occurrence, status), so a COMPLETED and
* a SKIPPED row for the same occurrence are both legal to SQLite, and a
* plain unique index on (reminder, occurrence) would forbid the MISSED row
* that legitimately precedes a late answer. So the invariant lives here, in
* one transaction: read, decide, write.
Expand Down Expand Up @@ -175,7 +175,7 @@ interface ReminderDao {
/**
* Drops occurrence records that are past on both axes: recorded before
* [before] *and* for an occurrence before it. The second condition protects a
* reminder completed ahead of time its record is young but its occurrence
* reminder completed ahead of time: its record is young but its occurrence
* is still in the future, and it is the only thing marking that occurrence
* resolved.
*/
Expand Down
Loading