Skip to content

Commit 4a89d26

Browse files
authored
Battery not low constraint (#30)
1 parent beee062 commit 4a89d26

9 files changed

Lines changed: 242 additions & 11 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
- 📱 **Kotlin Multiplatform**: Shared logic for Android and iOS.
1010
- 💾 **Persistence**: Tasks are stored in a local SQLite database (via Room) and resumed after app restarts.
1111
- 🔗 **Work Chaining**: Easily chain multiple tasks together with `then` operations.
12-
- ⚙️ **Constraints**: Define requirements like `requiredNetwork` for your tasks.
12+
- ⚙️ **Constraints**: Define requirements like `requiredNetwork` and `requireBatteryNotLow` for your tasks.
1313
- 🛠️ **DSL-based API**: Clean and intuitive DSL for initialization and task definition.
1414
- 📊 **Monitoring**: Observe task status using Kotlin Flows.
1515

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
{
2+
"formatVersion": 1,
3+
"database": {
4+
"version": 3,
5+
"identityHash": "3aa46bbfd2b3c83f0efd26e20dc287f2",
6+
"entities": [
7+
{
8+
"tableName": "worker",
9+
"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`))",
10+
"fields": [
11+
{
12+
"fieldPath": "uuid",
13+
"columnName": "uuid",
14+
"affinity": "TEXT",
15+
"notNull": true
16+
},
17+
{
18+
"fieldPath": "queueId",
19+
"columnName": "queue_id",
20+
"affinity": "TEXT",
21+
"notNull": true
22+
},
23+
{
24+
"fieldPath": "identifier",
25+
"columnName": "identifier",
26+
"affinity": "TEXT",
27+
"notNull": true
28+
},
29+
{
30+
"fieldPath": "state",
31+
"columnName": "state",
32+
"affinity": "TEXT",
33+
"notNull": true
34+
},
35+
{
36+
"fieldPath": "tags",
37+
"columnName": "tags",
38+
"affinity": "TEXT",
39+
"notNull": true
40+
},
41+
{
42+
"fieldPath": "workerDependencies",
43+
"columnName": "worker_dependencies",
44+
"affinity": "TEXT",
45+
"notNull": true
46+
},
47+
{
48+
"fieldPath": "inputData",
49+
"columnName": "input_data",
50+
"affinity": "TEXT"
51+
},
52+
{
53+
"fieldPath": "outputData",
54+
"columnName": "output_data",
55+
"affinity": "TEXT"
56+
},
57+
{
58+
"fieldPath": "constraints.requireNetwork",
59+
"columnName": "constraints_require_network",
60+
"affinity": "INTEGER",
61+
"notNull": true
62+
},
63+
{
64+
"fieldPath": "constraints.requireBatteryNotLow",
65+
"columnName": "constraints_require_battery_not_low",
66+
"affinity": "INTEGER",
67+
"notNull": true
68+
}
69+
],
70+
"primaryKey": {
71+
"autoGenerate": false,
72+
"columnNames": [
73+
"uuid"
74+
]
75+
}
76+
}
77+
],
78+
"setupQueries": [
79+
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
80+
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3aa46bbfd2b3c83f0efd26e20dc287f2')"
81+
]
82+
}
83+
}

lorraine/src/androidMain/kotlin/io/dot/lorraine/mapping/Constraints.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ internal fun LorraineConstraints.asWorkManagerConstraints(): Constraints {
1010
NetworkType.CONNECTED
1111
} else {
1212
NetworkType.NOT_REQUIRED
13-
}
13+
},
14+
requiresBatteryNotLow = requireBatteryNotLow
1415
)
1516
}

lorraine/src/commonMain/kotlin/io/dot/lorraine/db/LorraineDB.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import io.dot.lorraine.db.dao.WorkerDao
1111
import io.dot.lorraine.db.entity.WorkerEntity
1212

