Compare commits

..

No commits in common. "2c850dbd0bac516898ceae1019fa7d4ac77d1dea" and "6032e9fd074c4153417b016f3e3b669ffbb5281a" have entirely different histories.

28 changed files with 15 additions and 231 deletions

View file

@ -1,38 +0,0 @@
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

View file

@ -1,35 +0,0 @@
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

View file

@ -31,14 +31,6 @@ Response: `201 Created`
`hour` must be 0-23, `minute` must be 0-59. `label` is optional (defaults to empty). `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 ### Remove an alarm
```bash ```bash

View file

@ -26,12 +26,10 @@ android {
signingConfigs { signingConfigs {
create("release") { create("release") {
val keystorePath = System.getenv("RELEASE_KEYSTORE_PATH") storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore")
storeFile = if (keystorePath != null) file(keystorePath) storePassword = "android"
else file(System.getProperty("user.home") + "/.android/debug.keystore") keyAlias = "androiddebugkey"
storePassword = System.getenv("RELEASE_KEYSTORE_PASS") ?: "android" keyPassword = "android"
keyAlias = System.getenv("RELEASE_KEY_ALIAS") ?: "androiddebugkey"
keyPassword = System.getenv("RELEASE_KEY_PASS") ?: "android"
} }
} }
buildTypes { buildTypes {

View file

@ -2,45 +2,12 @@ package com.example.helios_alarm_clock.data
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(entities = [AlarmEntity::class], version = 3, exportSchema = true) @Database(entities = [AlarmEntity::class], version = 1, exportSchema = true)
abstract class AlarmDatabase : RoomDatabase() { abstract class AlarmDatabase : RoomDatabase() {
abstract fun alarmDao(): AlarmDao abstract fun alarmDao(): AlarmDao
companion object { companion object {
const val NAME = "helios_alarms.db" 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")
}
}
} }
} }

View file

@ -1,6 +1,5 @@
package com.example.helios_alarm_clock.data package com.example.helios_alarm_clock.data
import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -13,7 +12,5 @@ data class AlarmEntity(
val hour: Int, val hour: Int,
val minute: Int, val minute: Int,
val label: String, val label: String,
val triggerTimeMillis: Long, val triggerTimeMillis: Long
@ColumnInfo(defaultValue = "")
val date: String? = null
) )

View file

@ -22,9 +22,7 @@ object AppModule {
context, context,
AlarmDatabase::class.java, AlarmDatabase::class.java,
AlarmDatabase.NAME AlarmDatabase.NAME
) ).build()
.addMigrations(AlarmDatabase.MIGRATION_1_2, AlarmDatabase.MIGRATION_2_3)
.build()
@Provides @Provides
@Singleton @Singleton

View file

@ -22,7 +22,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Calendar
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@ -56,17 +55,13 @@ class AlarmRingService : Service() {
startSound() startSound()
// Save as last alarm and delete from the database // Delete the fired alarm from the database
if (alarmId.isNotEmpty()) { if (alarmId.isNotEmpty()) {
scope.launch { scope.launch {
try { try {
val alarm = alarmDao.getById(alarmId)
if (alarm != null) {
saveLastAlarm(alarm.hour, alarm.minute, alarm.label)
}
alarmDao.deleteById(alarmId) alarmDao.deleteById(alarmId)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to process alarm $alarmId", e) Log.e(TAG, "Failed to delete alarm $alarmId", e)
} }
} }
} }
@ -147,17 +142,6 @@ class AlarmRingService : Service() {
.build() .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 { companion object {
private const val TAG = "AlarmRingService" private const val TAG = "AlarmRingService"
const val NOTIFICATION_ID = 2 const val NOTIFICATION_ID = 2

View file

@ -36,9 +36,6 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json 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.Calendar
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@ -129,49 +126,21 @@ class KtorService : Service() {
} }
val id = UUID.randomUUID().toString() 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 now = Calendar.getInstance()
val trigger = Calendar.getInstance().apply { val trigger = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, req.hour) set(Calendar.HOUR_OF_DAY, req.hour)
set(Calendar.MINUTE, req.minute) set(Calendar.MINUTE, req.minute)
set(Calendar.SECOND, 0) set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0) set(Calendar.MILLISECOND, 0)
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 (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( val entity = AlarmEntity(
id = id, id = id,
hour = req.hour, hour = req.hour,
minute = req.minute, minute = req.minute,
label = req.label, label = req.label,
triggerTimeMillis = trigger.timeInMillis, triggerTimeMillis = trigger.timeInMillis
date = req.date
) )
alarmDao.insert(entity) alarmDao.insert(entity)
@ -212,29 +181,6 @@ class KtorService : Service() {
val alarms = alarmDao.getAll() val alarms = alarmDao.getAll()
call.respond(alarms) 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) } }.also { it.start(wait = false) }
} }
@ -272,7 +218,7 @@ class KtorService : Service() {
} }
@Serializable @Serializable
data class SetAlarmRequest(val hour: Int, val minute: Int, val label: String = "", val date: String? = null) data class SetAlarmRequest(val hour: Int, val minute: Int, val label: String = "")
@Serializable @Serializable
data class RemoveAlarmRequest(val id: String) data class RemoveAlarmRequest(val id: String)
@ -285,6 +231,3 @@ data class StatusResponse(val status: String)
@Serializable @Serializable
data class ErrorResponse(val error: String) data class ErrorResponse(val error: String)
@Serializable
data class LastAlarmResponse(val hour: Int, val minute: Int, val label: String, val firedAt: Long)

View file

@ -55,10 +55,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Calendar import java.util.Calendar
import java.util.Locale
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.helios_alarm_clock.data.AlarmEntity import com.example.helios_alarm_clock.data.AlarmEntity
@ -265,17 +262,6 @@ fun AlarmCard(
fontWeight = FontWeight.Light, fontWeight = FontWeight.Light,
color = MaterialTheme.colorScheme.onSurface 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()) { if (alarm.label.isNotBlank()) {
Text( Text(
text = alarm.label, text = alarm.label,

View file

@ -30,7 +30,7 @@ class MainViewModel @Inject constructor(
val port: Int = KtorService.PORT val port: Int = KtorService.PORT
fun createAlarm(hour: Int, minute: Int, label: String, date: String? = null) { fun createAlarm(hour: Int, minute: Int, label: String) {
viewModelScope.launch { viewModelScope.launch {
val now = Calendar.getInstance() val now = Calendar.getInstance()
val trigger = Calendar.getInstance().apply { val trigger = Calendar.getInstance().apply {
@ -38,22 +38,14 @@ class MainViewModel @Inject constructor(
set(Calendar.MINUTE, minute) set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0) set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0) set(Calendar.MILLISECOND, 0)
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) if (before(now)) add(Calendar.DAY_OF_YEAR, 1)
} }
}
val entity = AlarmEntity( val entity = AlarmEntity(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
hour = hour, hour = hour,
minute = minute, minute = minute,
label = label, label = label,
triggerTimeMillis = trigger.timeInMillis, triggerTimeMillis = trigger.timeInMillis
date = date
) )
alarmDao.insert(entity) alarmDao.insert(entity)
alarmScheduler.schedule(entity) alarmScheduler.schedule(entity)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 658 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 658 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 992 B

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 992 B

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Before After
Before After

0
gradlew vendored Executable file → Normal file
View file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Before After
Before After