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
5 changes: 5 additions & 0 deletions client-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ android {
testOptions {
unitTests.isIncludeAndroidResources = true
}

sourceSets {
getByName("androidTest").assets.srcDir("$projectDir/schemas")
}
}

kotlin {
Expand All @@ -86,6 +90,7 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.lifecycle.service)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.markdown.renderer.m3)

implementation(platform(libs.androidx.compose.bom))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "71604ee7ad683f4ea93090d15593f539",
"entities": [
{
"tableName": "tasks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `goal` TEXT NOT NULL, `schedule` TEXT NOT NULL, `contextSwitch` INTEGER NOT NULL, `isActive` INTEGER NOT NULL, `color` INTEGER, `executionAlarm` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "goal",
"columnName": "goal",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "schedule",
"columnName": "schedule",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contextSwitch",
"columnName": "contextSwitch",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER"
},
{
"fieldPath": "executionAlarm",
"columnName": "executionAlarm",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '71604ee7ad683f4ea93090d15593f539')"
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package eventually.test.client.persistence.tasks

import android.content.Context
import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import eventually.client.persistence.tasks.TaskEntityDatabase
import eventually.core.model.Task
import kotlinx.coroutines.runBlocking
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.time.Duration
import java.time.Instant

@RunWith(AndroidJUnit4::class)
class TaskEntityDatabaseMigrationSpec {
@get:Rule
val helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
TaskEntityDatabase::class.java
)

@Test
fun migrateFromVersion2To3() {
helper.createDatabase(DatabaseName, 2).use { db ->
db.execSQL(
"INSERT INTO tasks (id, name, description, goal, schedule, contextSwitch, isActive, color) " +
"VALUES (42, 'test-task', 'test-description', 'test-goal', '{\"type\":\"once\",\"instant\":42}', 300, 1, 7)"
)
}

helper.runMigrationsAndValidate(DatabaseName, 3, true, TaskEntityDatabase.MIGRATION_2_3).close()

val context = ApplicationProvider.getApplicationContext<Context>()
val database = Room
.databaseBuilder(context, TaskEntityDatabase::class.java, DatabaseName)
.addMigrations(TaskEntityDatabase.MIGRATION_1_2, TaskEntityDatabase.MIGRATION_2_3)
.build()

try {
val tasks = runBlocking { database.dao().getAll() }

assertThat(tasks.size, equalTo(1))
assertThat(tasks.single().id, equalTo(42))
assertThat(tasks.single().name, equalTo("test-task"))
assertThat(tasks.single().schedule, equalTo(Task.Schedule.Once(instant = Instant.ofEpochSecond(42))))
assertThat(tasks.single().contextSwitch, equalTo(Duration.ofMinutes(5)))
assertThat(tasks.single().isActive, equalTo(true))
assertThat(tasks.single().color, equalTo(7))
assertThat(tasks.single().executionAlarm, equalTo(false))
} finally {
database.close()
}
}

companion object {
private const val DatabaseName: String = "migration-test.db"
}
}
8 changes: 8 additions & 0 deletions client-android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
android:name="android.permission.SCHEDULE_EXACT_ALARM"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />

Expand Down Expand Up @@ -67,6 +68,13 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<activity
android:name=".activities.AlarmActivity"
android:excludeFromRecents="true"
android:exported="false"
android:launchMode="singleInstance"
android:theme="@style/AppTheme" />
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package eventually.client.activities

import android.app.NotificationManager
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import eventually.client.R
import eventually.client.activities.helpers.TaskManagement
import eventually.client.scheduling.NotificationManagerExtensions.deleteInstanceNotifications
import eventually.client.scheduling.NotificationManagerExtensions.isInstanceExecutionAlarmActive
import eventually.client.serialization.Extras.requireInstanceId
import eventually.client.serialization.Extras.requireInstant
import eventually.client.serialization.Extras.requireTask
import eventually.client.settings.Settings.defaultPreferences
import eventually.client.settings.Settings.getDateTimeFormat
import eventually.client.settings.Settings.getPostponeLength
import eventually.client.ui.screens.AlarmScreen
import eventually.client.ui.theme.EventuallyTheme
import eventually.core.model.Task
import kotlinx.coroutines.delay
import java.time.Duration
import java.time.Instant
import java.util.UUID

