Commit current workspace state

This commit is contained in:
Mikael Hugo 2026-05-13 01:58:24 +02:00
parent d2c3c3b534
commit be4ffb4ede
5 changed files with 141 additions and 2 deletions

View file

@ -21,7 +21,7 @@ android {
versionName "1.25.0"
buildConfigField 'String', 'ONCALL_DEFAULT_SERVER_URL', '"https://oncall.hugo.dk"'
buildConfigField 'String', 'ONCALL_CONFIG_URL', '"https://oncall.hugo.dk/mobile/config"'
buildConfigField 'String', 'ONCALL_CONFIG_URL', '"https://ops.centralcloud.com/api/android/config"'
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.content.RestrictionsManager
import com.centralcloud.oncall.BuildConfig
import com.centralcloud.oncall.db.Repository
import com.centralcloud.oncall.sms.SmsRelayPreferences
import com.centralcloud.oncall.util.Log
/**
@ -57,5 +58,16 @@ object OnCallManagedConfig {
TAG,
"Managed OnCall bootstrap config available: configUrl=$configUrl, environment=$environment, enrollment=${enrollmentToken.isNotEmpty()}",
)
// Store the enrollment token as the device_id used for heartbeats and
// remote config auth. Only written once (on first MDM push or reinstall).
if (enrollmentToken.isNotEmpty()) {
val smsPrefs = SmsRelayPreferences(context)
if (smsPrefs.deviceId != enrollmentToken) {
smsPrefs.deviceId = enrollmentToken
smsPrefs.baseUrl = configUrl.substringBeforeLast("/api").trimEnd('/')
Log.i(TAG, "Stored enrollment token as device_id")
}
}
}
}

View file

@ -0,0 +1,94 @@
package com.centralcloud.oncall.sms
import android.content.Context
import android.util.Log
import com.centralcloud.oncall.BuildConfig
import com.centralcloud.oncall.util.HttpUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
/**
* Fetches the centralcloud-ops remote config for this device and applies
* any SMS-relay settings to [SmsRelayPreferences].
*
* Called once on app start (from MainActivity.onCreate). If the device has
* no device_id configured yet the fetch is skipped nothing to authenticate
* with. Config is also re-applied on every successful heartbeat cycle
* (DeviceHeartbeatWorker) so it stays fresh without an explicit call here.
*
* GET {opsUrl}/api/android/config
* X-Device-Id: <token>
*
* Response (relevant fields):
* {
* "ops_url": "https://ops.centralcloud.com",
* "sms_relay": {
* "enabled": true,
* "whitelist": ["+46701234567", "BANKID"]
* }
* }
*/
object RemoteConfigFetcher {
private const val TAG = "RemoteConfigFetcher"
/**
* Fetches remote config and applies it.
* Returns true if the fetch succeeded, false on any error.
* Must be called from a coroutine suspends on IO.
*/
suspend fun fetchAndApply(context: Context): Boolean = withContext(Dispatchers.IO) {
val prefs = SmsRelayPreferences(context)
val deviceId = prefs.deviceId
if (deviceId.isBlank()) {
Log.d(TAG, "skip: device_id not set yet")
return@withContext false
}
val baseUrl = prefs.baseUrl.ifBlank { BuildConfig.ONCALL_CONFIG_URL.substringBeforeLast("/api") }
val url = "${baseUrl.trimEnd('/')}/api/android/config"
return@withContext try {
val client = HttpUtil.defaultClient(context, baseUrl)
val request = HttpUtil.requestBuilder(url)
.addHeader("X-Device-Id", deviceId)
.get()
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Log.w(TAG, "config fetch failed: ${response.code}")
return@use false
}
val body = response.body?.string() ?: return@use false
apply(prefs, JSONObject(body))
true
}
} catch (t: Throwable) {
Log.w(TAG, "config fetch error", t)
false
}
}
private fun apply(prefs: SmsRelayPreferences, json: JSONObject) {
// Persist ops_url as the base URL for heartbeats and SMS forwarding.
json.optString("ops_url").takeIf { it.isNotBlank() }?.let { opsUrl ->
if (prefs.baseUrl != opsUrl) {
prefs.baseUrl = opsUrl
Log.d(TAG, "updated baseUrl=$opsUrl")
}
}
val smsRelay = json.optJSONObject("sms_relay") ?: return
val enabled = smsRelay.optBoolean("enabled", false)
val whitelist = smsRelay.optJSONArray("whitelist")
?.let { arr -> (0 until arr.length()).map { arr.getString(it) }.toSet() }
?: emptySet()
prefs.enabled = enabled
prefs.whitelist = whitelist
Log.d(TAG, "applied sms_relay: enabled=$enabled whitelist_size=${whitelist.size}")
}
}

