Skip to content

Commit 75e2226

Browse files
Run local server from foreground notification with proper information and notification permission (#141)
* Initial plan * Add LocalServerService and notification permission support Co-authored-by: yogeshpaliyal <[email protected]> * Fix LocalServerService to start foreground immediately Co-authored-by: yogeshpaliyal <[email protected]> * Improve notification permission flow with pending state Co-authored-by: yogeshpaliyal <[email protected]> * Fix trailing whitespace in LocalServerService Co-authored-by: yogeshpaliyal <[email protected]> * fix: streamline notification permission handling and add running status message --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: yogeshpaliyal <[email protected]>
1 parent c74998a commit 75e2226

File tree

5 files changed

+212
-24
lines changed

5 files changed

+212
-24
lines changed

app/src/main/AndroidManifest.xml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
<uses-permission android:name="android.permission.INTERNET" />
2121
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
2222
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
23+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
24+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
25+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
2326

2427
<application
2528
android:name=".DeeprApplication"
@@ -53,6 +56,16 @@
5356
android:name="com.journeyapps.barcodescanner.CaptureActivity"
5457
android:screenOrientation="portrait"
5558
tools:replace="screenOrientation" />
59+
60+
<service
61+
android:name=".server.LocalServerService"
62+
android:enabled="true"
63+
android:exported="false"
64+
android:foregroundServiceType="specialUse">
65+
<property
66+
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
67+
android:value="Local network server for managing deeplinks" />
68+
</service>
5669
</application>
5770

5871
</manifest>
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package com.yogeshpaliyal.deepr.server
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.Context
9+
import android.content.Intent
10+
import android.os.Build
11+
import android.os.IBinder
12+
import androidx.core.app.NotificationCompat
13+
import com.yogeshpaliyal.deepr.MainActivity
14+
import com.yogeshpaliyal.deepr.R
15+
import kotlinx.coroutines.CoroutineScope
16+
import kotlinx.coroutines.Dispatchers
17+
import kotlinx.coroutines.SupervisorJob
18+
import kotlinx.coroutines.cancel
19+
import kotlinx.coroutines.flow.first
20+
import kotlinx.coroutines.launch
21+
import org.koin.android.ext.android.inject
22+
23+
class LocalServerService : Service() {
24+
private val localServerRepository: LocalServerRepository by inject()
25+
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
26+
27+
override fun onCreate() {
28+
super.onCreate()
29+
createNotificationChannel()
30+
}
31+
32+
override fun onStartCommand(
33+
intent: Intent?,
34+
flags: Int,
35+
startId: Int,
36+
): Int {
37+
when (intent?.action) {
38+
ACTION_START -> {
39+
// Start foreground immediately to avoid ANR
40+
startForeground(NOTIFICATION_ID, createNotification(null))
41+
serviceScope.launch {
42+
localServerRepository.startServer()
43+
observeServerState()
44+
}
45+
}
46+
ACTION_STOP -> {
47+
serviceScope.launch {
48+
localServerRepository.stopServer()
49+
stopForeground(STOP_FOREGROUND_REMOVE)
50+
stopSelf()
51+
}
52+
}
53+
}
54+
return START_STICKY
55+
}
56+
57+
private suspend fun observeServerState() {
58+
serviceScope.launch {
59+
localServerRepository.isRunning.collect { isRunning ->
60+
if (isRunning) {
61+
val serverUrl = localServerRepository.serverUrl.first()
62+
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
63+
notificationManager.notify(NOTIFICATION_ID, createNotification(serverUrl))
64+
}
65+
}
66+
}
67+
}
68+
69+
private fun createNotificationChannel() {
70+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
71+
val channel =
72+
NotificationChannel(
73+
CHANNEL_ID,
74+
getString(R.string.local_server_notification_channel_name),
75+
NotificationManager.IMPORTANCE_LOW,
76+
).apply {
77+
description = getString(R.string.local_server_notification_channel_description)
78+
setShowBadge(false)
79+
}
80+
81+
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
82+
notificationManager.createNotificationChannel(channel)
83+
}
84+
}
85+
86+
private fun createNotification(serverUrl: String?): Notification {
87+
val notificationIntent = Intent(this, MainActivity::class.java)
88+
val pendingIntent =
89+
PendingIntent.getActivity(
90+
this,
91+
0,
92+
notificationIntent,
93+
PendingIntent.FLAG_IMMUTABLE,
94+
)
95+
96+
val stopIntent =
97+
Intent(this, LocalServerService::class.java).apply {
98+
action = ACTION_STOP
99+
}
100+
val stopPendingIntent =
101+
PendingIntent.getService(
102+
this,
103+
1,
104+
stopIntent,
105+
PendingIntent.FLAG_IMMUTABLE,
106+
)
107+
108+
return NotificationCompat
109+
.Builder(this, CHANNEL_ID)
110+
.setContentTitle(getString(R.string.local_server_running))
111+
.setContentText(
112+
if (serverUrl != null) {
113+
getString(R.string.local_server_notification_text, serverUrl)
114+
} else {
115+
getString(R.string.local_server_starting)
116+
},
117+
).setSmallIcon(R.drawable.ic_launcher_foreground)
118+
.setContentIntent(pendingIntent)
119+
.addAction(
120+
0,
121+
getString(R.string.stop),
122+
stopPendingIntent,
123+
).setOngoing(true)
124+
.build()
125+
}
126+
127+
override fun onBind(intent: Intent?): IBinder? = null
128+
129+
override fun onDestroy() {
130+
super.onDestroy()
131+
serviceScope.cancel()
132+
}
133+
134+
companion object {
135+
private const val CHANNEL_ID = "local_server_channel"
136+
private const val NOTIFICATION_ID = 1001
137+
const val ACTION_START = "com.yogeshpaliyal.deepr.ACTION_START_SERVER"
138+
const val ACTION_STOP = "com.yogeshpaliyal.deepr.ACTION_STOP_SERVER"
139+
140+
fun startService(context: Context) {
141+
val intent =
142+
Intent(context, LocalServerService::class.java).apply {
143+
action = ACTION_START
144+
}
145+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
146+
context.startForegroundService(intent)
147+
} else {
148+
context.startService(intent)
149+
}
150+
}
151+
152+
fun stopService(context: Context) {
153+
val intent =
154+
Intent(context, LocalServerService::class.java).apply {
155+
action = ACTION_STOP
156+
}
157+
context.startService(intent)
158+
}
159+
}
160+
}

