Compare commits
10 commits
6032e9fd07
...
2c850dbd0b
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c850dbd0b | |||
| f498ecdef4 | |||
| c78cb8a77d | |||
| 5793af5772 | |||
| 16ed756e4f | |||
| ad602f48c3 | |||
| fe3800866f | |||
| 8a30aac16e | |||
| 311821b58e | |||
|
|
6b4ee77cba |
38
.github/workflows/build-apk.yml
vendored
Normal file
|
|
@ -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
|
||||
35
.github/workflows/generate-keystore.yml
vendored
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 658 B |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 658 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 414 B |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 414 B |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 992 B |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 992 B |
|
Before Width: | Height: | Size: 5 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 5 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 2.8 KiB |
0
gradlew
vendored
Normal file → Executable file
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 343 KiB |