View file

@ -60,7 +60,8 @@ import com.centralcloud.oncall.msg.NotificationDispatcher
import com.centralcloud.oncall.msg.Poller
import com.centralcloud.oncall.service.SubscriberService
import com.centralcloud.oncall.service.SubscriberServiceManager
import com.centralcloud.oncall.util.Log
import com.centralcloud.oncall.sms.RemoteConfigFetcher
import com.centralcloud.oncall.sms.SmsRelayPreferences
import com.centralcloud.oncall.util.SUBSCRIPTION_ICONS
import com.centralcloud.oncall.util.dangerButton
import com.centralcloud.oncall.util.displayName
@ -384,6 +385,13 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
// Permissions
maybeRequestNotificationPermission()
// Fetch remote config (SMS relay whitelist etc.) then ask for SMS
// permissions if the server says relay is enabled for this device.
lifecycleScope.launch {
RemoteConfigFetcher.fetchAndApply(applicationContext)
maybeRequestSmsPermissions()
}
// FIXME 2026-05-04: Remove this migration after 1 month
migrateSubscriptionIconsFromCache()
}
@ -397,6 +405,24 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
}
}
private fun maybeRequestSmsPermissions() {
val prefs = SmsRelayPreferences(this)
if (!prefs.enabled) return
val missing = listOf(Manifest.permission.RECEIVE_SMS, Manifest.permission.READ_SMS)
.filter { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_DENIED }
if (missing.isEmpty()) return
MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.sms_relay_permission_dialog_title))
.setMessage(getString(R.string.sms_relay_permission_dialog_message))
.setPositiveButton(getString(R.string.sms_relay_permission_dialog_grant)) { _, _ ->
ActivityCompat.requestPermissions(this, missing.toTypedArray(), REQUEST_CODE_SMS_PERMISSIONS)
}
.setNegativeButton(getString(R.string.sms_relay_permission_dialog_later), null)
.show()
}
override fun onResume() {
super.onResume()
showHideNotificationMenuItems()
@ -950,5 +976,6 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
const val POLL_WORKER_INTERVAL_MINUTES = 60L
const val DELETE_WORKER_INTERVAL_MINUTES = 8 * 60L
const val SERVICE_START_WORKER_INTERVAL_MINUTES = 3 * 60L
const val REQUEST_CODE_SMS_PERMISSIONS = 42
}
}

View file

@ -566,4 +566,10 @@
<string name="client_certificate_dialog_error_wrong_password">Wrong password or invalid PKCS#12 file</string>
<string name="client_certificate_dialog_error_invalid_p12_password">Invalid password or corrupted PKCS#12 file</string>
<string name="client_certificate_dialog_error_invalid_url">Invalid service URL</string>
<!-- SMS relay permission dialog -->
<string name="sms_relay_permission_dialog_title">SMS monitoring enabled</string>
<string name="sms_relay_permission_dialog_message">This device is configured to forward SMS messages from monitored numbers to the on-call system. Grant SMS access to enable this feature.</string>
<string name="sms_relay_permission_dialog_grant">Grant access</string>
<string name="sms_relay_permission_dialog_later">Later</string>
</resources>