1313
@Database(
14-
version = 2,
14+
version = 3,
1515
entities = [
1616
WorkerEntity::class
1717
]

lorraine/src/commonMain/kotlin/io/dot/lorraine/db/entity/ConstraintEntity.kt

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,20 @@ import io.dot.lorraine.dsl.LorraineConstraints
66
internal data class ConstraintEntity(
77

88
@ColumnInfo(name = "require_network")
9-
val requireNetwork: Boolean
9+
val requireNetwork: Boolean,
10+
@ColumnInfo(name = "require_battery_not_low")
11+
val requireBatteryNotLow: Boolean
1012

1113
)
1214

1315
internal fun ConstraintEntity.toDomain() = LorraineConstraints(
14-
requireNetwork = requireNetwork
16+
requireNetwork = requireNetwork,
17+
requireBatteryNotLow = requireBatteryNotLow
1518
)
1619

1720
internal fun LorraineConstraints.toEntity() = ConstraintEntity(
18-
requireNetwork = requireNetwork
19-
)
21+
requireNetwork = requireNetwork,
22+
requireBatteryNotLow = requireBatteryNotLow
23+
)
24+
25+
Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,26 @@
11
package io.dot.lorraine.dsl
22

33
data class LorraineConstraints internal constructor(
4-
val requireNetwork: Boolean
4+
val requireNetwork: Boolean,
5+
val requireBatteryNotLow: Boolean,
56
) {
67

78
companion object {
89
val NONE = LorraineConstraints(
9-
requireNetwork = false
10+
requireNetwork = false,
11+
requireBatteryNotLow = false
1012
)
1113
}
1214
}
1315

1416
class LorraineConstraintsDefinition internal constructor() {
1517
var requiredNetwork: Boolean = false
18+
var requiredBatteryNotLow: Boolean = false
19+
1620

1721
fun build() = LorraineConstraints(
18-
requireNetwork = requiredNetwork
22+
requireNetwork = requiredNetwork,
23+
requireBatteryNotLow = requiredBatteryNotLow
1924
)
2025

2126
}

lorraine/src/commonMain/kotlin/io/dot/lorraine/models/LorraineLogger.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import io.dot.lorraine.logger.Logger
55
class LorraineLogger private constructor(
66
private val enable: Boolean,
77
private val logger: Logger
8-
) {
8+
) : Logger {
99

1010
companion object {
1111

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

2020
}
2121

22+
override fun info(message: String) {
23+
if (enable) logger.info(message)
24+
}
25+
26+
override fun error(message: String) {
27+
if (enable) logger.error(message)
28+
}
29+
2230
}

lorraine/src/iosMain/kotlin/io/dot/lorraine/Platform.ios.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
package io.dot.lorraine
44

