Skip to content

Commit cf19645

Browse files
committed
Merge remote-tracking branch 'origin/feature/peripheral' into develop
# Conflicts: # gradle/libs.versions.toml
2 parents b889944 + 6df1151 commit cf19645

11 files changed

Lines changed: 823 additions & 81 deletions

File tree

example/holder/app/composeApp/src/androidMain/AndroidManifest.xml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
2020
xmlns:tools="http://schemas.android.com/tools">
2121
<uses-permission android:name="android.permission.INTERNET"/>
22+
<uses-permission android:name="android.permission.CAMERA"/>
2223

2324

2425
<!-- Necessary to perform any Bluetooth classic or BLE communication, such as requesting a
@@ -50,6 +51,13 @@
5051
tools:targetApi="s"
5152
/>
5253

54+
<!-- Required to advertise BLE services on Android 12+ -->
55+
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"/>
56+
57+
<!-- Required for foreground service on Android 14+ -->
58+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
59+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
60+
5361

5462
<!-- "If you say the feature is required for your app, then the Google Play store will hide your
5563
app from users on devices lacking those features. For this reason, you should only set the
@@ -61,6 +69,10 @@
6169
android:name="android.hardware.bluetooth_le"
6270
android:required="false"/>
6371

72+
<!-- Camera for QR code scanning -->
73+
<uses-feature
74+
android:name="android.hardware.camera"
75+
android:required="false"/>
6476

6577
<!-- NFC engagement -->
6678
<uses-feature
@@ -94,7 +106,8 @@
94106
android:name=".MdocNfcService"
95107
android:exported="true"
96108
android:label="@string/nfc_engagement_service_desc"
97-
android:permission="android.permission.BIND_NFC_SERVICE">
109+
android:permission="android.permission.BIND_NFC_SERVICE"
110+
android:foregroundServiceType="connectedDevice">
98111
<intent-filter>
99112
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE"/>
100113
</intent-filter>
@@ -104,6 +117,11 @@
104117
android:resource="@xml/nfc_engagement_apdu_service"/>
105118
</service>
106119

120+
<service
121+
android:name=".BleEngagementService"
122+
android:enabled="true"
123+
android:exported="false"
124+
android:foregroundServiceType="connectedDevice"/>
107125

