Skip to content

Commit 4028698

Browse files
author
SessionHero01
committed
Better APIs
1 parent 36b8ed2 commit 4028698

File tree

7 files changed

+193
-54
lines changed

7 files changed

+193
-54
lines changed

android_lib/demoapp/src/main/java/dev/fanchao/demoapp/MainActivity.kt

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
88
import androidx.sqlite.db.SupportSQLiteOpenHelper
99
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
1010
import dev.fanchao.demoapp.databinding.ActivityMainBinding
11+
import dev.fanchao.sqliteviewer.StartedInstance
1112
import dev.fanchao.sqliteviewer.model.SupportQueryable
1213
import dev.fanchao.sqliteviewer.startDatabaseViewerServer
13-
import kotlinx.coroutines.GlobalScope
14-
import kotlinx.coroutines.Job
1514
import kotlin.getValue
1615

1716
class MainActivity : ComponentActivity() {
@@ -45,25 +44,22 @@ class MainActivity : ComponentActivity() {
4544
val binding = ActivityMainBinding.inflate(LayoutInflater.from(this))
4645
setContentView(binding.root)
4746

48-
var job: Job? = null
47+
var instance: StartedInstance? = null
4948

5049
binding.start.setOnClickListener {
51-
job = startDatabaseViewerServer(
50+
instance = startDatabaseViewerServer(
5251
context = this,
53-
scope = GlobalScope,
5452
port = 3000,
5553
queryable = SupportQueryable(factory.writableDatabase)
5654
)
5755

58-
if (job != null) {
59-
binding.start.isEnabled = false
60-
binding.stop.isEnabled = true
61-
}
56+
binding.start.isEnabled = false
57+
binding.stop.isEnabled = true
6258
}
6359

6460
binding.stop.setOnClickListener {
65-
job?.cancel()
66-
job = null
61+
instance?.stop()
62+
instance = null
6763

6864
binding.start.isEnabled = true
6965
binding.stop.isEnabled = false

android_lib/library/src/androidMain/AndroidManifest.xml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@
66
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
77

88
<application>
9-
<service android:name="dev.fanchao.sqliteviewer.DatabaseViewerService"
10-
android:foregroundServiceType="specialUse"
11-
android:exported="false" />
9+
<service
10+
android:name="dev.fanchao.sqliteviewer.DatabaseViewerService"
11+
android:exported="false"
12+
android:foregroundServiceType="specialUse" />
1213
<activity android:name="dev.fanchao.sqliteviewer.PermissionRequestActivity" />
14+
<receiver
15+
android:name="dev.fanchao.sqliteviewer.CopyTextReceiver"
16+
android:exported="false" />
1317
</application>
1418

1519
</manifest>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package dev.fanchao.sqliteviewer
2+
3+
import android.content.BroadcastReceiver
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.widget.Toast
7+
8+
class CopyTextReceiver : BroadcastReceiver() {
9+
override fun onReceive(context: Context, intent: Intent) {
10+
val textToCopy = intent.getStringExtra(EXTRA_TEXT) ?: return
11+
12+
// Use the ClipboardManager to copy the text
13+
val clipboard =
14+
context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
15+
val clip = android.content.ClipData.newPlainText("Copied Text", textToCopy)
16+
clipboard.setPrimaryClip(clip)
17+
18+
Toast.makeText(context, "\"$textToCopy\" copied", Toast.LENGTH_SHORT).show()
19+
}
20+
21+
companion object {
22+
const val EXTRA_TEXT = "text_to_copy"
23+
}
24+
}

android_lib/library/src/androidMain/kotlin/dev/fanchao/sqliteviewer/DatabaseViewerServer.android.kt

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,39 +7,63 @@ import android.content.Intent
77
import android.content.IntentFilter
88
import android.os.Build
99
import dev.fanchao.sqliteviewer.model.Queryable
10-
import kotlinx.coroutines.CoroutineScope
11-
import kotlinx.coroutines.Job
10+
import kotlinx.coroutines.GlobalScope
11+
import kotlinx.coroutines.flow.takeWhile
12+
import kotlinx.coroutines.flow.transformWhile
1213
import kotlinx.coroutines.launch
1314
import kotlinx.coroutines.suspendCancellableCoroutine
1415
import java.lang.ref.WeakReference
1516
import kotlin.coroutines.resume
1617

1718
fun startDatabaseViewerServer(
1819
context: Activity,
19-
scope: CoroutineScope,
2020
port: Int,
2121
queryable: Queryable,
22-
): Job? {
22+
): StartedInstance {
2323
val contextRef = WeakReference(context)
2424
val applicationContext = context.applicationContext
2525

26-
return scope.launch {
27-
val (job, actualPort) = startDatabaseViewerServerShared(
28-
port = port,
29-
queryable = queryable,
30-
assetProvider = AndroidAssetProvider(applicationContext),
31-
)
26+
val instance = startDatabaseViewerServerShared(
27+
port = port,
28+
queryable = queryable,
29+
assetProvider = AndroidAssetProvider(applicationContext),
30+
)
3231

33-
val (intent, broadcastAction) = DatabaseViewerService.createStartIntent(applicationContext, port)
34-
contextRef.get()?.startService(intent)
32+
GlobalScope.launch {
33+
instance.state
34+
.transformWhile { state ->
35+
emit(state)
36+
state !is StartedInstance.State.Stopped
37+
}
38+
.collect { state ->
39+
when (state) {
40+
StartedInstance.State.Starting -> {}
3541

36-
try {
37-
waitForBroadcast(applicationContext, broadcastAction)
38-
} finally {
39-
applicationContext.startService(DatabaseViewerService.createStopIntent(applicationContext, actualPort))
40-
job.cancel()
41-
}
42+
is StartedInstance.State.Running -> {
43+
val (intent, broadcastAction) = DatabaseViewerService.createStartIntent(applicationContext, port)
44+
contextRef.get()?.startService(intent)
45+
46+
launch {
47+
waitForBroadcast(applicationContext, broadcastAction)
48+
instance.stop()
49+
}
50+
}
51+
52+
is StartedInstance.State.Stopped -> {
53+
if (state.port != null) {
54+
applicationContext.startService(
55+
DatabaseViewerService.createStopIntent(
56+
context = applicationContext,
57+
port = state.port
58+
)
59+
)
60+
}
61+
}
62+
}
63+
}
4264
}
65+
66+
return instance
4367
}
4468

4569
private suspend fun waitForBroadcast(

android_lib/library/src/androidMain/kotlin/dev/fanchao/sqliteviewer/DatabaseViewerService.kt

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ import android.app.PendingIntent
55
import android.app.Service
66
import android.content.Context
77
import android.content.Intent
8+
import android.os.Build
89
import android.os.IBinder
910
import androidx.core.app.NotificationChannelCompat
1011
import androidx.core.app.NotificationCompat
1112
import androidx.core.app.NotificationManagerCompat
13+
import java.net.Inet4Address
14+
import java.net.NetworkInterface
1215

1316
internal typealias BroadcastAction = String
1417

@@ -27,21 +30,61 @@ internal class DatabaseViewerService : Service() {
2730

2831
NotificationManagerCompat.from(this).createNotificationChannel(channel)
2932

33+
val ipAddresses = NetworkInterface.getNetworkInterfaces()
34+
.asSequence()
35+
.flatMap { it.inetAddresses.asSequence() }
36+
.filter { !it.isLoopbackAddress && !it.isMulticastAddress }
37+
.filterIsInstance<Inet4Address>()
38+
.toSet()
39+
3040
for (port in startedPorts) {
31-
startForeground(port, NotificationCompat.Builder(this, channel.id)
32-
.setContentTitle("Server running")
33-
.setContentText("Listening on port $port")
41+
val listeningOn = ipAddresses.map { "${it.hostAddress}:$port" } + "localhost:$port"
42+
val listeningOnText = listeningOn.joinToString(separator = ", ")
43+
44+
val builder = NotificationCompat.Builder(this, channel.id)
45+
.setOngoing(true)
46+
.setContentTitle("SQLite Viewer Service running")
47+
.setContentText("Listening on $listeningOnText")
3448
// Set small icon to the package's launcher icon
3549
.setSmallIcon(R.drawable.outline_architecture)
36-
.addAction(R.drawable.ic_stop, "Stop", PendingIntent.getBroadcast(
37-
this,
38-
0,
39-
Intent("${STOP_BROADCAST_ACTION_PREFIX}$port").apply {
40-
`package` = this@DatabaseViewerService.packageName
41-
},
42-
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
43-
))
44-
.build())
50+
.addAction(
51+
R.drawable.ic_stop, "Stop", PendingIntent.getBroadcast(
52+
this,
53+
0,
54+
Intent("${STOP_BROADCAST_ACTION_PREFIX}$port").apply {
55+
`package` = this@DatabaseViewerService.packageName
56+
},
57+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
58+
)
59+
)
60+
.addAction(
61+
R.drawable.baseline_article_24, "Copy adb command", PendingIntent.getBroadcast(
62+
this,
63+
1,
64+
Intent(this, CopyTextReceiver::class.java)
65+
.putExtra(
66+
CopyTextReceiver.EXTRA_TEXT,
67+
"adb forward tcp:$port tcp:$port"
68+
),
69+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
70+
71+
)
72+
)
73+
74+
for ((index, address) in listeningOn.withIndex()) {
75+
builder.addAction(
76+
R.drawable.baseline_article_24, "Copy \"$address\"",
77+
PendingIntent.getBroadcast(
78+
this,
79+
index + 2,
80+
Intent(this, CopyTextReceiver::class.java)
81+
.putExtra(CopyTextReceiver.EXTRA_TEXT, address),
82+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
83+
)
84+
)
85+
}
86+
87+
startForeground(port, builder.build())
4588
}
4689
}
4790

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
2+
3+
<path android:fillColor="@android:color/white" android:pathData="M19,3L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM14,17L7,17v-2h7v2zM17,13L7,13v-2h10v2zM17,9L7,9L7,7h10v2z"/>
4+
5+
</vector>

android_lib/library/src/commonMain/kotlin/dev/fanchao/sqliteviewer/DatabaseViewerServer.kt

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import io.ktor.serialization.kotlinx.json.json
1010
import io.ktor.server.application.Application
1111
import io.ktor.server.application.install
1212
import io.ktor.server.cio.CIO
13+
import io.ktor.server.engine.EmbeddedServer
1314
import io.ktor.server.engine.embeddedServer
1415
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
1516
import io.ktor.server.request.path
@@ -19,33 +20,75 @@ import io.ktor.server.response.respondSource
1920
import io.ktor.server.routing.get
2021
import io.ktor.server.routing.post
2122
import io.ktor.server.routing.routing
23+
import kotlinx.coroutines.CancellationException
24+
import kotlinx.coroutines.DelicateCoroutinesApi
2225
import kotlinx.coroutines.GlobalScope
23-
import kotlinx.coroutines.Job
26+
import kotlinx.coroutines.coroutineScope
27+
import kotlinx.coroutines.flow.SharingStarted
28+
import kotlinx.coroutines.flow.StateFlow
29+
import kotlinx.coroutines.flow.flow
30+
import kotlinx.coroutines.flow.stateIn
2431
import kotlinx.coroutines.launch
32+
import kotlin.concurrent.atomics.AtomicBoolean
33+
import kotlin.concurrent.atomics.ExperimentalAtomicApi
34+
35+
@OptIn(ExperimentalAtomicApi::class, DelicateCoroutinesApi::class)
36+
class StartedInstance (
37+
private val server: EmbeddedServer<*, *>
38+
) {
39+
sealed interface State {
40+
data object Starting : State
41+
data class Running(val port: Int) : State
42+
data class Stopped(val port: Int?) : State
43+
}
44+
45+
private val stopRequested = AtomicBoolean(false)
46+
47+
val state: StateFlow<State> = flow {
48+
var port: Int? = null
49+
try {
50+
coroutineScope {
51+
val startJob = launch {
52+
server.startSuspend(wait = true)
53+
}
54+
55+
port = server.engine.resolvedConnectors().first().port
56+
emit(State.Running(port))
57+
startJob.join() // Wait for the server to finish starting
58+
}
59+
} catch (e: Throwable) {
60+
if (e is CancellationException) throw e
61+
} finally {
62+
emit(State.Stopped(port))
63+
}
64+
}.stateIn(GlobalScope, started = SharingStarted.Eagerly, initialValue = State.Starting)
65+
66+
fun stop() {
67+
if (stopRequested.compareAndSet(expectedValue = false, newValue = true)) {
68+
GlobalScope.launch {
69+
server.stopSuspend()
70+
}
71+
}
72+
}
73+
}
2574

2675
/**
2776
* Launches the Ktor server in a new Job, returns Pair<Job, Deferred<Int>>.
2877
* Job is the server handle (cancel to stop), Deferred<Int> completes with the bound port.
2978
*/
30-
suspend fun startDatabaseViewerServerShared(
79+
fun startDatabaseViewerServerShared(
3180
port: Int,
3281
queryable: Queryable,
3382
assetProvider: StaticAssetProvider,
34-
): Pair<Job, Int> {
83+
): StartedInstance {
3584
val server = embeddedServer(
3685
factory = CIO,
3786
port = port,
3887
) {
3988
configureDatabaseViewerRouting(queryable, assetProvider)
4089
}
4190

42-
val job = GlobalScope.launch {
43-
runCatching { server.startSuspend(wait = true) }
44-
server.stop(1000, 1000)
45-
}
46-
47-
val port = server.engine.resolvedConnectors().first().port
48-
return job to port
91+
return StartedInstance(server)
4992
}
5093

5194
private fun Application.configureDatabaseViewerRouting(queryable: Queryable, assetProvider: StaticAssetProvider) {

0 commit comments

Comments
 (0)