Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions composeApp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />

<application
android:name=".AndroidApplication"
Expand All @@ -13,15 +16,20 @@
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:exported="true"
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|mnc|colorMode|density|fontScale|fontWeightAdjustment|keyboard|layoutDirection|locale|mcc|navigation|smallestScreenSize|touchscreen|uiMode"
android:name=".MainActivity">
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<service
android:name=".background.RunService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import org.ooni.engine.AndroidNetworkTypeFinder
import org.ooni.engine.AndroidOonimkallBridge
import org.ooni.probe.background.RunService
import org.ooni.probe.di.Dependencies
import org.ooni.probe.shared.Platform
import org.ooni.probe.shared.PlatformInfo
Expand All @@ -37,6 +38,7 @@ class AndroidApplication : Application() {
buildDataStore = ::buildDataStore,
isBatteryCharging = ::checkBatteryCharging,
launchUrl = ::launchUrl,
startBackgroundRunInner = { RunService.start(this, it) },
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
package org.ooni.probe.background

import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.compose.ui.graphics.toArgb
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.encodeToString
import ooniprobe.composeapp.generated.resources.Dashboard_Running_Running
import ooniprobe.composeapp.generated.resources.Notification_StopTest
import ooniprobe.composeapp.generated.resources.Res
import ooniprobe.composeapp.generated.resources.notification_channel_name
import org.jetbrains.compose.resources.StringResource
import org.ooni.probe.AndroidApplication
import org.ooni.probe.MainActivity
import org.ooni.probe.R
import org.ooni.probe.data.models.RunSpecification
import org.ooni.probe.data.models.TestRunState
import org.ooni.probe.di.Dependencies
import org.ooni.probe.ui.primaryLight
import kotlin.math.roundToInt

class RunService : Service() {
private val dependencies by lazy { (application as AndroidApplication).dependencies }
private val backgroundDispatcher by lazy { dependencies.backgroundDispatcher }
private val json by lazy { dependencies.json }
private val runDescriptors by lazy { dependencies.runDescriptors }
private val getCurrentTestState by lazy { dependencies.getCurrentTestState }
private val cancelCurrentTest by lazy { dependencies.cancelCurrentTest }
private val notificationManager by lazy { getSystemService(NotificationManager::class.java) }

private val stopRunReceiver by lazy { StopRunReceiver() }

override fun onCreate() {
super.onCreate()
val intentFilter = IntentFilter(ACTION_STOP_RUN)
ContextCompat.registerReceiver(
this,
stopRunReceiver,
intentFilter,
ContextCompat.RECEIVER_NOT_EXPORTED,
)
}

override fun onDestroy() {
super.onDestroy()
unregisterReceiver(stopRunReceiver)
}

override fun onBind(intent: Intent?) = null

override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
val specJson = intent?.getStringExtra(EXTRA_SPEC) ?: run {
Logger.w("Could not start RunService: spec missing")
return START_NOT_STICKY
}

val spec = try {
json.decodeFromString<RunSpecification>(specJson)
} catch (e: Exception) {
Logger.w("Could not start RunService: invalid spec", e)
return START_NOT_STICKY
}

runBlocking {
if (!startForeground()) {
stopSelf()
return@runBlocking
}

// Start the actual run
CoroutineScope(backgroundDispatcher).launch {
runDescriptors(spec)
}

// Observe the run state to update the notifications and stop the service
var testStarted = false
getCurrentTestState()
.onEach { testState ->
when (testState) {
is TestRunState.Idle -> {
if (testStarted) stopSelf()
}

is TestRunState.Running -> {
testStarted = true
notificationManager.notify(
NOTIFICATION_ID,
buildNotification(testState),
)
}

TestRunState.Stopping -> {
stopSelf()
}
}
}
.launchIn(CoroutineScope(backgroundDispatcher))
}

return START_NOT_STICKY
}

private suspend fun startForeground(): Boolean {
return try {
buildNotificationChannelIfNeeded()
val notification = buildNotification(TestRunState.Running())

ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
notification,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else {
0
},
)
true
} catch (e: Exception) {
Logger.w("Could not start foreground RunService", e)
false
}
}

private suspend fun buildNotificationChannelIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.createNotificationChannel(
NotificationChannel(
NOTIFICATION_CHANNEL_ID,
getString(Res.string.notification_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
),
)
}
}

