diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml new file mode 100644 index 0000000..1bfc0fb --- /dev/null +++ b/.github/workflows/build-apk.yml @@ -0,0 +1,38 @@ +name: Build Release APK + +on: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Decode keystore + run: | + echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > $RUNNER_TEMP/helios-release.jks + + - name: Build release APK + env: + RELEASE_KEYSTORE_PATH: ${{ runner.temp }}/helios-release.jks + RELEASE_KEYSTORE_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }} + RELEASE_KEY_ALIAS: helios + RELEASE_KEY_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }} + run: ./gradlew assembleRelease + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: helios-alarm-clock-debug + path: app/build/outputs/apk/release/*.apk + retention-days: 7 diff --git a/.github/workflows/generate-keystore.yml b/.github/workflows/generate-keystore.yml new file mode 100644 index 0000000..7c71ef3 --- /dev/null +++ b/.github/workflows/generate-keystore.yml @@ -0,0 +1,35 @@ +name: Generate Keystore (run once) + +on: + workflow_dispatch: + +jobs: + generate: + runs-on: ubuntu-latest + steps: + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Generate keystore + run: | + keytool -genkeypair \ + -keystore helios-release.jks \ + -alias helios \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 \ + -storepass helios123 \ + -keypass helios123 \ + -dname "CN=Helios, OU=Helios, O=Helios, L=Berlin, ST=Berlin, C=DE" + echo "KEYSTORE_B64=$(base64 -w 0 helios-release.jks)" >> $GITHUB_OUTPUT + id: keygen + + - name: Upload keystore + uses: actions/upload-artifact@v4 + with: + name: helios-release-keystore + path: helios-release.jks + retention-days: 1 diff --git a/README.md b/README.md index 7bb2fcb..e701c3a 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,14 @@ Response: `201 Created` `hour` must be 0-23, `minute` must be 0-59. `label` is optional (defaults to empty). +To set an alarm for a specific future date, add an optional `date` field in `YYYY-MM-DD` format. Without `date`, the alarm fires at the next occurrence of the given time (today or tomorrow). + +```bash +curl -X POST http://PHONE_IP:8080/set \ + -H "Content-Type: application/json" \ + -d '{"hour": 8, "minute": 0, "label": "Vacation start", "date": "2026-10-08"}' +``` + ### Remove an alarm ```bash diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 47e155b..a56f93f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,10 +26,12 @@ android { signingConfigs { create("release") { - storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore") - storePassword = "android" - keyAlias = "androiddebugkey" - keyPassword = "android" + val keystorePath = System.getenv("RELEASE_KEYSTORE_PATH") + storeFile = if (keystorePath != null) file(keystorePath) + else file(System.getProperty("user.home") + "/.android/debug.keystore") + storePassword = System.getenv("RELEASE_KEYSTORE_PASS") ?: "android" + keyAlias = System.getenv("RELEASE_KEY_ALIAS") ?: "androiddebugkey" + keyPassword = System.getenv("RELEASE_KEY_PASS") ?: "android" } } buildTypes { diff --git a/app/src/main/java/com/example/helios_alarm_clock/data/AlarmDatabase.kt b/app/src/main/java/com/example/helios_alarm_clock/data/AlarmDatabase.kt index 85f911c..2ff82ae 100644 --- a/app/src/main/java/com/example/helios_alarm_clock/data/AlarmDatabase.kt +++ b/app/src/main/java/com/example/helios_alarm_clock/data/AlarmDatabase.kt @@ -2,12 +2,45 @@ package com.example.helios_alarm_clock.data import androidx.room.Database import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase -@Database(entities = [AlarmEntity::class], version = 1, exportSchema = true) +@Database(entities = [AlarmEntity::class], version = 3, exportSchema = true) abstract class AlarmDatabase : RoomDatabase() { abstract fun alarmDao(): AlarmDao companion object { const val NAME = "helios_alarms.db" + + // Fresh install path: add date column with correct default + val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE alarms ADD COLUMN date TEXT DEFAULT ''") + } + } + + // Fix path: first install had DEFAULT NULL instead of DEFAULT '' + // SQLite can't alter column defaults, so recreate the table + val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL(""" + CREATE TABLE alarms_new ( + id TEXT NOT NULL PRIMARY KEY, + hour INTEGER NOT NULL, + minute INTEGER NOT NULL, + label TEXT NOT NULL, + triggerTimeMillis INTEGER NOT NULL, + date TEXT DEFAULT '' + ) + """.trimIndent()) + db.execSQL(""" + INSERT INTO alarms_new (id, hour, minute, label, triggerTimeMillis, date) + SELECT id, hour, minute, label, triggerTimeMillis, COALESCE(date, '') + FROM alarms + """.trimIndent()) + db.execSQL("DROP TABLE alarms") + db.execSQL("ALTER TABLE alarms_new RENAME TO alarms") + } + } } } diff --git a/app/src/main/java/com/example/helios_alarm_clock/data/AlarmEntity.kt b/app/src/main/java/com/example/helios_alarm_clock/data/AlarmEntity.kt index 7adf2eb..b663c2b 100644 --- a/app/src/main/java/com/example/helios_alarm_clock/data/AlarmEntity.kt +++ b/app/src/main/java/com/example/helios_alarm_clock/data/AlarmEntity.kt @@ -1,5 +1,6 @@ package com.example.helios_alarm_clock.data +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import kotlinx.serialization.Serializable @@ -12,5 +13,7 @@ data class AlarmEntity( val hour: Int, val minute: Int, val label: String, - val triggerTimeMillis: Long + val triggerTimeMillis: Long, + @ColumnInfo(defaultValue = "") + val date: String? = null ) diff --git a/app/src/main/java/com/example/helios_alarm_clock/di/AppModule.kt b/app/src/main/java/com/example/helios_alarm_clock/di/AppModule.kt index a21afb6..14e33e2 100644 --- a/app/src/main/java/com/example/helios_alarm_clock/di/AppModule.kt +++ b/app/src/main/java/com/example/helios_alarm_clock/di/AppModule.kt @@ -22,7 +22,9 @@ object AppModule { context, AlarmDatabase::class.java, AlarmDatabase.NAME - ).build() + ) + .addMigrations(AlarmDatabase.MIGRATION_1_2, AlarmDatabase.MIGRATION_2_3) + .build() @Provides @Singleton diff --git a/app/src/main/java/com/example/helios_alarm_clock/service/AlarmRingService.kt b/app/src/main/java/com/example/helios_alarm_clock/service/AlarmRingService.kt index 8c4bce4..f56cb60 100644 --- a/app/src/main/java/com/example/helios_alarm_clock/service/AlarmRingService.kt +++ b/app/src/main/java/com/example/helios_alarm_clock/service/AlarmRingService.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import java.util.Calendar import javax.inject.Inject @AndroidEntryPoint @@ -55,13 +56,17 @@ class AlarmRingService : Service() { startSound() - // Delete the fired alarm from the database + // Save as last alarm and delete from the database if (alarmId.isNotEmpty()) { scope.launch { try { + val alarm = alarmDao.getById(alarmId) + if (alarm != null) { + saveLastAlarm(alarm.hour, alarm.minute, alarm.label) + } alarmDao.deleteById(alarmId) } catch (e: Exception) { - Log.e(TAG, "Failed to delete alarm $alarmId", e) + Log.e(TAG, "Failed to process alarm $alarmId", e) } } } @@ -142,6 +147,17 @@ class AlarmRingService : Service() { .build() } + private fun saveLastAlarm(hour: Int, minute: Int, label: String) { + val prefs = getSharedPreferences("last_alarm", Context.MODE_PRIVATE) + val now = Calendar.getInstance() + prefs.edit() + .putInt("hour", hour) + .putInt("minute", minute) + .putString("label", label) + .putLong("firedAt", now.timeInMillis) + .apply() + } + companion object { private const val TAG = "AlarmRingService" const val NOTIFICATION_ID = 2 diff --git a/app/src/main/java/com/example/helios_alarm_clock/service/KtorService.kt b/app/src/main/java/com/example/helios_alarm_clock/service/KtorService.kt index 5cebd75..28c830d 100644 --- a/app/src/main/java/com/example/helios_alarm_clock/service/KtorService.kt +++ b/app/src/main/java/com/example/helios_alarm_clock/service/KtorService.kt @@ -36,6 +36,9 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException import java.util.Calendar import java.util.UUID import javax.inject.Inject @@ -126,13 +129,40 @@ class KtorService : Service() { } val id = UUID.randomUUID().toString() + // Parse date outside apply block so return@post works at the correct scope + val parsedDate: LocalDate? = if (req.date != null) { + try { + LocalDate.parse(req.date, DateTimeFormatter.ISO_LOCAL_DATE) + } catch (e: DateTimeParseException) { + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("date must be in YYYY-MM-DD format (e.g. 2026-10-08)") + ) + return@post + } + } else null + val now = Calendar.getInstance() val trigger = Calendar.getInstance().apply { set(Calendar.HOUR_OF_DAY, req.hour) set(Calendar.MINUTE, req.minute) set(Calendar.SECOND, 0) set(Calendar.MILLISECOND, 0) - if (before(now)) add(Calendar.DAY_OF_YEAR, 1) + if (parsedDate != null) { + set(Calendar.YEAR, parsedDate.year) + set(Calendar.MONTH, parsedDate.monthValue - 1) + set(Calendar.DAY_OF_MONTH, parsedDate.dayOfMonth) + } else { + if (before(now)) add(Calendar.DAY_OF_YEAR, 1) + } + } + + if (parsedDate != null && trigger.before(now)) { + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("Alarm time is in the past") + ) + return@post } val entity = AlarmEntity( @@ -140,7 +170,8 @@ class KtorService : Service() { hour = req.hour, minute = req.minute, label = req.label, - triggerTimeMillis = trigger.timeInMillis + triggerTimeMillis = trigger.timeInMillis, + date = req.date ) alarmDao.insert(entity) @@ -181,6 +212,29 @@ class KtorService : Service() { val alarms = alarmDao.getAll() call.respond(alarms) } + + get("/last-alarm") { + val prefs = this@KtorService.getSharedPreferences( + "last_alarm", + Context.MODE_PRIVATE + ) + val firedAt = prefs.getLong("firedAt", -1) + if (firedAt == -1L) { + call.respond( + HttpStatusCode.NotFound, + ErrorResponse("No alarm has fired yet") + ) + } else { + call.respond( + LastAlarmResponse( + hour = prefs.getInt("hour", 0), + minute = prefs.getInt("minute", 0), + label = prefs.getString("label", "") ?: "", + firedAt = firedAt + ) + ) + } + } } }.also { it.start(wait = false) } } @@ -218,7 +272,7 @@ class KtorService : Service() { } @Serializable -data class SetAlarmRequest(val hour: Int, val minute: Int, val label: String = "") +data class SetAlarmRequest(val hour: Int, val minute: Int, val label: String = "", val date: String? = null) @Serializable data class RemoveAlarmRequest(val id: String) @@ -231,3 +285,6 @@ data class StatusResponse(val status: String) @Serializable data class ErrorResponse(val error: String) + +@Serializable +data class LastAlarmResponse(val hour: Int, val minute: Int, val label: String, val firedAt: Long) diff --git a/app/src/main/java/com/example/helios_alarm_clock/ui/MainActivity.kt b/app/src/main/java/com/example/helios_alarm_clock/ui/MainActivity.kt index 8abef33..b2d2104 100644 --- a/app/src/main/java/com/example/helios_alarm_clock/ui/MainActivity.kt +++ b/app/src/main/java/com/example/helios_alarm_clock/ui/MainActivity.kt @@ -55,7 +55,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.core.content.ContextCompat +import java.time.LocalDate +import java.time.format.DateTimeFormatter import java.util.Calendar +import java.util.Locale import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.helios_alarm_clock.data.AlarmEntity @@ -262,6 +265,17 @@ fun AlarmCard( fontWeight = FontWeight.Light, color = MaterialTheme.colorScheme.onSurface ) + if (!alarm.date.isNullOrBlank()) { + val formattedDate = runCatching { + val parsed = LocalDate.parse(alarm.date, DateTimeFormatter.ISO_LOCAL_DATE) + parsed.format(DateTimeFormatter.ofPattern("EEE, dd.MM.yyyy", Locale.GERMAN)) + }.getOrElse { alarm.date } + Text( + text = formattedDate, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } if (alarm.label.isNotBlank()) { Text( text = alarm.label, diff --git a/app/src/main/java/com/example/helios_alarm_clock/ui/MainViewModel.kt b/app/src/main/java/com/example/helios_alarm_clock/ui/MainViewModel.kt index cc870cc..09aab8f 100644 --- a/app/src/main/java/com/example/helios_alarm_clock/ui/MainViewModel.kt +++ b/app/src/main/java/com/example/helios_alarm_clock/ui/MainViewModel.kt @@ -30,7 +30,7 @@ class MainViewModel @Inject constructor( val port: Int = KtorService.PORT - fun createAlarm(hour: Int, minute: Int, label: String) { + fun createAlarm(hour: Int, minute: Int, label: String, date: String? = null) { viewModelScope.launch { val now = Calendar.getInstance() val trigger = Calendar.getInstance().apply { @@ -38,14 +38,22 @@ class MainViewModel @Inject constructor( set(Calendar.MINUTE, minute) set(Calendar.SECOND, 0) set(Calendar.MILLISECOND, 0) - if (before(now)) add(Calendar.DAY_OF_YEAR, 1) + if (date != null) { + val parsedDate = java.time.LocalDate.parse(date) + set(Calendar.YEAR, parsedDate.year) + set(Calendar.MONTH, parsedDate.monthValue - 1) + set(Calendar.DAY_OF_MONTH, parsedDate.dayOfMonth) + } else { + if (before(now)) add(Calendar.DAY_OF_YEAR, 1) + } } val entity = AlarmEntity( id = UUID.randomUUID().toString(), hour = hour, minute = minute, label = label, - triggerTimeMillis = trigger.timeInMillis + triggerTimeMillis = trigger.timeInMillis, + date = date ) alarmDao.insert(entity) alarmScheduler.schedule(entity) diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp index 8c12e6d..a646846 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp index 782c9a4..68bf370 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index 8c12e6d..a646846 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp index 023a85f..e0b2e91 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp index ebe4711..b0a0e0f 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index 023a85f..e0b2e91 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index a151099..6b223f3 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp index 28831bb..631ec87 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index a151099..6b223f3 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index 82c4f75..a45856e 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp index fd1f2e1..939adf5 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index 82c4f75..a45856e 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index 2fca466..46e9b81 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp index cc0cc3f..5268dd6 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index 2fca466..46e9b81 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/helios-alarm-app-icon.png b/helios-alarm-app-icon.png index 2582ae9..2047087 100644 Binary files a/helios-alarm-app-icon.png and b/helios-alarm-app-icon.png differ