app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/LocalNetworkServer.kt

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package com.yogeshpaliyal.deepr.ui.screens
22

3+
import android.Manifest
34
import android.content.ClipData
45
import android.content.ClipboardManager
56
import android.content.Context
7+
import android.os.Build
68
import android.widget.Toast
79
import androidx.compose.foundation.background
810
import androidx.compose.foundation.layout.Arrangement
@@ -44,8 +46,12 @@ import androidx.compose.ui.unit.LayoutDirection
4446
import androidx.compose.ui.unit.dp
4547
import androidx.compose.ui.unit.sp
4648
import androidx.lifecycle.compose.collectAsStateWithLifecycle
49+
import com.google.accompanist.permissions.ExperimentalPermissionsApi
50+
import com.google.accompanist.permissions.isGranted
51+
import com.google.accompanist.permissions.rememberPermissionState
4752
import com.lightspark.composeqr.QrCodeView
4853
import com.yogeshpaliyal.deepr.R
54+
import com.yogeshpaliyal.deepr.server.LocalServerService
4955
import com.yogeshpaliyal.deepr.viewmodel.LocalServerViewModel
5056
import compose.icons.TablerIcons
5157
import compose.icons.tablericons.ArrowLeft
@@ -55,7 +61,7 @@ import org.koin.androidx.compose.koinViewModel
5561

5662
data object LocalNetworkServer
5763

58-
@OptIn(ExperimentalMaterial3Api::class)
64+
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
5965
@Composable
6066
fun LocalNetworkServerScreen(
6167
backStack: SnapshotStateList<Any>,
@@ -67,6 +73,22 @@ fun LocalNetworkServerScreen(
6773
val serverUrl by viewModel.serverUrl.collectAsStateWithLifecycle()
6874
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
6975

76+
// Track if user wants to start the server (used for permission flow)
77+
val pendingStart = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
78+
79+
// Request notification permission for Android 13+
80+
val notificationPermissionState =
81+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
82+
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) {
83+
if (pendingStart.value) {
84+
pendingStart.value = false
85+
LocalServerService.startService(context)
86+
}
87+
}
88+
} else {
89+
null
90+
}
91+
7092
Scaffold(
7193
modifier = modifier.fillMaxSize(),
7294
topBar = {
@@ -138,9 +160,17 @@ fun LocalNetworkServerScreen(
138160
checked = isRunning,
139161
onCheckedChange = {
140162
if (it) {
141-
viewModel.startServer()
163+
// Check if notification permission is required and granted
164+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
165+
notificationPermissionState?.status?.isGranted == false
166+
) {
167+
pendingStart.value = true
168+
notificationPermissionState.launchPermissionRequest()
169+
} else {
170+
LocalServerService.startService(context)
171+
}
142172
} else {
143-
viewModel.stopServer()
173+
LocalServerService.stopService(context)
144174
}
145175
},
146176
)
Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,11 @@
11
package com.yogeshpaliyal.deepr.viewmodel
22

33
import androidx.lifecycle.ViewModel
4-
import androidx.lifecycle.viewModelScope
54
import com.yogeshpaliyal.deepr.server.LocalServerRepository
6-
import kotlinx.coroutines.launch
75

86
class LocalServerViewModel(
97
private val localServerRepository: LocalServerRepository,
108
) : ViewModel() {
119
val isRunning = localServerRepository.isRunning
1210
val serverUrl = localServerRepository.serverUrl
13-
14-
fun startServer() {
15-
viewModelScope.launch {
16-
localServerRepository.startServer()
17-
}
18-
}
19-
20-
fun stopServer() {
21-
viewModelScope.launch {
22-
localServerRepository.stopServer()
23-
}
24-
}
25-
26-
override fun onCleared() {
27-
super.onCleared()
28-
viewModelScope.launch {
29-
localServerRepository.stopServer()
30-
}
31-
}
3211
}

app/src/main/res/values/strings.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@
138138

139139
<!-- Local Network Server -->
140140
<string name="local_network_server">Local Network Server</string>
141+
<string name="local_server_running">Local Server Running</string>
141142
<string name="server_status">Server Status</string>
142143
<string name="server_running">Server is running and accessible on your local network</string>
143144
<string name="server_running_tap_to_configure">Server Running - Tap to configure</string>
@@ -153,4 +154,9 @@
153154
<string name="api_get_links">Get all saved links</string>
154155
<string name="api_add_link">Add a new link (JSON: {"link": "url", "name": "name"})</string>
155156
<string name="api_get_link_info">Get metadata for a URL</string>
157+
<string name="local_server_notification_channel_name">Local Server</string>
158+
<string name="local_server_notification_channel_description">Notifications for local server status</string>
159+
<string name="local_server_notification_text">Server URL: %s</string>
160+
<string name="local_server_starting">Starting server…</string>
161+
<string name="stop">Stop</string>
156162
</resources>

0 commit comments

Comments
 (0)