diff --git a/app/build.gradle b/app/build.gradle index db32ddba..6ce90c3b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" diff --git a/app/src/main/java/com/centralcloud/oncall/app/OnCallManagedConfig.kt b/app/src/main/java/com/centralcloud/oncall/app/OnCallManagedConfig.kt index 8aa9af06..1cf33ead 100644 --- a/app/src/main/java/com/centralcloud/oncall/app/OnCallManagedConfig.kt +++ b/app/src/main/java/com/centralcloud/oncall/app/OnCallManagedConfig.kt @@ -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") + } + } } } diff --git a/app/src/main/java/com/centralcloud/oncall/sms/RemoteConfigFetcher.kt b/app/src/main/java/com/centralcloud/oncall/sms/RemoteConfigFetcher.kt new file mode 100644 index 00000000..c9fb77fb --- /dev/null +++ b/app/src/main/java/com/centralcloud/oncall/sms/RemoteConfigFetcher.kt @@ -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: + * + * 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}") + } +} diff --git a/app/src/main/java/com/centralcloud/oncall/ui/MainActivity.kt b/app/src/main/java/com/centralcloud/oncall/ui/MainActivity.kt index 1a102d68..d99e52e9 100644 --- a/app/src/main/java/com/centralcloud/oncall/ui/MainActivity.kt +++ b/app/src/main/java/com/centralcloud/oncall/ui/MainActivity.kt @@ -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 } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8a52159c..8a453924 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -566,4 +566,10 @@ Wrong password or invalid PKCS#12 file Invalid password or corrupted PKCS#12 file Invalid service URL + + + SMS monitoring enabled + This device is configured to forward SMS messages from monitored numbers to the on-call system. Grant SMS access to enable this feature. + Grant access + Later