Skip to content

Commit 5e9ee11

Browse files
committed
chore: fixes for peripheral server
1 parent 8e00789 commit 5e9ee11

10 files changed

Lines changed: 476 additions & 13 deletions

File tree

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@
5050
tools:targetApi="s"
5151
/>
5252

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

5461
<!-- "If you say the feature is required for your app, then the Google Play store will hide your
5562
app from users on devices lacking those features. For this reason, you should only set the
@@ -94,7 +101,8 @@
94101
android:name=".MdocNfcService"
95102
android:exported="true"
96103
android:label="@string/nfc_engagement_service_desc"
97-
android:permission="android.permission.BIND_NFC_SERVICE">
104+
android:permission="android.permission.BIND_NFC_SERVICE"
105+
android:foregroundServiceType="connectedDevice">
98106
<intent-filter>
99107
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE"/>
100108
</intent-filter>
@@ -104,6 +112,11 @@
104112
android:resource="@xml/nfc_engagement_apdu_service"/>
105113
</service>
106114

115+
<service
116+
android:name=".BleEngagementService"
117+
android:enabled="true"
118+
android:exported="false"
119+
android:foregroundServiceType="connectedDevice"/>
107120

108121
</application>
109122

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)