Skip to content
Merged
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
- 📱 **Kotlin Multiplatform**: Shared logic for Android and iOS.
- 💾 **Persistence**: Tasks are stored in a local SQLite database (via Room) and resumed after app restarts.
- 🔗 **Work Chaining**: Easily chain multiple tasks together with `then` operations.
- ⚙️ **Constraints**: Define requirements like `requiredNetwork` for your tasks.
- ⚙️ **Constraints**: Define requirements like `requiredNetwork` and `requireBatteryNotLow` for your tasks.
- 🛠️ **DSL-based API**: Clean and intuitive DSL for initialization and task definition.
- 📊 **Monitoring**: Observe task status using Kotlin Flows.

Expand Down
83 changes: 83 additions & 0 deletions lorraine/schemas/io.dot.lorraine.db.LorraineDB/3.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "3aa46bbfd2b3c83f0efd26e20dc287f2",
"entities": [
{
"tableName": "worker",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `queue_id` TEXT NOT NULL, `identifier` TEXT NOT NULL, `state` TEXT NOT NULL, `tags` TEXT NOT NULL, `worker_dependencies` TEXT NOT NULL, `input_data` TEXT, `output_data` TEXT, `constraints_require_network` INTEGER NOT NULL, `constraints_require_battery_not_low` INTEGER NOT NULL, PRIMARY KEY(`uuid`))",
"fields": [
{
"fieldPath": "uuid",
"columnName": "uuid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "queueId",
"columnName": "queue_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "identifier",
"columnName": "identifier",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "state",
"columnName": "state",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "workerDependencies",
"columnName": "worker_dependencies",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "inputData",
"columnName": "input_data",
"affinity": "TEXT"
},
{
"fieldPath": "outputData",
"columnName": "output_data",
"affinity": "TEXT"
},
{
"fieldPath": "constraints.requireNetwork",
"columnName": "constraints_require_network",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "constraints.requireBatteryNotLow",
"columnName": "constraints_require_battery_not_low",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"uuid"
]
}
}
],
"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, '3aa46bbfd2b3c83f0efd26e20dc287f2')"
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ internal fun LorraineConstraints.asWorkManagerConstraints(): Constraints {
NetworkType.CONNECTED
} else {
NetworkType.NOT_REQUIRED
}
},
requiresBatteryNotLow = requireBatteryNotLow
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import io.dot.lorraine.db.dao.WorkerDao
import io.dot.lorraine.db.entity.WorkerEntity

