react-native-flic2 2.0.0-beta.11 → 2.0.0-beta.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +81 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +50 -0
- package/android/src/main/java/nl/xguard/flic2/ActivityUtil.kt +29 -0
- package/android/src/main/java/nl/xguard/flic2/Flic2ButtonListener.kt +144 -0
- package/android/src/main/java/nl/xguard/flic2/Flic2Converter.kt +105 -0
- package/android/src/main/java/nl/xguard/flic2/Flic2Module.kt +521 -0
- package/android/src/main/java/nl/xguard/flic2/Flic2Package.kt +34 -0
- package/android/src/main/java/nl/xguard/flic2/Flic2Service.kt +274 -0
- package/package.json +1 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
buildscript {
|
|
2
|
+
ext.getExtOrDefault = {name ->
|
|
3
|
+
return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['Flic2_' + name]
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
repositories {
|
|
7
|
+
google()
|
|
8
|
+
mavenCentral()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
dependencies {
|
|
12
|
+
classpath "com.android.tools.build:gradle:8.7.2"
|
|
13
|
+
// noinspection DifferentKotlinGradleVersion
|
|
14
|
+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
apply plugin: "com.android.library"
|
|
20
|
+
apply plugin: "kotlin-android"
|
|
21
|
+
|
|
22
|
+
apply plugin: "com.facebook.react"
|
|
23
|
+
|
|
24
|
+
def getExtOrIntegerDefault(name) {
|
|
25
|
+
return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["Flic2_" + name]).toInteger()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
android {
|
|
29
|
+
namespace "nl.xguard.flic2"
|
|
30
|
+
|
|
31
|
+
compileSdkVersion getExtOrIntegerDefault("compileSdkVersion")
|
|
32
|
+
|
|
33
|
+
defaultConfig {
|
|
34
|
+
minSdkVersion getExtOrIntegerDefault("minSdkVersion")
|
|
35
|
+
targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
buildFeatures {
|
|
39
|
+
buildConfig true
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
buildTypes {
|
|
43
|
+
release {
|
|
44
|
+
minifyEnabled false
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
lintOptions {
|
|
49
|
+
disable "GradleCompatible"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
compileOptions {
|
|
53
|
+
sourceCompatibility JavaVersion.VERSION_1_8
|
|
54
|
+
targetCompatibility JavaVersion.VERSION_1_8
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
sourceSets {
|
|
58
|
+
main {
|
|
59
|
+
java.srcDirs += [
|
|
60
|
+
"generated/java",
|
|
61
|
+
"generated/jni"
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
repositories {
|
|
68
|
+
mavenCentral()
|
|
69
|
+
google()
|
|
70
|
+
maven { url 'https://jitpack.io' }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
def kotlin_version = getExtOrDefault("kotlinVersion")
|
|
74
|
+
|
|
75
|
+
dependencies {
|
|
76
|
+
implementation "com.facebook.react:react-android"
|
|
77
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
|
78
|
+
implementation 'com.github.50ButtonsEach:flic2lib-android:1.+'
|
|
79
|
+
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
|
80
|
+
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
|
|
81
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
2
|
+
xmlns:tools="http://schemas.android.com/tools">
|
|
3
|
+
|
|
4
|
+
<!-- Bluetooth permissions for API < 31 -->
|
|
5
|
+
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
|
6
|
+
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
|
7
|
+
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
|
|
8
|
+
|
|
9
|
+
<!-- Bluetooth permissions for API >= 31 -->
|
|
10
|
+
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
|
11
|
+
android:usesPermissionFlags="neverForLocation"
|
|
12
|
+
tools:targetApi="s" />
|
|
13
|
+
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
|
14
|
+
|
|
15
|
+
<!-- Foreground service permission -->
|
|
16
|
+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
17
|
+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
|
18
|
+
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
|
19
|
+
|
|
20
|
+
<application>
|
|
21
|
+
<service
|
|
22
|
+
android:name=".Flic2Service"
|
|
23
|
+
android:enabled="true"
|
|
24
|
+
android:exported="false"
|
|
25
|
+
android:foregroundServiceType="connectedDevice" />
|
|
26
|
+
|
|
27
|
+
<receiver
|
|
28
|
+
android:name=".Flic2Service$BootUpReceiver"
|
|
29
|
+
android:enabled="true"
|
|
30
|
+
android:permission="android.permission.RECEIVE_BOOT_COMPLETED"
|
|
31
|
+
android:exported="false">
|
|
32
|
+
<intent-filter>
|
|
33
|
+
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
|
34
|
+
<category android:name="android.intent.category.DEFAULT" />
|
|
35
|
+
</intent-filter>
|
|
36
|
+
</receiver>
|
|
37
|
+
|
|
38
|
+
<receiver
|
|
39
|
+
android:name=".Flic2Service$UpdateReceiver"
|
|
40
|
+
android:enabled="true"
|
|
41
|
+
android:exported="false">
|
|
42
|
+
<intent-filter>
|
|
43
|
+
<action android:name="android.intent.action.PACKAGE_REPLACED" />
|
|
44
|
+
<data
|
|
45
|
+
android:scheme="package" />
|
|
46
|
+
</intent-filter>
|
|
47
|
+
</receiver>
|
|
48
|
+
</application>
|
|
49
|
+
|
|
50
|
+
</manifest>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
package nl.xguard.flic2
|
|
2
|
+
|
|
3
|
+
import android.app.ActivityManager
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.Intent
|
|
6
|
+
import android.os.Build
|
|
7
|
+
|
|
8
|
+
object ActivityUtil {
|
|
9
|
+
private const val TAG = "ActivityUtil"
|
|
10
|
+
|
|
11
|
+
fun isServiceRunning(context: Context, serviceClass: Class<*>): Boolean {
|
|
12
|
+
val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
|
13
|
+
for (service in manager.getRunningServices(Integer.MAX_VALUE)) {
|
|
14
|
+
if (serviceClass.name == service.service.className) {
|
|
15
|
+
return true
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
fun startForegroundService(context: Context, intent: Intent) {
|
|
22
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
23
|
+
context.startForegroundService(intent)
|
|
24
|
+
} else {
|
|
25
|
+
context.startService(intent)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
package nl.xguard.flic2
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.Arguments
|
|
4
|
+
import com.facebook.react.bridge.WritableMap
|
|
5
|
+
import io.flic.flic2libandroid.BatteryLevel
|
|
6
|
+
import io.flic.flic2libandroid.Flic2Button
|
|
7
|
+
import io.flic.flic2libandroid.Flic2ButtonListener
|
|
8
|
+
|
|
9
|
+
class Flic2ButtonEventListener(
|
|
10
|
+
private val emitEvent: (WritableMap) -> Unit
|
|
11
|
+
) : Flic2ButtonListener() {
|
|
12
|
+
|
|
13
|
+
// Map Android library's onConnect() to emit "connected" (matches iOS)
|
|
14
|
+
override fun onConnect(button: Flic2Button) {
|
|
15
|
+
emitEvent(createButtonEvent(button, "connected"))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Map Android library's onReady() to emit "ready" (matches iOS)
|
|
19
|
+
override fun onReady(button: Flic2Button, timestamp: Long) {
|
|
20
|
+
emitEvent(createButtonEvent(button, "ready"))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Map Android library's onDisconnect() to emit "disconnected" (matches iOS)
|
|
24
|
+
override fun onDisconnect(button: Flic2Button) {
|
|
25
|
+
emitEvent(createButtonEvent(button, "disconnected"))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Map Android library's onFailure() to emit "connectionFailed" (matches iOS)
|
|
29
|
+
override fun onFailure(button: Flic2Button, errorCode: Int, subCode: Int) {
|
|
30
|
+
emitEvent(createButtonEvent(button, "connectionFailed").apply {
|
|
31
|
+
putMap("error", Arguments.createMap().apply {
|
|
32
|
+
putInt("code", errorCode)
|
|
33
|
+
putInt("subCode", subCode)
|
|
34
|
+
putString("message", "Connection failed: code=$errorCode, subCode=$subCode")
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
override fun onButtonUpOrDown(
|
|
40
|
+
button: Flic2Button,
|
|
41
|
+
wasQueued: Boolean,
|
|
42
|
+
lastQueued: Boolean,
|
|
43
|
+
timestamp: Long,
|
|
44
|
+
isUp: Boolean,
|
|
45
|
+
isDown: Boolean
|
|
46
|
+
) {
|
|
47
|
+
val event = if (isDown) "buttonDown" else "buttonUp"
|
|
48
|
+
emitEvent(createButtonEvent(button, event).apply {
|
|
49
|
+
putBoolean("queued", wasQueued)
|
|
50
|
+
// Match old Android implementation and iOS:
|
|
51
|
+
// - Age is in seconds
|
|
52
|
+
// - Only meaningful for queued events; 0 for real-time events
|
|
53
|
+
val ageSeconds = if (wasQueued) {
|
|
54
|
+
(button.readyTimestamp - timestamp) / 1000.0
|
|
55
|
+
} else {
|
|
56
|
+
0.0
|
|
57
|
+
}
|
|
58
|
+
putDouble("age", ageSeconds)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Android library calls ALL applicable callback methods, causing duplicate events.
|
|
63
|
+
// Only onButtonSingleOrDoubleClickOrHold should emit events to match iOS behavior.
|
|
64
|
+
override fun onButtonClickOrHold(
|
|
65
|
+
button: Flic2Button,
|
|
66
|
+
wasQueued: Boolean,
|
|
67
|
+
lastQueued: Boolean,
|
|
68
|
+
timestamp: Long,
|
|
69
|
+
isClick: Boolean,
|
|
70
|
+
isHold: Boolean
|
|
71
|
+
) {
|
|
72
|
+
// Intentionally empty - events are handled by onButtonSingleOrDoubleClickOrHold
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Android library calls ALL applicable callback methods, causing duplicate events.
|
|
76
|
+
// Only onButtonSingleOrDoubleClickOrHold should emit events to match iOS behavior.
|
|
77
|
+
override fun onButtonSingleOrDoubleClick(
|
|
78
|
+
button: Flic2Button,
|
|
79
|
+
wasQueued: Boolean,
|
|
80
|
+
lastQueued: Boolean,
|
|
81
|
+
timestamp: Long,
|
|
82
|
+
isSingleClick: Boolean,
|
|
83
|
+
isDoubleClick: Boolean
|
|
84
|
+
) {
|
|
85
|
+
// Intentionally empty - events are handled by onButtonSingleOrDoubleClickOrHold
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
override fun onButtonSingleOrDoubleClickOrHold(
|
|
89
|
+
button: Flic2Button,
|
|
90
|
+
wasQueued: Boolean,
|
|
91
|
+
lastQueued: Boolean,
|
|
92
|
+
timestamp: Long,
|
|
93
|
+
isSingleClick: Boolean,
|
|
94
|
+
isDoubleClick: Boolean,
|
|
95
|
+
isHold: Boolean
|
|
96
|
+
) {
|
|
97
|
+
val event = when {
|
|
98
|
+
isSingleClick -> "click"
|
|
99
|
+
isDoubleClick -> "doubleClick"
|
|
100
|
+
isHold -> "hold"
|
|
101
|
+
else -> "unknown"
|
|
102
|
+
}
|
|
103
|
+
emitEvent(createButtonEvent(button, event).apply {
|
|
104
|
+
putBoolean("queued", wasQueued)
|
|
105
|
+
// Match old Android implementation and iOS:
|
|
106
|
+
// - Age is in seconds
|
|
107
|
+
// - Only meaningful for queued events; 0 for real-time events
|
|
108
|
+
val ageSeconds = if (wasQueued) {
|
|
109
|
+
(button.readyTimestamp - timestamp) / 1000.0
|
|
110
|
+
} else {
|
|
111
|
+
0.0
|
|
112
|
+
}
|
|
113
|
+
putDouble("age", ageSeconds)
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Map Android library's onUnpaired() to emit "unpaired" (matches iOS)
|
|
118
|
+
override fun onUnpaired(button: Flic2Button) {
|
|
119
|
+
emitEvent(createButtonEvent(button, "unpaired"))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Map Android library's onBatteryLevelUpdated() to emit "batteryUpdate" (matches iOS)
|
|
123
|
+
override fun onBatteryLevelUpdated(button: Flic2Button, level: BatteryLevel) {
|
|
124
|
+
emitEvent(createButtonEvent(button, "batteryUpdate").apply {
|
|
125
|
+
putDouble("voltage", level.voltage.toDouble())
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Map Android library's onNameUpdated() to emit "nicknameUpdate" (matches iOS)
|
|
130
|
+
override fun onNameUpdated(button: Flic2Button, newName: String) {
|
|
131
|
+
emitEvent(createButtonEvent(button, "nicknameUpdate").apply {
|
|
132
|
+
putString("nickname", newName)
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private fun createButtonEvent(button: Flic2Button, event: String): WritableMap {
|
|
137
|
+
return Arguments.createMap().apply {
|
|
138
|
+
putString("uuid", button.uuid)
|
|
139
|
+
putString("event", event)
|
|
140
|
+
putMap("button", Flic2Converter.buttonToMap(button))
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
package nl.xguard.flic2
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.Arguments
|
|
4
|
+
import com.facebook.react.bridge.WritableArray
|
|
5
|
+
import com.facebook.react.bridge.WritableMap
|
|
6
|
+
import io.flic.flic2libandroid.Flic2Button
|
|
7
|
+
|
|
8
|
+
object Flic2Converter {
|
|
9
|
+
|
|
10
|
+
fun buttonToMap(button: Flic2Button): WritableMap {
|
|
11
|
+
return Arguments.createMap().apply {
|
|
12
|
+
putString("uuid", button.uuid)
|
|
13
|
+
putString("identifier", button.uuid) // Android uses UUID as identifier
|
|
14
|
+
putString("name", button.getName() ?: "")
|
|
15
|
+
putString("nickname", button.getName() ?: "")
|
|
16
|
+
putString("bluetoothAddress", button.getBdAddr() ?: "")
|
|
17
|
+
putString("serialNumber", button.getSerialNumber() ?: "")
|
|
18
|
+
|
|
19
|
+
// ready timestamp
|
|
20
|
+
putLong("readyTimestamp", button.getReadyTimestamp())
|
|
21
|
+
|
|
22
|
+
// Use connection state constants instead of enum
|
|
23
|
+
val connState = button.getConnectionState()
|
|
24
|
+
putInt("state", connectionStateToInt(connState))
|
|
25
|
+
putString("stateName", connectionStateToString(connState))
|
|
26
|
+
|
|
27
|
+
// iOS-only: Return defaults for trigger/latency mode (not supported in Android v1.1.0+)
|
|
28
|
+
putInt("triggerMode", 0)
|
|
29
|
+
putString("triggerModeName", "")
|
|
30
|
+
putInt("latencyMode", 0)
|
|
31
|
+
putString("latencyModeName", "")
|
|
32
|
+
|
|
33
|
+
putInt("pressCount", button.getPressCount())
|
|
34
|
+
putInt("firmwareRevision", button.getFirmwareVersion())
|
|
35
|
+
|
|
36
|
+
// Check if ready by comparing connection state
|
|
37
|
+
putBoolean("isReady", connState == Flic2Button.CONNECTION_STATE_CONNECTED_READY)
|
|
38
|
+
|
|
39
|
+
// Get battery level from BatteryLevel object
|
|
40
|
+
val batteryLevel = button.getLastKnownBatteryLevel()
|
|
41
|
+
putDouble("batteryVoltage", batteryLevel?.voltage?.toDouble() ?: 0.0)
|
|
42
|
+
|
|
43
|
+
putBoolean("isUnpaired", button.isUnpaired())
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
fun buttonsToArray(buttons: List<Flic2Button>): WritableArray {
|
|
48
|
+
return Arguments.createArray().apply {
|
|
49
|
+
buttons.forEach { button ->
|
|
50
|
+
pushMap(buttonToMap(button))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private fun connectionStateToInt(state: Int): Int {
|
|
56
|
+
return when (state) {
|
|
57
|
+
Flic2Button.CONNECTION_STATE_DISCONNECTED -> 0
|
|
58
|
+
Flic2Button.CONNECTION_STATE_CONNECTING -> 1
|
|
59
|
+
Flic2Button.CONNECTION_STATE_CONNECTED_STARTING -> 2
|
|
60
|
+
Flic2Button.CONNECTION_STATE_CONNECTED_READY -> 3
|
|
61
|
+
else -> 0
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private fun connectionStateToString(state: Int): String {
|
|
66
|
+
return when (state) {
|
|
67
|
+
Flic2Button.CONNECTION_STATE_DISCONNECTED -> "disconnected"
|
|
68
|
+
Flic2Button.CONNECTION_STATE_CONNECTING -> "connecting"
|
|
69
|
+
Flic2Button.CONNECTION_STATE_CONNECTED_STARTING -> "connected"
|
|
70
|
+
Flic2Button.CONNECTION_STATE_CONNECTED_READY -> "connected"
|
|
71
|
+
else -> "disconnected"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Trigger mode and latency mode are iOS-only features (removed from Android library v1.1.0+)
|
|
76
|
+
|
|
77
|
+
fun scanResultToString(result: Int): String {
|
|
78
|
+
return when (result) {
|
|
79
|
+
0 -> "success"
|
|
80
|
+
1 -> "alreadyRunning"
|
|
81
|
+
2 -> "bluetoothNotActivated"
|
|
82
|
+
3 -> "unknown"
|
|
83
|
+
4 -> "noPublicButtonDiscovered"
|
|
84
|
+
5 -> "alreadyConnectedToAnotherDevice"
|
|
85
|
+
6 -> "connectionTimeout"
|
|
86
|
+
7 -> "invalidVerifier"
|
|
87
|
+
8 -> "blePairingFailedPreviousPairingAlreadyExisting"
|
|
88
|
+
9 -> "blePairingFailedUserCanceled"
|
|
89
|
+
10 -> "blePairingFailedUnknownReason"
|
|
90
|
+
11 -> "appCredentialsDontMatch"
|
|
91
|
+
12 -> "userCanceled"
|
|
92
|
+
13 -> "invalidBluetoothAddress"
|
|
93
|
+
14 -> "genuineCheckFailed"
|
|
94
|
+
15 -> "tooManyApps"
|
|
95
|
+
16 -> "couldNotSetBluetoothNotify"
|
|
96
|
+
17 -> "couldNotDiscoverBluetoothServices"
|
|
97
|
+
18 -> "buttonDisconnectedDuringVerification"
|
|
98
|
+
19 -> "failedToEstablish"
|
|
99
|
+
20 -> "connectionLimitReached"
|
|
100
|
+
21 -> "notInPublicMode"
|
|
101
|
+
else -> "unknown"
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
package nl.xguard.flic2
|
|
2
|
+
|
|
3
|
+
import android.content.ComponentName
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.Intent
|
|
6
|
+
import android.content.ServiceConnection
|
|
7
|
+
import android.os.Build
|
|
8
|
+
import android.os.IBinder
|
|
9
|
+
import android.util.Log
|
|
10
|
+
import com.facebook.react.bridge.Arguments
|
|
11
|
+
import com.facebook.react.bridge.Promise
|
|
12
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
13
|
+
import com.facebook.react.bridge.WritableMap
|
|
14
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
15
|
+
import io.flic.flic2libandroid.Flic2Button
|
|
16
|
+
import io.flic.flic2libandroid.Flic2Manager
|
|
17
|
+
import io.flic.flic2libandroid.Flic2ScanCallback
|
|
18
|
+
import kotlinx.coroutines.CoroutineScope
|
|
19
|
+
import kotlinx.coroutines.Dispatchers
|
|
20
|
+
import kotlinx.coroutines.Job
|
|
21
|
+
import kotlinx.coroutines.TimeoutCancellationException
|
|
22
|
+
import kotlinx.coroutines.cancel
|
|
23
|
+
import kotlinx.coroutines.launch
|
|
24
|
+
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
25
|
+
import kotlinx.coroutines.withTimeout
|
|
26
|
+
import kotlin.coroutines.resume
|
|
27
|
+
import kotlin.coroutines.resumeWithException
|
|
28
|
+
|
|
29
|
+
// Custom exception for scan errors with error codes
|
|
30
|
+
class ScanException(val errorCode: String, val code: Int, message: String) : Exception(message)
|
|
31
|
+
|
|
32
|
+
@ReactModule(name = Flic2Module.NAME)
|
|
33
|
+
class Flic2Module(reactContext: ReactApplicationContext) :
|
|
34
|
+
NativeFlic2Spec(reactContext) {
|
|
35
|
+
|
|
36
|
+
private var flic2Service: Flic2Service? = null
|
|
37
|
+
private var serviceBound = false
|
|
38
|
+
private val moduleScope = CoroutineScope(Dispatchers.Main + Job())
|
|
39
|
+
private var scanJob: Job? = null
|
|
40
|
+
private val buttonListeners = mutableMapOf<String, Flic2ButtonEventListener>()
|
|
41
|
+
private var initializePromise: Promise? = null
|
|
42
|
+
|
|
43
|
+
companion object {
|
|
44
|
+
const val NAME = "Flic2"
|
|
45
|
+
private const val TAG = "Flic2Module"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private val serviceConnection = object : ServiceConnection {
|
|
49
|
+
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
|
50
|
+
Log.d(TAG, "Service connected")
|
|
51
|
+
flic2Service = (service as Flic2Service.Flic2ServiceBinder).getService()
|
|
52
|
+
serviceBound = true
|
|
53
|
+
|
|
54
|
+
// Set up listeners for existing buttons
|
|
55
|
+
flic2Service?.getManager()?.let { manager ->
|
|
56
|
+
manager.buttons.forEach { button ->
|
|
57
|
+
setupButtonListener(button)
|
|
58
|
+
}
|
|
59
|
+
// Update foreground service state based on button count
|
|
60
|
+
updateForegroundServiceState(manager.buttons.size)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Resolve the initialize promise if pending
|
|
64
|
+
initializePromise?.let { promise ->
|
|
65
|
+
promise.resolve(null)
|
|
66
|
+
initializePromise = null
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
override fun onServiceDisconnected(name: ComponentName?) {
|
|
71
|
+
Log.d(TAG, "Service disconnected")
|
|
72
|
+
serviceBound = false
|
|
73
|
+
flic2Service = null
|
|
74
|
+
|
|
75
|
+
// Reject any pending initialize promise
|
|
76
|
+
initializePromise?.let { promise ->
|
|
77
|
+
promise.reject("SERVICE_DISCONNECTED", "Service disconnected unexpectedly")
|
|
78
|
+
initializePromise = null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
override fun getName(): String {
|
|
84
|
+
return NAME
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
override fun invalidate() {
|
|
88
|
+
super.invalidate()
|
|
89
|
+
|
|
90
|
+
// Remove all button listeners before cleanup to prevent callbacks after teardown
|
|
91
|
+
try {
|
|
92
|
+
val manager = flic2Service?.getManager()
|
|
93
|
+
if (manager != null) {
|
|
94
|
+
buttonListeners.forEach { (uuid, listener) ->
|
|
95
|
+
try {
|
|
96
|
+
// Find the button and remove the listener
|
|
97
|
+
val button = manager.buttons.find { it.uuid == uuid }
|
|
98
|
+
if (button != null) {
|
|
99
|
+
button.removeListener(listener)
|
|
100
|
+
Log.d(TAG, "Removed listener for button during invalidate: $uuid")
|
|
101
|
+
}
|
|
102
|
+
} catch (e: Exception) {
|
|
103
|
+
Log.w(TAG, "Failed to remove listener for button during invalidate: $uuid", e)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} catch (e: Exception) {
|
|
108
|
+
Log.w(TAG, "Error during listener cleanup in invalidate", e)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Clear listeners map
|
|
112
|
+
buttonListeners.clear()
|
|
113
|
+
|
|
114
|
+
moduleScope.cancel()
|
|
115
|
+
if (serviceBound) {
|
|
116
|
+
reactApplicationContext.unbindService(serviceConnection)
|
|
117
|
+
serviceBound = false
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// MARK: - Manager Methods
|
|
122
|
+
|
|
123
|
+
override fun initialize(background: Boolean, promise: Promise) {
|
|
124
|
+
try {
|
|
125
|
+
// Store the promise to resolve when service is connected
|
|
126
|
+
initializePromise = promise
|
|
127
|
+
|
|
128
|
+
val intent = Intent(reactApplicationContext, Flic2Service::class.java)
|
|
129
|
+
|
|
130
|
+
// Check if service is already running
|
|
131
|
+
if (!ActivityUtil.isServiceRunning(reactApplicationContext, Flic2Service::class.java)) {
|
|
132
|
+
// Start service
|
|
133
|
+
ActivityUtil.startForegroundService(reactApplicationContext, intent)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Bind to service - promise will be resolved in onServiceConnected
|
|
137
|
+
val bound = reactApplicationContext.bindService(
|
|
138
|
+
intent,
|
|
139
|
+
serviceConnection,
|
|
140
|
+
Context.BIND_AUTO_CREATE
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if (!bound) {
|
|
144
|
+
initializePromise = null
|
|
145
|
+
promise.reject("INIT_ERROR", "Failed to bind to service")
|
|
146
|
+
}
|
|
147
|
+
} catch (e: Exception) {
|
|
148
|
+
Log.e(TAG, "Failed to initialize", e)
|
|
149
|
+
initializePromise = null
|
|
150
|
+
promise.reject("INIT_ERROR", "Failed to initialize: ${e.message}", e)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
override fun getButtons(promise: Promise) {
|
|
155
|
+
try {
|
|
156
|
+
val manager = flic2Service?.getManager()
|
|
157
|
+
if (manager == null) {
|
|
158
|
+
promise.reject("NOT_INITIALIZED", "Manager not initialized")
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
val buttons = manager.buttons
|
|
163
|
+
val buttonArray = Flic2Converter.buttonsToArray(buttons)
|
|
164
|
+
promise.resolve(buttonArray)
|
|
165
|
+
} catch (e: Exception) {
|
|
166
|
+
Log.e(TAG, "Failed to get buttons", e)
|
|
167
|
+
promise.reject("GET_BUTTONS_ERROR", "Failed to get buttons: ${e.message}", e)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
override fun scanForButtons(promise: Promise) {
|
|
172
|
+
val manager = flic2Service?.getManager()
|
|
173
|
+
if (manager == null) {
|
|
174
|
+
promise.reject("NOT_INITIALIZED", "Manager not initialized")
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Cancel any existing scan
|
|
179
|
+
scanJob?.cancel()
|
|
180
|
+
|
|
181
|
+
Log.d(TAG, "Starting scan")
|
|
182
|
+
|
|
183
|
+
// Emit started event (matches iOS)
|
|
184
|
+
emitOnScanStatusChange(Arguments.createMap().apply {
|
|
185
|
+
putString("event", "started")
|
|
186
|
+
putString("eventName", "started")
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
manager.startScan(object : Flic2ScanCallback {
|
|
190
|
+
override fun onDiscoveredAlreadyPairedButton(button: Flic2Button) {
|
|
191
|
+
Log.d(TAG, "Discovered already paired button")
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
override fun onDiscovered(bdAddr: String) {
|
|
195
|
+
Log.d(TAG, "Discovered button: $bdAddr")
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
override fun onConnected() {
|
|
199
|
+
Log.d(TAG, "Button connected during scan")
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
override fun onComplete(result: Int, subCode: Int, button: Flic2Button?) {
|
|
203
|
+
Log.d(TAG, "Scan complete: result=$result, button=${button?.uuid}")
|
|
204
|
+
|
|
205
|
+
if (result == Flic2ScanCallback.RESULT_SUCCESS && button != null) {
|
|
206
|
+
// Auto-connect (trigger mode not available in Android v1.1.0+)
|
|
207
|
+
button.connect()
|
|
208
|
+
|
|
209
|
+
setupButtonListener(button)
|
|
210
|
+
|
|
211
|
+
// Update foreground service state after adding button
|
|
212
|
+
flic2Service?.getManager()?.let { manager ->
|
|
213
|
+
updateForegroundServiceState(manager.buttons.size)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Emit discovered event as button event (like iOS)
|
|
217
|
+
emitOnButtonEvent(Arguments.createMap().apply {
|
|
218
|
+
putString("uuid", button.uuid)
|
|
219
|
+
putString("event", "discovered")
|
|
220
|
+
putMap("button", Flic2Converter.buttonToMap(button))
|
|
221
|
+
})
|
|
222
|
+
} else {
|
|
223
|
+
Log.e(TAG, "Scan failed with error code: ${Flic2Converter.scanResultToString(result)}")
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Emit scan completion with result code
|
|
227
|
+
emitOnScanStatusChange(Arguments.createMap().apply {
|
|
228
|
+
putString("event", "completion")
|
|
229
|
+
putString("eventName", "completion")
|
|
230
|
+
putInt("result", mapScanResultToCode(result))
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
// Return immediately - scan results will come through events
|
|
236
|
+
promise.resolve(null)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
override fun stopScan(promise: Promise) {
|
|
240
|
+
try {
|
|
241
|
+
val manager = flic2Service?.getManager()
|
|
242
|
+
if (manager == null) {
|
|
243
|
+
promise.reject("NOT_INITIALIZED", "Manager not initialized")
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
scanJob?.cancel()
|
|
248
|
+
manager.stopScan()
|
|
249
|
+
|
|
250
|
+
promise.resolve(null)
|
|
251
|
+
} catch (e: Exception) {
|
|
252
|
+
Log.e(TAG, "Failed to stop scan", e)
|
|
253
|
+
promise.reject("STOP_SCAN_ERROR", "Failed to stop scan: ${e.message}", e)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
override fun forgetButton(uuid: String, promise: Promise) {
|
|
258
|
+
try {
|
|
259
|
+
val manager = flic2Service?.getManager()
|
|
260
|
+
if (manager == null) {
|
|
261
|
+
promise.reject("NOT_INITIALIZED", "Manager not initialized")
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
val button = manager.buttons.find { it.uuid == uuid }
|
|
266
|
+
if (button == null) {
|
|
267
|
+
promise.reject("BUTTON_NOT_FOUND", "Button not found")
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Disconnect before forgetting like iOS
|
|
272
|
+
button.disconnectOrAbortPendingConnection()
|
|
273
|
+
|
|
274
|
+
// Explicitly remove listener from button before forgetting (matches old implementation)
|
|
275
|
+
val listener = buttonListeners[uuid]
|
|
276
|
+
if (listener != null) {
|
|
277
|
+
try {
|
|
278
|
+
button.removeListener(listener)
|
|
279
|
+
Log.d(TAG, "Removed listener for button: $uuid")
|
|
280
|
+
} catch (e: Exception) {
|
|
281
|
+
Log.w(TAG, "Failed to remove listener for button: $uuid", e)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Remove listener from map
|
|
286
|
+
buttonListeners.remove(uuid)
|
|
287
|
+
|
|
288
|
+
// Forget button
|
|
289
|
+
manager.forgetButton(button)
|
|
290
|
+
|
|
291
|
+
// Update foreground service state after removing button
|
|
292
|
+
updateForegroundServiceState(manager.buttons.size)
|
|
293
|
+
|
|
294
|
+
promise.resolve(null)
|
|
295
|
+
} catch (e: Exception) {
|
|
296
|
+
Log.e(TAG, "Failed to forget button", e)
|
|
297
|
+
promise.reject("FORGET_ERROR", "Failed to forget button: ${e.message}", e)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// MARK: - Button Methods
|
|
302
|
+
|
|
303
|
+
override fun connectButton(uuid: String, promise: Promise) {
|
|
304
|
+
try {
|
|
305
|
+
val button = findButton(uuid)
|
|
306
|
+
if (button == null) {
|
|
307
|
+
promise.reject("BUTTON_NOT_FOUND", "Button not found")
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
button.connect()
|
|
312
|
+
|
|
313
|
+
promise.resolve(Flic2Converter.buttonToMap(button))
|
|
314
|
+
} catch (e: Exception) {
|
|
315
|
+
Log.e(TAG, "Failed to connect button", e)
|
|
316
|
+
promise.reject("CONNECT_ERROR", "Failed to connect: ${e.message}", e)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
override fun disconnectButton(uuid: String, promise: Promise) {
|
|
321
|
+
try {
|
|
322
|
+
val button = findButton(uuid)
|
|
323
|
+
if (button == null) {
|
|
324
|
+
promise.reject("BUTTON_NOT_FOUND", "Button not found")
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
button.disconnectOrAbortPendingConnection()
|
|
329
|
+
|
|
330
|
+
promise.resolve(Flic2Converter.buttonToMap(button))
|
|
331
|
+
} catch (e: Exception) {
|
|
332
|
+
Log.e(TAG, "Failed to disconnect button", e)
|
|
333
|
+
promise.reject("DISCONNECT_ERROR", "Failed to disconnect: ${e.message}", e)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
override fun setTriggerMode(uuid: String, mode: Double, promise: Promise) {
|
|
338
|
+
promise.reject(
|
|
339
|
+
"NOT_SUPPORTED_ON_ANDROID",
|
|
340
|
+
"Trigger mode is only supported on iOS. Android Flic2 library v1.1.0+ does not support trigger modes."
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
override fun setLatencyMode(uuid: String, mode: Double, promise: Promise) {
|
|
345
|
+
promise.reject(
|
|
346
|
+
"NOT_SUPPORTED_ON_ANDROID",
|
|
347
|
+
"Latency mode is only supported on iOS. Android Flic2 library v1.1.0+ does not support latency modes."
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
override fun setNickname(uuid: String, nickname: String, promise: Promise) {
|
|
352
|
+
try {
|
|
353
|
+
val button = findButton(uuid)
|
|
354
|
+
if (button == null) {
|
|
355
|
+
promise.reject("BUTTON_NOT_FOUND", "Button not found")
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// v1.1.0 uses setName() method instead of property
|
|
360
|
+
button.setName(nickname)
|
|
361
|
+
|
|
362
|
+
promise.resolve(Flic2Converter.buttonToMap(button))
|
|
363
|
+
} catch (e: Exception) {
|
|
364
|
+
Log.e(TAG, "Failed to set nickname", e)
|
|
365
|
+
promise.reject("SET_NICKNAME_ERROR", "Failed to set nickname: ${e.message}", e)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
override fun connectAllKnownButtons(promise: Promise) {
|
|
370
|
+
try {
|
|
371
|
+
val manager = flic2Service?.getManager()
|
|
372
|
+
if (manager == null) {
|
|
373
|
+
promise.reject("NOT_INITIALIZED", "Manager not initialized")
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
val buttons = manager.buttons
|
|
378
|
+
|
|
379
|
+
buttons.forEach { button ->
|
|
380
|
+
Log.d(TAG, "Connecting button: ${button.getName()}")
|
|
381
|
+
// Trigger mode not available in Android v1.1.0+
|
|
382
|
+
button.connect()
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
promise.resolve(null)
|
|
386
|
+
} catch (e: Exception) {
|
|
387
|
+
Log.e(TAG, "Failed to connect all buttons", e)
|
|
388
|
+
promise.reject("CONNECT_ALL_ERROR", "Failed to connect all buttons: ${e.message}", e)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
override fun disconnectAllKnownButtons(promise: Promise) {
|
|
393
|
+
try {
|
|
394
|
+
val manager = flic2Service?.getManager()
|
|
395
|
+
if (manager == null) {
|
|
396
|
+
promise.reject("NOT_INITIALIZED", "Manager not initialized")
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
val buttons = manager.buttons
|
|
401
|
+
|
|
402
|
+
buttons.forEach { button ->
|
|
403
|
+
Log.d(TAG, "Disconnecting button: ${button.name}")
|
|
404
|
+
button.disconnectOrAbortPendingConnection()
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
promise.resolve(null)
|
|
408
|
+
} catch (e: Exception) {
|
|
409
|
+
Log.e(TAG, "Failed to disconnect all buttons", e)
|
|
410
|
+
promise.reject("DISCONNECT_ALL_ERROR", "Failed to disconnect all buttons: ${e.message}", e)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
override fun forgetAllButtons(promise: Promise) {
|
|
415
|
+
try {
|
|
416
|
+
val manager = flic2Service?.getManager()
|
|
417
|
+
if (manager == null) {
|
|
418
|
+
promise.reject("NOT_INITIALIZED", "Manager not initialized")
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Create a copy of the list to avoid concurrent modification
|
|
423
|
+
val buttons = manager.buttons.toList()
|
|
424
|
+
|
|
425
|
+
buttons.forEach { button ->
|
|
426
|
+
// Explicitly remove listener from button before forgetting (matches old implementation)
|
|
427
|
+
val listener = buttonListeners[button.uuid]
|
|
428
|
+
if (listener != null) {
|
|
429
|
+
try {
|
|
430
|
+
button.removeListener(listener)
|
|
431
|
+
Log.d(TAG, "Removed listener for button: ${button.uuid}")
|
|
432
|
+
} catch (e: Exception) {
|
|
433
|
+
Log.w(TAG, "Failed to remove listener for button: ${button.uuid}", e)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Remove listener from map
|
|
438
|
+
buttonListeners.remove(button.uuid)
|
|
439
|
+
|
|
440
|
+
// Disconnect before forgetting
|
|
441
|
+
button.disconnectOrAbortPendingConnection()
|
|
442
|
+
|
|
443
|
+
// Forget button
|
|
444
|
+
manager.forgetButton(button)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Update foreground service state after removing all buttons
|
|
448
|
+
updateForegroundServiceState(manager.buttons.size)
|
|
449
|
+
|
|
450
|
+
promise.resolve(null)
|
|
451
|
+
} catch (e: Exception) {
|
|
452
|
+
Log.e(TAG, "Failed to forget all buttons", e)
|
|
453
|
+
promise.reject("FORGET_ALL_ERROR", "Failed to forget all buttons: ${e.message}", e)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
override fun isScanning(promise: Promise) {
|
|
458
|
+
try {
|
|
459
|
+
val manager = flic2Service?.getManager()
|
|
460
|
+
if (manager == null) {
|
|
461
|
+
promise.reject("NOT_INITIALIZED", "Manager not initialized")
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
promise.resolve(scanJob != null && scanJob?.isActive == true)
|
|
466
|
+
} catch (e: Exception) {
|
|
467
|
+
Log.e(TAG, "Failed to check scanning status", e)
|
|
468
|
+
promise.reject("IS_SCANNING_ERROR", "Failed to check scanning status: ${e.message}", e)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// MARK: - Helper Methods
|
|
473
|
+
|
|
474
|
+
private fun findButton(uuid: String): Flic2Button? {
|
|
475
|
+
return flic2Service?.getManager()?.buttons?.find { it.uuid == uuid }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private fun setupButtonListener(button: Flic2Button) {
|
|
479
|
+
// Remove existing listener if any
|
|
480
|
+
buttonListeners.remove(button.uuid)
|
|
481
|
+
|
|
482
|
+
// Create new listener
|
|
483
|
+
val listener = Flic2ButtonEventListener { event ->
|
|
484
|
+
emitOnButtonEvent(event)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Add listener to button
|
|
488
|
+
button.addListener(listener)
|
|
489
|
+
|
|
490
|
+
// Store listener reference
|
|
491
|
+
buttonListeners[button.uuid] = listener
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private fun updateForegroundServiceState(buttonCount: Int) {
|
|
495
|
+
if (buttonCount > 0) {
|
|
496
|
+
// Start foreground service when buttons exist
|
|
497
|
+
flic2Service?.startForegroundService()
|
|
498
|
+
} else {
|
|
499
|
+
// Stop foreground service when no buttons
|
|
500
|
+
flic2Service?.stopForegroundService()
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private fun mapScanResultToCode(result: Int): Int {
|
|
505
|
+
// Map Android library's 9 result codes (0-8) to TypeScript enum codes (0-21) matching iOS
|
|
506
|
+
// Android library only provides these constants, so we map them to the closest equivalent
|
|
507
|
+
return when (result) {
|
|
508
|
+
Flic2ScanCallback.RESULT_SUCCESS -> 0 // SUCCESS
|
|
509
|
+
Flic2ScanCallback.RESULT_FAILED_ALREADY_RUNNING -> 1 // ALREADY_RUNNING
|
|
510
|
+
Flic2ScanCallback.RESULT_FAILED_BLUETOOTH_OFF -> 2 // BLUETOOTH_NOT_ACTIVATED
|
|
511
|
+
Flic2ScanCallback.RESULT_FAILED_SCAN_ERROR -> 3 // UNKNOWN
|
|
512
|
+
Flic2ScanCallback.RESULT_FAILED_NO_NEW_BUTTONS_FOUND -> 4 // NO_PUBLIC_BUTTON_DISCOVERED
|
|
513
|
+
Flic2ScanCallback.RESULT_FAILED_BUTTON_ALREADY_CONNECTED_TO_OTHER_DEVICE -> 5 // ALREADY_CONNECTED_TO_ANOTHER_DEVICE
|
|
514
|
+
Flic2ScanCallback.RESULT_FAILED_CONNECT_TIMED_OUT -> 6 // CONNECTION_TIMEOUT
|
|
515
|
+
Flic2ScanCallback.RESULT_FAILED_VERIFY_TIMED_OUT -> 7 // INVALID_VERIFIER
|
|
516
|
+
Flic2ScanCallback.RESULT_SYSTEM_PAIRING_DIALOG_NOT_ACCEPTED -> 9 // BLE_PAIRING_FAILED_USER_CANCELED
|
|
517
|
+
else -> 3 // UNKNOWN (for any unexpected codes)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
package nl.xguard.flic2
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.BaseReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfo
|
|
7
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
8
|
+
import java.util.HashMap
|
|
9
|
+
|
|
10
|
+
class Flic2Package : BaseReactPackage() {
|
|
11
|
+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
|
|
12
|
+
return if (name == Flic2Module.NAME) {
|
|
13
|
+
Flic2Module(reactContext)
|
|
14
|
+
} else {
|
|
15
|
+
null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
|
|
20
|
+
return ReactModuleInfoProvider {
|
|
21
|
+
val moduleInfos: MutableMap<String, ReactModuleInfo> = HashMap()
|
|
22
|
+
moduleInfos[Flic2Module.NAME] = ReactModuleInfo(
|
|
23
|
+
Flic2Module.NAME,
|
|
24
|
+
Flic2Module.NAME,
|
|
25
|
+
false, // canOverrideExistingModule
|
|
26
|
+
false, // needsEagerInit
|
|
27
|
+
false, // isCxxModule
|
|
28
|
+
true // isTurboModule
|
|
29
|
+
)
|
|
30
|
+
moduleInfos
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
package nl.xguard.flic2
|
|
2
|
+
|
|
3
|
+
import android.app.Notification
|
|
4
|
+
import android.app.NotificationChannel
|
|
5
|
+
import android.app.NotificationManager
|
|
6
|
+
import android.app.PendingIntent
|
|
7
|
+
import android.app.Service
|
|
8
|
+
import android.content.BroadcastReceiver
|
|
9
|
+
import android.content.Context
|
|
10
|
+
import android.content.Intent
|
|
11
|
+
import android.content.pm.PackageManager
|
|
12
|
+
import android.os.Binder
|
|
13
|
+
import android.os.Build
|
|
14
|
+
import android.os.Handler
|
|
15
|
+
import android.os.IBinder
|
|
16
|
+
import android.os.Looper
|
|
17
|
+
import android.util.Log
|
|
18
|
+
import androidx.core.app.NotificationCompat
|
|
19
|
+
import io.flic.flic2libandroid.Flic2Manager
|
|
20
|
+
|
|
21
|
+
class Flic2Service : Service() {
|
|
22
|
+
|
|
23
|
+
private val binder = Flic2ServiceBinder()
|
|
24
|
+
private var manager: Flic2Manager? = null
|
|
25
|
+
private var isServiceStarted = false
|
|
26
|
+
private var notification: Notification? = null
|
|
27
|
+
|
|
28
|
+
companion object {
|
|
29
|
+
private const val TAG = "Flic2Service"
|
|
30
|
+
private const val DEFAULT_NOTIFICATION_ID = 123321
|
|
31
|
+
private const val DEFAULT_CHANNEL_ID = "Notification_Channel_Flic2Service"
|
|
32
|
+
|
|
33
|
+
// Metadata keys for notification configuration
|
|
34
|
+
private const val KEY_CHANNEL_NAME = "nl.xguard.flic2.notification_channel_name"
|
|
35
|
+
private const val KEY_CHANNEL_DESCRIPTION = "nl.xguard.flic2.notification_channel_description"
|
|
36
|
+
private const val NOTIFICATION_TITLE_KEY = "nl.xguard.flic2.notification_title"
|
|
37
|
+
private const val NOTIFICATION_TEXT_KEY = "nl.xguard.flic2.notification_text"
|
|
38
|
+
private const val NOTIFICATION_ICON_KEY = "nl.xguard.flic2.notification_icon"
|
|
39
|
+
private const val NOTIFICATION_ID_KEY = "nl.xguard.flic2.notification_id"
|
|
40
|
+
private const val CHANNEL_ID_KEY = "nl.xguard.flic2.notification_channel_id"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
inner class Flic2ServiceBinder : Binder() {
|
|
44
|
+
fun getService(): Flic2Service = this@Flic2Service
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
override fun onCreate() {
|
|
48
|
+
super.onCreate()
|
|
49
|
+
Log.d(TAG, "Service onCreate")
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// Initialize Flic2Manager on main thread with Handler
|
|
53
|
+
// v1.1.0 API: init() returns void, must call getInstance() after
|
|
54
|
+
Flic2Manager.init(
|
|
55
|
+
applicationContext,
|
|
56
|
+
Handler(Looper.getMainLooper())
|
|
57
|
+
)
|
|
58
|
+
manager = Flic2Manager.getInstance()
|
|
59
|
+
Log.d(TAG, "Flic2Manager initialized successfully")
|
|
60
|
+
} catch (e: Exception) {
|
|
61
|
+
Log.e(TAG, "Failed to initialize Flic2Manager", e)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Create notification channel and notification in onCreate
|
|
65
|
+
createNotificationChannel()
|
|
66
|
+
notification = createNotification()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
70
|
+
Log.d(TAG, "Service onStartCommand")
|
|
71
|
+
|
|
72
|
+
if (intent != null) {
|
|
73
|
+
if (Intent.ACTION_BOOT_COMPLETED == intent.action) {
|
|
74
|
+
Log.d(TAG, "onStartCommand: ACTION_BOOT_COMPLETED")
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Start foreground service if notification is ready
|
|
79
|
+
if (notification != null) {
|
|
80
|
+
startForegroundService()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return START_STICKY
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
override fun onBind(intent: Intent?): IBinder {
|
|
87
|
+
Log.d(TAG, "Service onBind")
|
|
88
|
+
return binder
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
override fun onDestroy() {
|
|
92
|
+
Log.d(TAG, "Service onDestroy")
|
|
93
|
+
super.onDestroy()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fun getManager(): Flic2Manager? = manager
|
|
97
|
+
|
|
98
|
+
fun isManagerInitialized(): Boolean = manager != null
|
|
99
|
+
|
|
100
|
+
fun startForegroundService() {
|
|
101
|
+
if (!isServiceStarted && notification != null) {
|
|
102
|
+
isServiceStarted = true
|
|
103
|
+
try {
|
|
104
|
+
startForeground(getNotificationId(), notification)
|
|
105
|
+
} catch (e: Exception) {
|
|
106
|
+
Log.w(TAG, "startForegroundService() exception", e)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
fun stopForegroundService() {
|
|
112
|
+
if (isServiceStarted) {
|
|
113
|
+
isServiceStarted = false
|
|
114
|
+
stopForeground(true)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private fun createNotificationChannel() {
|
|
119
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
120
|
+
val channel = NotificationChannel(
|
|
121
|
+
getChannelId(),
|
|
122
|
+
getChannelName(),
|
|
123
|
+
NotificationManager.IMPORTANCE_LOW
|
|
124
|
+
).apply {
|
|
125
|
+
description = getChannelDescription()
|
|
126
|
+
setShowBadge(false)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private fun createNotification(): Notification {
|
|
134
|
+
val notificationIntent = Intent(this, Flic2Service::class.java)
|
|
135
|
+
val pendingIntent = PendingIntent.getActivity(
|
|
136
|
+
this,
|
|
137
|
+
0,
|
|
138
|
+
notificationIntent,
|
|
139
|
+
PendingIntent.FLAG_IMMUTABLE
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return NotificationCompat.Builder(this, getChannelId())
|
|
143
|
+
.setContentTitle(getNotificationTitle())
|
|
144
|
+
.setContentText(getNotificationText())
|
|
145
|
+
.setSmallIcon(getNotificationIcon())
|
|
146
|
+
.setContentIntent(pendingIntent)
|
|
147
|
+
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
148
|
+
.setOngoing(true)
|
|
149
|
+
.build()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private fun getNotificationId(): Int {
|
|
153
|
+
return try {
|
|
154
|
+
val metadata = applicationContext.packageManager
|
|
155
|
+
.getApplicationInfo(applicationContext.packageName, PackageManager.GET_META_DATA)
|
|
156
|
+
.metaData
|
|
157
|
+
metadata?.getInt(NOTIFICATION_ID_KEY, DEFAULT_NOTIFICATION_ID) ?: DEFAULT_NOTIFICATION_ID
|
|
158
|
+
} catch (e: PackageManager.NameNotFoundException) {
|
|
159
|
+
Log.w(TAG, "getNotificationId() NameNotFoundException", e)
|
|
160
|
+
DEFAULT_NOTIFICATION_ID
|
|
161
|
+
} catch (e: Exception) {
|
|
162
|
+
Log.w(TAG, "getNotificationId() exception", e)
|
|
163
|
+
DEFAULT_NOTIFICATION_ID
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private fun getChannelId(): String {
|
|
168
|
+
return try {
|
|
169
|
+
val metadata = applicationContext.packageManager
|
|
170
|
+
.getApplicationInfo(applicationContext.packageName, PackageManager.GET_META_DATA)
|
|
171
|
+
.metaData
|
|
172
|
+
metadata?.getString(CHANNEL_ID_KEY) ?: DEFAULT_CHANNEL_ID
|
|
173
|
+
} catch (e: PackageManager.NameNotFoundException) {
|
|
174
|
+
Log.w(TAG, "getChannelId() NameNotFoundException", e)
|
|
175
|
+
DEFAULT_CHANNEL_ID
|
|
176
|
+
} catch (e: Exception) {
|
|
177
|
+
Log.w(TAG, "getChannelId() exception", e)
|
|
178
|
+
DEFAULT_CHANNEL_ID
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private fun getChannelName(): String {
|
|
183
|
+
return try {
|
|
184
|
+
val metadata = applicationContext.packageManager
|
|
185
|
+
.getApplicationInfo(applicationContext.packageName, PackageManager.GET_META_DATA)
|
|
186
|
+
.metaData
|
|
187
|
+
metadata?.getString(KEY_CHANNEL_NAME) ?: "Flic2Channel"
|
|
188
|
+
} catch (e: PackageManager.NameNotFoundException) {
|
|
189
|
+
Log.w(TAG, "getChannelName() NameNotFoundException", e)
|
|
190
|
+
"Flic2Channel"
|
|
191
|
+
} catch (e: Exception) {
|
|
192
|
+
Log.w(TAG, "getChannelName() exception", e)
|
|
193
|
+
"Flic2Channel"
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private fun getChannelDescription(): String {
|
|
198
|
+
return try {
|
|
199
|
+
val metadata = applicationContext.packageManager
|
|
200
|
+
.getApplicationInfo(applicationContext.packageName, PackageManager.GET_META_DATA)
|
|
201
|
+
.metaData
|
|
202
|
+
metadata?.getString(KEY_CHANNEL_DESCRIPTION) ?: "Flic2Channel"
|
|
203
|
+
} catch (e: PackageManager.NameNotFoundException) {
|
|
204
|
+
Log.w(TAG, "getChannelDescription() NameNotFoundException", e)
|
|
205
|
+
"Flic2Channel"
|
|
206
|
+
} catch (e: Exception) {
|
|
207
|
+
Log.w(TAG, "getChannelDescription() exception", e)
|
|
208
|
+
"Flic2Channel"
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private fun getNotificationTitle(): String {
|
|
213
|
+
return try {
|
|
214
|
+
val metadata = applicationContext.packageManager
|
|
215
|
+
.getApplicationInfo(applicationContext.packageName, PackageManager.GET_META_DATA)
|
|
216
|
+
.metaData
|
|
217
|
+
metadata?.getString(NOTIFICATION_TITLE_KEY) ?: "Flic 2"
|
|
218
|
+
} catch (e: PackageManager.NameNotFoundException) {
|
|
219
|
+
Log.w(TAG, "getNotificationTitle() NameNotFoundException", e)
|
|
220
|
+
"Flic 2"
|
|
221
|
+
} catch (e: Exception) {
|
|
222
|
+
Log.w(TAG, "getNotificationTitle() exception", e)
|
|
223
|
+
"Flic 2"
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private fun getNotificationText(): String {
|
|
228
|
+
return try {
|
|
229
|
+
val metadata = applicationContext.packageManager
|
|
230
|
+
.getApplicationInfo(applicationContext.packageName, PackageManager.GET_META_DATA)
|
|
231
|
+
.metaData
|
|
232
|
+
metadata?.getString(NOTIFICATION_TEXT_KEY) ?: "Flic 2 service is running"
|
|
233
|
+
} catch (e: PackageManager.NameNotFoundException) {
|
|
234
|
+
Log.w(TAG, "getNotificationText() NameNotFoundException", e)
|
|
235
|
+
"Flic 2 service is running"
|
|
236
|
+
} catch (e: Exception) {
|
|
237
|
+
Log.w(TAG, "getNotificationText() exception", e)
|
|
238
|
+
"Flic 2 service is running"
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private fun getNotificationIcon(): Int {
|
|
243
|
+
return try {
|
|
244
|
+
val metadata = applicationContext.packageManager
|
|
245
|
+
.getApplicationInfo(applicationContext.packageName, PackageManager.GET_META_DATA)
|
|
246
|
+
.metaData
|
|
247
|
+
val icon = metadata?.getInt(NOTIFICATION_ICON_KEY, 0) ?: 0
|
|
248
|
+
if (icon != 0) icon else android.R.drawable.ic_dialog_info
|
|
249
|
+
} catch (e: PackageManager.NameNotFoundException) {
|
|
250
|
+
Log.w(TAG, "getNotificationIcon() NameNotFoundException", e)
|
|
251
|
+
android.R.drawable.ic_dialog_info
|
|
252
|
+
} catch (e: Exception) {
|
|
253
|
+
Log.w(TAG, "getNotificationIcon() exception", e)
|
|
254
|
+
android.R.drawable.ic_dialog_info
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// BootUpReceiver for handling device boot
|
|
259
|
+
class BootUpReceiver : BroadcastReceiver() {
|
|
260
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
261
|
+
Log.d(TAG, "BootUpReceiver()")
|
|
262
|
+
// The Application class's onCreate has already been called at this point, which is what we want
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// UpdateReceiver for handling app updates
|
|
267
|
+
class UpdateReceiver : BroadcastReceiver() {
|
|
268
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
269
|
+
Log.d(TAG, "UpdateReceiver()")
|
|
270
|
+
// The Application class's onCreate has already been called at this point, which is what we want
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
package/package.json
CHANGED