class AlarmActivity : ComponentActivity() {
private val preferences by lazy { defaultPreferences() }

private var alarm: Alarm? by mutableStateOf(null)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

showOverLockScreen()
enableEdgeToEdge()

alarm = intent.toAlarm()

if (alarm == null) {
finish()
return
}

setContent {
val dateTimeFormat = remember { preferences.getDateTimeFormat() }
val postponeLength = remember { preferences.getPostponeLength() }

EventuallyTheme {
alarm?.let { current ->
LaunchedEffect(current) {
do {
delay(RingingCheckInterval)
} while (isRinging(current))

finish()
}

AlarmScreen(
task = current.task,
execution = current.execution,
dateTimeFormat = dateTimeFormat,
postponeLength = postponeLength,
onPostpone = { postpone(current, postponeLength) },
onDismiss = { dismiss(current) }
)
}
}
}
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.toAlarm()?.let {
setIntent(intent)
alarm = it
}
}

private fun isRinging(alarm: Alarm): Boolean =
getSystemService(NotificationManager::class.java).isInstanceExecutionAlarmActive(alarm.task.id, alarm.instance)

private fun dismiss(alarm: Alarm) {
if (isFinishing) return

getSystemService(NotificationManager::class.java).deleteInstanceNotifications(alarm.task.id, alarm.instance)
TaskManagement.dismissTaskInstance(this, alarm.task.id, alarm.instance)
Toast.makeText(this, getString(R.string.toast_task_dismissed), Toast.LENGTH_SHORT).show()
finish()
}

private fun postpone(alarm: Alarm, by: Duration) {
if (isFinishing) return

getSystemService(NotificationManager::class.java).deleteInstanceNotifications(alarm.task.id, alarm.instance)
TaskManagement.postponeTaskInstance(this, alarm.task.id, alarm.instance, by)
Toast.makeText(this, getString(R.string.toast_task_postponed), Toast.LENGTH_SHORT).show()
finish()
}

private fun showOverLockScreen() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
}

window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}

data class Alarm(val task: Task, val instance: UUID, val execution: Instant)

companion object {
const val ExtraTask: String = "eventually.client.activities.AlarmActivity.extra_task"
const val ExtraInstance: String = "eventually.client.activities.AlarmActivity.extra_instance"
const val ExtraExecution: String = "eventually.client.activities.AlarmActivity.extra_execution"

fun Intent.toAlarm(): Alarm? =
runCatching {
Alarm(
task = requireTask(ExtraTask),
instance = requireInstanceId(ExtraInstance),
execution = requireInstant(ExtraExecution)
)
}.getOrNull()

private const val RingingCheckInterval: Long = 1000
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ class Converters {
schedule = task.schedule,
contextSwitch = task.contextSwitch,
isActive = task.isActive,
color = task.color
color = task.color,
executionAlarm = task.executionAlarm
)

fun entityToTask(entity: TaskEntity) = Task(
Expand All @@ -107,7 +108,8 @@ class Converters {
schedule = entity.schedule,
contextSwitch = entity.contextSwitch,
isActive = entity.isActive,
color = entity.color
color = entity.color,
executionAlarm = entity.executionAlarm
)

fun taskToString(task: Task): String =
Expand Down Expand Up @@ -222,6 +224,7 @@ class Converters {
json.addProperty("context_switch", task.contextSwitch.seconds)
json.addProperty("is_active", task.isActive)
json.addProperty("color", task.color)
json.addProperty("execution_alarm", task.executionAlarm)

return json
}
Expand All @@ -235,7 +238,8 @@ class Converters {
schedule = jsonToSchedule(task.get("schedule").asJsonObject),
contextSwitch = Duration.ofSeconds(task.get("context_switch").asLong),
isActive = task.get("is_active").asBoolean,
color = task.get("color")?.asInt
color = task.get("color")?.asInt,
executionAlarm = task.get("execution_alarm")?.asBoolean ?: false
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package eventually.client.persistence.tasks

import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import eventually.core.model.Task
Expand All @@ -15,5 +16,7 @@ data class TaskEntity(
val schedule: Task.Schedule,
val contextSwitch: Duration,
val isActive: Boolean,
val color: Int?
val color: Int?,
@ColumnInfo(defaultValue = "0")
val executionAlarm: Boolean = false
)
Loading
Loading