@Database(
version = 2,
version = 3,
entities = [
WorkerEntity::class
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,20 @@ import io.dot.lorraine.dsl.LorraineConstraints
internal data class ConstraintEntity(

@ColumnInfo(name = "require_network")
val requireNetwork: Boolean
val requireNetwork: Boolean,
@ColumnInfo(name = "require_battery_not_low")
val requireBatteryNotLow: Boolean

)

internal fun ConstraintEntity.toDomain() = LorraineConstraints(
requireNetwork = requireNetwork
requireNetwork = requireNetwork,
requireBatteryNotLow = requireBatteryNotLow
)

internal fun LorraineConstraints.toEntity() = ConstraintEntity(
requireNetwork = requireNetwork
)
requireNetwork = requireNetwork,
requireBatteryNotLow = requireBatteryNotLow
)


Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
package io.dot.lorraine.dsl

data class LorraineConstraints internal constructor(
val requireNetwork: Boolean
val requireNetwork: Boolean,
val requireBatteryNotLow: Boolean,
) {

companion object {
val NONE = LorraineConstraints(
requireNetwork = false
requireNetwork = false,
requireBatteryNotLow = false
)
}
}

class LorraineConstraintsDefinition internal constructor() {
var requiredNetwork: Boolean = false
var requiredBatteryNotLow: Boolean = false


fun build() = LorraineConstraints(
requireNetwork = requiredNetwork
requireNetwork = requiredNetwork,
requireBatteryNotLow = requiredBatteryNotLow
)

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import io.dot.lorraine.logger.Logger
class LorraineLogger private constructor(
private val enable: Boolean,
private val logger: Logger
) {
) : Logger {

companion object {

Expand All @@ -19,4 +19,12 @@ class LorraineLogger private constructor(

}

override fun info(message: String) {
if (enable) logger.info(message)
}

override fun error(message: String) {
if (enable) logger.error(message)
}

}
9 changes: 9 additions & 0 deletions lorraine/src/iosMain/kotlin/io/dot/lorraine/Platform.ios.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

package io.dot.lorraine

import io.dot.lorraine.constraint.BatteryNotLowCheck
import io.dot.lorraine.constraint.ConnectivityCheck
import io.dot.lorraine.constraint.ConstraintCheck
import io.dot.lorraine.constraint.match
Expand All @@ -14,6 +15,7 @@ import io.dot.lorraine.dsl.LorraineRequest
import io.dot.lorraine.models.ExistingLorrainePolicy
import io.dot.lorraine.models.LorraineApplication
import io.dot.lorraine.models.LorraineInfo
import io.dot.lorraine.logger.DefaultLogger
import io.dot.lorraine.work.LorraineWorker
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
Expand All @@ -34,10 +36,17 @@ internal class IOSPlatform(
private val queues: MutableMap<String, NSOperationQueue> = mutableMapOf()
private val scope = application.scope

private val logger = application.logger ?: DefaultLogger

val constraints = listOf<ConstraintCheck>(
ConnectivityCheck(
scope = scope,
onChange = ::constraintChanged
),
BatteryNotLowCheck(
scope = scope,
onChange = ::constraintChanged,
logger = logger
)
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package io.dot.lorraine.constraint

import io.dot.lorraine.dsl.LorraineConstraints
import io.dot.lorraine.logger.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import okio.Closeable
import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSOperationQueue
import platform.UIKit.UIDevice
import platform.UIKit.UIDeviceBatteryLevelDidChangeNotification
import platform.UIKit.UIDeviceBatteryState
import platform.UIKit.UIDeviceBatteryStateDidChangeNotification

internal class BatteryNotLowCheck(
scope: CoroutineScope,
onChange: () -> Unit,
logger: Logger
) : ConstraintCheck {

private val observer = AppleBatteryObserver()

private val _value = MutableStateFlow(false)

init {
observer.setListener(
object : BatteryObserver.Listener {
override fun batteryChanged(isNotLow: Boolean) {
_value.update { isNotLow }
}
}
)

scope.launch {
_value.onEach { logger.info("BatteryNotLowCheck: $it") }.collect { onChange() }
}
}

override suspend fun match(constraints: LorraineConstraints): Boolean {
if (!constraints.requireBatteryNotLow)
return true

return _value.value
}

}

internal class AppleBatteryObserver : BatteryObserver, Closeable {
private var listener: BatteryObserver.Listener? = null
private var levelObserver: Any? = null
private var stateObserver: Any? = null

override fun setListener(listener: BatteryObserver.Listener) {
this.listener = listener
UIDevice.currentDevice.batteryMonitoringEnabled = true

val center = NSNotificationCenter.defaultCenter

levelObserver = center.addObserverForName(
UIDeviceBatteryLevelDidChangeNotification,
null,
NSOperationQueue.mainQueue
) { _ -> notifyListener() }

stateObserver = center.addObserverForName(
UIDeviceBatteryStateDidChangeNotification,
null,
NSOperationQueue.mainQueue
) { _ -> notifyListener() }

// Notify initial state
notifyListener()
}

private fun notifyListener() {
listener?.batteryChanged(isBatteryNotLow())
}

private fun isBatteryNotLow(): Boolean {
val device = UIDevice.currentDevice
val batteryLevel = device.batteryLevel
val batteryState = device.batteryState

// -1 means unknown (simulator), treat as not low
if (batteryLevel < 0) return true

// Battery is NOT low if:
// - level is above 15%, OR
// - device is charging, OR
// - device is full
return batteryLevel > 0.15f ||
batteryState == UIDeviceBatteryState.UIDeviceBatteryStateCharging ||
Comment thread
dombroks marked this conversation as resolved.
batteryState == UIDeviceBatteryState.UIDeviceBatteryStateFull
}

override fun close() {
levelObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) }
stateObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) }
UIDevice.currentDevice.batteryMonitoringEnabled = false
}
}

internal interface BatteryObserver : Closeable {
/**
* Sets the listener
*
* Implementation must call [listener] shortly after [setListener] returns to let the callers know about the initial state.
*/
fun setListener(listener: Listener)

interface Listener {
fun batteryChanged(isNotLow: Boolean)
}
}