private suspend fun buildNotification(state: TestRunState.Running): Notification {
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.notification_icon)
.setContentTitle(getString(Res.string.Dashboard_Running_Running))
.setContentText(state.testType?.labelRes?.let { getString(it) })
.setColor(state.descriptor?.color?.toArgb() ?: primaryLight.toArgb())
.setProgress(1000, (state.testProgress * 1000).roundToInt(), false)
.setAutoCancel(false)
.setContentIntent(openAppIntent)
.addAction(
NotificationCompat.Action.Builder(
null,
getString(Res.string.Notification_StopTest),
stopRunIntent,
).build(),
)
.build()
}

private val openAppIntent
get() = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
PendingIntent.FLAG_IMMUTABLE,
)

private val stopRunIntent
get() = PendingIntent.getBroadcast(
this,
1,
Intent(ACTION_STOP_RUN),
PendingIntent.FLAG_IMMUTABLE,
)

private suspend fun getString(stringResource: StringResource) = org.jetbrains.compose.resources.getString(stringResource)

private inner class StopRunReceiver : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent,
) {
if (intent.action != ACTION_STOP_RUN) return
cancelCurrentTest()
}
}

companion object {
private const val NOTIFICATION_CHANNEL_ID = "RUN"
private const val NOTIFICATION_ID = 1
private const val EXTRA_SPEC = "spec"
private const val ACTION_STOP_RUN = "stop_run"

fun start(
context: Context,
spec: RunSpecification,
) {
ContextCompat.startForegroundService(
context,
Intent(context, RunService::class.java)
.putExtra(EXTRA_SPEC, Dependencies.buildJson().encodeToString(spec)),
)
}
}
}
11 changes: 11 additions & 0 deletions composeApp/src/androidMain/res/drawable/notification_icon.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">

<path
android:fillColor="#FF000000"
android:pathData="M15,1H9v2h6V1zM11,14h2V8h-2V14zM19.03,7.39l1.42,-1.42c-0.43,-0.51 -0.9,-0.99 -1.41,-1.41l-1.42,1.42C16.07,4.74 14.12,4 12,4c-4.97,0 -9,4.03 -9,9s4.02,9 9,9s9,-4.03 9,-9C21,10.88 20.26,8.93 19.03,7.39zM12,20c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7s7,3.13 7,7S15.87,20 12,20z" />

</vector>
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,5 @@
<string name="hours_abbreviated">%1$dh</string>
<string name="minutes_abbreviated">%1$dm</string>
<string name="seconds_abbreviated">%1$ds</string>
<string name="notification_channel_name">Testing</string>
</resources>
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
package org.ooni.engine.models

import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import ooniprobe.composeapp.generated.resources.Res
import ooniprobe.composeapp.generated.resources.Test_Dash_Fullname
import ooniprobe.composeapp.generated.resources.Test_Experimental_Fullname
Expand All @@ -26,6 +33,7 @@ import org.jetbrains.compose.resources.StringResource
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

@Serializable(with = TestTypeSerializer::class)
sealed class TestType {
abstract val name: String
abstract val labelRes: StringResource
Expand Down Expand Up @@ -159,3 +167,17 @@ sealed class TestType {
fun fromName(name: String) = ALL_NAMED.firstOrNull { it.name == name } ?: Experimental(name)
}
}

object TestTypeSerializer : KSerializer<TestType> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("org.ooni.engine.models.TestType", PrimitiveKind.STRING)

override fun deserialize(decoder: Decoder): TestType = TestType.fromName(decoder.decodeString())

override fun serialize(
encoder: Encoder,
value: TestType,
) {
encoder.encodeString(value.name)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.ooni.probe.data.models

import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.Serializable
import org.ooni.probe.shared.now

data class InstalledTestDescriptorModel(
Expand All @@ -22,6 +23,7 @@ data class InstalledTestDescriptorModel(
val revision: String?,
val autoUpdate: Boolean,
) {
@Serializable
data class Id(
val value: Long,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package org.ooni.probe.data.models

import kotlinx.serialization.Serializable
import org.ooni.engine.models.OONINetTest
import org.ooni.engine.models.TestType

@Serializable
data class NetTest(
val test: TestType,
val inputs: List<String>? = emptyList(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
package org.ooni.probe.data.models

import kotlinx.serialization.Serializable
import org.ooni.engine.models.TaskOrigin

@Serializable
data class RunSpecification(
val tests: List<Test>,
val taskOrigin: TaskOrigin,
val isRerun: Boolean,
) {
@Serializable
data class Test(
val source: Source,
val netTests: List<NetTest>,
) {
@Serializable
sealed interface Source {
@Serializable
data class Default(val name: String) : Source

@Serializable
data class Installed(val id: InstalledTestDescriptorModel.Id) : Source
}
}
Expand Down
Loading