5+
import io.dot.lorraine.constraint.BatteryNotLowCheck
56
import io.dot.lorraine.constraint.ConnectivityCheck
67
import io.dot.lorraine.constraint.ConstraintCheck
78
import io.dot.lorraine.constraint.match
@@ -14,6 +15,7 @@ import io.dot.lorraine.dsl.LorraineRequest
1415
import io.dot.lorraine.models.ExistingLorrainePolicy
1516
import io.dot.lorraine.models.LorraineApplication
1617
import io.dot.lorraine.models.LorraineInfo
18+
import io.dot.lorraine.logger.DefaultLogger
1719
import io.dot.lorraine.work.LorraineWorker
1820
import kotlinx.coroutines.flow.Flow
1921
import kotlinx.coroutines.flow.map
@@ -34,10 +36,17 @@ internal class IOSPlatform(
3436
private val queues: MutableMap<String, NSOperationQueue> = mutableMapOf()
3537
private val scope = application.scope
3638

39+
private val logger = application.logger ?: DefaultLogger
40+
3741
val constraints = listOf<ConstraintCheck>(
3842
ConnectivityCheck(
3943
scope = scope,
4044
onChange = ::constraintChanged
45+
),
46+
BatteryNotLowCheck(
47+
scope = scope,
48+
onChange = ::constraintChanged,
49+
logger = logger
4150
)
4251
)
4352

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package io.dot.lorraine.constraint
2+
3+
import io.dot.lorraine.dsl.LorraineConstraints
4+
import io.dot.lorraine.logger.Logger
5+
import kotlinx.coroutines.CoroutineScope
6+
import kotlinx.coroutines.flow.MutableStateFlow
7+
import kotlinx.coroutines.flow.onEach
8+
import kotlinx.coroutines.flow.update
9+
import kotlinx.coroutines.launch
10+
import okio.Closeable
11+
import platform.Foundation.NSNotificationCenter
12+
import platform.Foundation.NSOperationQueue
13+
import platform.UIKit.UIDevice
14+
import platform.UIKit.UIDeviceBatteryLevelDidChangeNotification
15+
import platform.UIKit.UIDeviceBatteryState
16+
import platform.UIKit.UIDeviceBatteryStateDidChangeNotification
17+
18+
internal class BatteryNotLowCheck(
19+
scope: CoroutineScope,
20+
onChange: () -> Unit,
21+
logger: Logger
22+
) : ConstraintCheck {
23+
24+
private val observer = AppleBatteryObserver()
25+
26+
private val _value = MutableStateFlow(false)
27+
28+
init {
29+
observer.setListener(
30+
object : BatteryObserver.Listener {
31+
override fun batteryChanged(isNotLow: Boolean) {
32+
_value.update { isNotLow }
33+
}
34+
}
35+
)
36+
37+
scope.launch {
38+
_value.onEach { logger.info("BatteryNotLowCheck: $it") }.collect { onChange() }
39+
}
40+
}
41+
42+
override suspend fun match(constraints: LorraineConstraints): Boolean {
43+
if (!constraints.requireBatteryNotLow)
44+
return true
45+
46+
return _value.value
47+
}
48+
49+
}
50+
51+
internal class AppleBatteryObserver : BatteryObserver, Closeable {
52+
private var listener: BatteryObserver.Listener? = null
53+
private var levelObserver: Any? = null
54+
private var stateObserver: Any? = null
55+
56+
override fun setListener(listener: BatteryObserver.Listener) {
57+
this.listener = listener
58+
UIDevice.currentDevice.batteryMonitoringEnabled = true
59+
60+
val center = NSNotificationCenter.defaultCenter
61+
62+
levelObserver = center.addObserverForName(
63+
UIDeviceBatteryLevelDidChangeNotification,
64+
null,
65+
NSOperationQueue.mainQueue
66+
) { _ -> notifyListener() }
67+
68+
stateObserver = center.addObserverForName(
69+
UIDeviceBatteryStateDidChangeNotification,
70+
null,
71+
NSOperationQueue.mainQueue
72+
) { _ -> notifyListener() }
73+
74+
// Notify initial state
75+
notifyListener()
76+
}
77+
78+
private fun notifyListener() {
79+
listener?.batteryChanged(isBatteryNotLow())
80+
}
81+
82+
private fun isBatteryNotLow(): Boolean {
83+
val device = UIDevice.currentDevice
84+
val batteryLevel = device.batteryLevel
85+
val batteryState = device.batteryState
86+
87+
// -1 means unknown (simulator), treat as not low
88+
if (batteryLevel < 0) return true
89+
90+
// Battery is NOT low if:
91+
// - level is above 15%, OR
92+
// - device is charging, OR
93+
// - device is full
94+
return batteryLevel > 0.15f ||
95+
batteryState == UIDeviceBatteryState.UIDeviceBatteryStateCharging ||
96+
batteryState == UIDeviceBatteryState.UIDeviceBatteryStateFull
97+
}
98+
99+
override fun close() {
100+
levelObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) }
101+
stateObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) }
102+
UIDevice.currentDevice.batteryMonitoringEnabled = false
103+
}
104+
}
105+
106+
internal interface BatteryObserver : Closeable {
107+
/**
108+
* Sets the listener
109+
*
110+
* Implementation must call [listener] shortly after [setListener] returns to let the callers know about the initial state.
111+
*/
112+
fun setListener(listener: Listener)
113+
114+
interface Listener {
115+
fun batteryChanged(isNotLow: Boolean)
116+
}
117+
}
118+
119+

0 commit comments

Comments
 (0)