108126
</application>
109127

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* © 2025 Sphereon International B.V.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
18+
package com.sphereon.kiwa.sample.app
19+
20+
import android.content.Context
21+
import com.sphereon.core.api.LogManager
22+
import com.sphereon.mdoc.engagement.MdocEngagementEvent
23+
import com.sphereon.mdoc.engagement.MdocEngagementManager
24+
import kotlinx.coroutines.CoroutineScope
25+
import kotlinx.coroutines.Dispatchers
26+
import kotlinx.coroutines.SupervisorJob
27+
import kotlinx.coroutines.cancel
28+
import kotlinx.coroutines.flow.launchIn
29+
import kotlinx.coroutines.flow.onEach
30+
import me.tatarka.inject.annotations.Inject
31+
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
32+
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
33+
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn
34+
35+
/**
36+
* Observes BLE engagement lifecycle and manages the BleEngagementService foreground service.
37+
*
38+
* This observer monitors the engagement manager for BLE-related events and automatically
39+
* starts/stops the foreground service to ensure BLE operations continue even when the
40+
* screen is locked or the app is in the background.
41+
*
42+
* The foreground service is required because:
43+
* - Android suspends BLE advertising when the screen locks (especially on Samsung devices)
44+
* - Android 12+ has strict background execution limits
45+
* - Without a foreground service, BLE connections will be interrupted when the device sleeps
46+
*
47+
* Key responsibilities:
48+
* - Monitor engagement events from the engagement manager
49+
* - Start BleEngagementService when BLE advertising begins
50+
* - Stop BleEngagementService when BLE engagement completes
51+
* - Ensure service lifecycle matches engagement lifecycle
52+
*/
53+
interface BleEngagementLifecycleObserver {
54+
/**
55+
* Initializes the observer to start monitoring engagement events.
56+
*
57+
* @param context Android context for starting/stopping services
58+
* @param engagementManager Manager to observe for BLE engagement events
59+
*/
60+
fun initialize(context: Context, engagementManager: MdocEngagementManager)
61+
62+
/**
63+
* Stops observing and cleans up resources.
64+
*/
65+
fun shutdown()
66+
}
67+
68+
/**
69+
* Implementation of BleEngagementLifecycleObserver.
70+
*/
71+
@Inject
72+
@SingleIn(AppScope::class)
73+
@ContributesBinding(AppScope::class)
74+
class BleEngagementLifecycleObserverImpl @Inject constructor(
75+
logManager: LogManager
76+
) : BleEngagementLifecycleObserver {
77+
78+
private val log = logManager.withTag("BleEngagementLifecycle")
79+
private var observerScope: CoroutineScope? = null
80+
private var isServiceRunning = false
81+
82+
override fun initialize(context: Context, engagementManager: MdocEngagementManager) {
83+
log.info("Initializing BLE engagement lifecycle observer")
84+
85+
// Cancel any existing scope
86+
observerScope?.cancel()
87+
88+
// Create new scope for observing
89+
observerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
90+
91+
// Observe active engagement - start service when engagement becomes active
92+
engagementManager.activeEngagement
93+
.onEach { engagement ->
94+
handleEngagementChange(context, engagement)
95+
}
96+
.launchIn(observerScope!!)
97+
98+
log.info("BLE engagement lifecycle observer initialized")
99+
}
100+
101+
override fun shutdown() {
102+
log.info("Shutting down BLE engagement lifecycle observer")
103+
observerScope?.cancel()
104+
observerScope = null
105+
}
106+
107+
private fun handleEngagementChange(context: Context, engagement: Any?) {
108+
if (engagement == null) {
109+
// No active engagement - stop service if running
110+
if (isServiceRunning) {
111+
log.info("No active engagement, stopping foreground service")
112+
BleEngagementService.stop(context)
113+
isServiceRunning = false
114+
}
115+
} else {
116+
// Active engagement detected - start service to keep BLE alive during screen lock
117+
// The service is needed for any engagement that might use BLE (QR with BLE retrieval)
118+
if (!isServiceRunning) {
119+
log.info("Engagement active, starting foreground service to maintain BLE")
120+
BleEngagementService.start(context)
121+
isServiceRunning = true
122+
}
123+
}
124+
}
125+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/*
2+
* © 2025 Sphereon International B.V.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
18+
package com.sphereon.kiwa.sample.app
19+
20+
import android.app.Notification
21+
import android.app.NotificationChannel
22+
import android.app.NotificationManager
23+
import android.app.PendingIntent
24+
import android.app.Service
25+
import android.content.Context
26+
import android.content.Intent
27+
import android.content.pm.ServiceInfo
28+
import android.os.Build
29+
import android.os.IBinder
30+
import androidx.core.app.NotificationCompat
31+
32+
/**
33+
* Foreground service to maintain BLE advertising and engagement when the screen is locked.
34+
*
35+
* This service is essential for keeping BLE operations alive when the device screen is off
36+
* or the app is in the background. Without this service running as a foreground service,
37+
* Android will suspend BLE advertising when the screen locks, causing connection failures.
38+
*
39+
* Key responsibilities:
40+
* - Maintain foreground service status to prevent Android from killing BLE operations
41+
* - Display persistent notification to indicate active BLE engagement
42+
* - Survive screen lock and app backgrounding
43+
* - Coordinate with engagement manager for BLE lifecycle
44+
*
45+
* Note: This service must be started with startForeground() within 5 seconds of creation
46+
* as per Android requirements for foreground services.
47+
*/
48+
class BleEngagementService : Service() {
49+
50+
companion object {
51+
private const val NOTIFICATION_ID = 2001
52+
private const val CHANNEL_ID = "ble_engagement_channel"
53+
private const val CHANNEL_NAME = "BLE Engagement"
54+
55+
/**
56+
* Starts the BLE engagement foreground service.
57+
*
58+
* @param context Application or activity context
59+
*/
60+
fun start(context: Context) {
61+
val intent = Intent(context, BleEngagementService::class.java)
62+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
63+
context.startForegroundService(intent)
64+
} else {
65+
context.startService(intent)
66+
}
67+
}
68+
69+
/**
70+
* Stops the BLE engagement foreground service.
71+
*
72+
* @param context Application or activity context
73+
*/
74+
fun stop(context: Context) {
75+
val intent = Intent(context, BleEngagementService::class.java)
76+
context.stopService(intent)
77+
}
78+
}
79+
80+
private val app: KiwaSampleApplication
81+
get() = application as KiwaSampleApplication
82+
83+
override fun onCreate() {
84+
super.onCreate()
85+
app.log.info("BleEngagementService: Service created")
86+
createNotificationChannel()
87+
}
88+
89+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
90+
app.log.info("BleEngagementService: onStartCommand called")
91+
92+
val notification = createNotification()
93+
94+
// Start foreground with appropriate service type for Android 14+
95+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
96+
startForeground(
97+
NOTIFICATION_ID,
98+
notification,
99+
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
100+
)
101+
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
102+
startForeground(
103+
NOTIFICATION_ID,
104+
notification,
105+
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
106+
)
107+
} else {
108+
startForeground(NOTIFICATION_ID, notification)
109+
}
110+
111+
app.log.info("BleEngagementService: Started as foreground service")
112+
113+
// Return START_STICKY to ensure service is restarted if killed by system
114+
return START_STICKY
115+
}
116+
117+
override fun onBind(intent: Intent?): IBinder? {
118+
// This service doesn't support binding
119+
return null
120+
}
121+
122+
override fun onDestroy() {
123+
app.log.info("BleEngagementService: Service destroyed")
124+
super.onDestroy()
125+
}
126+
127+
/**
128+
* Creates the notification channel for BLE engagement notifications.
129+
* Required for Android O and above.
130+
*/
131+
private fun createNotificationChannel() {
132+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
133+
val channel = NotificationChannel(
134+
CHANNEL_ID,
135+
CHANNEL_NAME,
136+
NotificationManager.IMPORTANCE_LOW
137+
).apply {
138+
description = "Keeps BLE engagement active"
139+
setShowBadge(false)
140+
}
141+
142+
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
143+
notificationManager.createNotificationChannel(channel)
144+
}
145+
}
146+
147+
/**
148+
* Creates the persistent notification shown while the service is running.
149+
*
150+
* @return Notification to be displayed
151+
*/
152+
private fun createNotification(): Notification {
153+
val notificationIntent = Intent(this, MainActivity::class.java)
154+
val pendingIntent = PendingIntent.getActivity(
155+
this,
156+
0,
157+
notificationIntent,
158+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
159+
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
160+
} else {
161+
PendingIntent.FLAG_UPDATE_CURRENT
162+
}
163+
)
164+
165+
return NotificationCompat.Builder(this, CHANNEL_ID)
166+
.setContentTitle("BLE Engagement Active")
167+
.setContentText("Ready to connect with nearby devices")
168+
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
169+
.setContentIntent(pendingIntent)
170+
.setOngoing(true)
171+
.setPriority(NotificationCompat.PRIORITY_LOW)
172+
.setCategory(NotificationCompat.CATEGORY_SERVICE)
173+
.build()
174+
}
175+
}

0 commit comments

Comments
 (0)