diff --git a/android_gateway/app/build.gradle b/android_gateway/app/build.gradle index be78383..b87d08d 100644 --- a/android_gateway/app/build.gradle +++ b/android_gateway/app/build.gradle @@ -21,7 +21,7 @@ android { defaultConfig { applicationId "com.remotemessage.gateway" - minSdk 23 + minSdk 21 targetSdk 34 versionCode ciVersionCode versionName ciVersionName diff --git a/android_gateway/app/src/main/java/com/remotemessage/gateway/AndroidKeystoreRsa.kt b/android_gateway/app/src/main/java/com/remotemessage/gateway/AndroidKeystoreRsa.kt new file mode 100644 index 0000000..6980210 --- /dev/null +++ b/android_gateway/app/src/main/java/com/remotemessage/gateway/AndroidKeystoreRsa.kt @@ -0,0 +1,34 @@ +package com.remotemessage.gateway + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import androidx.annotation.RequiresApi +import java.security.KeyPairGenerator +import java.security.KeyStore + +@RequiresApi(Build.VERSION_CODES.M) +object AndroidKeystoreRsa { + fun getOrCreatePublicKeyPem( + alias: String, + toPem: (title: String, raw: ByteArray) -> String + ): String { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + if (!keyStore.containsAlias(alias)) { + val keyPairGenerator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore") + val spec = KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_DECRYPT or KeyProperties.PURPOSE_ENCRYPT + ) + .setKeySize(2048) + .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) + .build() + keyPairGenerator.initialize(spec) + keyPairGenerator.generateKeyPair() + } + + val cert = keyStore.getCertificate(alias) ?: error("android keystore certificate missing") + return toPem("PUBLIC KEY", cert.publicKey.encoded) + } +} diff --git a/android_gateway/app/src/main/java/com/remotemessage/gateway/GatewayForegroundService.kt b/android_gateway/app/src/main/java/com/remotemessage/gateway/GatewayForegroundService.kt index 5a69dd1..f737908 100644 --- a/android_gateway/app/src/main/java/com/remotemessage/gateway/GatewayForegroundService.kt +++ b/android_gateway/app/src/main/java/com/remotemessage/gateway/GatewayForegroundService.kt @@ -124,7 +124,7 @@ class GatewayForegroundService : Service() { } private fun updateNotification(content: String) { - val manager = getSystemService(NotificationManager::class.java) ?: return + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return manager.notify(NOTIFICATION_ID, buildNotification(content)) } @@ -150,7 +150,7 @@ class GatewayForegroundService : Service() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { return } - val manager = getSystemService(NotificationManager::class.java) ?: return + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return val channel = NotificationChannel( CHANNEL_ID, getString(R.string.fg_sync_channel_name), diff --git a/android_gateway/app/src/main/java/com/remotemessage/gateway/GatewayRuntime.kt b/android_gateway/app/src/main/java/com/remotemessage/gateway/GatewayRuntime.kt index 5d00f01..44e5e16 100644 --- a/android_gateway/app/src/main/java/com/remotemessage/gateway/GatewayRuntime.kt +++ b/android_gateway/app/src/main/java/com/remotemessage/gateway/GatewayRuntime.kt @@ -5,12 +5,9 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import java.io.ByteArrayOutputStream -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties import android.net.Uri import android.os.Build import android.provider.Telephony -import android.telephony.SubscriptionManager import android.telephony.SmsManager import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType @@ -74,6 +71,7 @@ object GatewayRuntime { private const val HISTORY_FORCE_FULL_SYNC_ONCE_KEY = "history_force_full_sync_once" private const val SERVER_PUBLIC_KEY_CACHE_MS = 60 * 1000L private const val SEND_TRACKER_PREF = "gateway_send_tracker" + private const val INVALID_SUBSCRIPTION_ID = -1 @Volatile private var cachedServerPublicPem: String? = null @@ -809,11 +807,19 @@ object GatewayRuntime { } private fun smsManagerForSubscriptionId(subscriptionId: Int?): SmsManager { - return when (subscriptionId) { - null -> SmsManager.getDefault() - SubscriptionManager.INVALID_SUBSCRIPTION_ID -> SmsManager.getDefault() - else -> SmsManager.getSmsManagerForSubscriptionId(subscriptionId) + if (subscriptionId == null || subscriptionId == INVALID_SUBSCRIPTION_ID) { + return SmsManager.getDefault() } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + val specificManager = runCatching { + val method = SmsManager::class.java.getMethod("getSmsManagerForSubscriptionId", Int::class.javaPrimitiveType!!) + method.invoke(null, subscriptionId) as? SmsManager + }.getOrNull() + if (specificManager != null) { + return specificManager + } + } + return SmsManager.getDefault() } private fun sendTextMessageCompat( @@ -1225,24 +1231,22 @@ object GatewayRuntime { } } - val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - if (!keyStore.containsAlias(KEYSTORE_ALIAS)) { - val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore") - val spec = KeyGenParameterSpec.Builder( - KEYSTORE_ALIAS, - KeyProperties.PURPOSE_DECRYPT or KeyProperties.PURPOSE_ENCRYPT - ) - .setKeySize(2048) - .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) - .build() - kpg.initialize(spec) - kpg.generateKeyPair() + val pubPem: String + val priPem: String + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val generatedPub = AndroidKeystoreRsa.getOrCreatePublicKeyPem(KEYSTORE_ALIAS) { title, raw -> + toPem(title, raw) + } + pubPem = generatedPub + priPem = "android-keystore:$KEYSTORE_ALIAS" + } else { + val pair = KeyPairGenerator.getInstance("RSA").apply { + initialize(2048) + }.generateKeyPair() + pubPem = toPem("PUBLIC KEY", pair.public.encoded) + priPem = toPem("PRIVATE KEY", pair.private.encoded) } - val cert = keyStore.getCertificate(KEYSTORE_ALIAS) ?: error("android keystore certificate missing") - val pubPem = toPem("PUBLIC KEY", cert.publicKey.encoded) - val priPem = "android-keystore:$KEYSTORE_ALIAS" pref.edit().putString(KEY_PUBLIC, pubPem).putString(KEY_PRIVATE, priPem).apply() return pubPem to priPem } diff --git a/android_gateway/app/src/main/java/com/remotemessage/gateway/GatewaySimSupport.kt b/android_gateway/app/src/main/java/com/remotemessage/gateway/GatewaySimSupport.kt index ddecce4..9e762e3 100644 --- a/android_gateway/app/src/main/java/com/remotemessage/gateway/GatewaySimSupport.kt +++ b/android_gateway/app/src/main/java/com/remotemessage/gateway/GatewaySimSupport.kt @@ -7,8 +7,6 @@ import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.provider.Telephony -import android.telephony.SubscriptionInfo -import android.telephony.SubscriptionManager import android.telephony.TelephonyManager import androidx.core.content.ContextCompat import kotlin.math.abs @@ -37,6 +35,8 @@ data class GatewayResolvedSimInfo( object GatewaySimSupport { private const val PREF_NAME = "gateway_config" + private const val INVALID_SUBSCRIPTION_ID = -1 + private const val SUBSCRIPTION_SERVICE = "telephony_subscription_service" fun readSnapshot(context: Context): GatewaySimSnapshot { val activeProfiles = readActiveProfiles(context) @@ -277,7 +277,6 @@ object GatewaySimSupport { private fun extractSubscriptionId(intent: Intent): Int? { val candidates = listOf( - SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX, "subscription", "subscription_id", "sub_id", @@ -286,8 +285,8 @@ object GatewaySimSupport { return candidates.firstNotNullOfOrNull { key -> intent.extras?.takeIf { it.containsKey(key) } ?.let { - val value = intent.getIntExtra(key, SubscriptionManager.INVALID_SUBSCRIPTION_ID) - value.takeIf { subId -> subId != SubscriptionManager.INVALID_SUBSCRIPTION_ID } + val value = intent.getIntExtra(key, INVALID_SUBSCRIPTION_ID) + value.takeIf { subId -> subId != INVALID_SUBSCRIPTION_ID } } } } @@ -315,23 +314,28 @@ object GatewaySimSupport { } private fun readActiveProfiles(context: Context): List { - val subscriptionManager = context.getSystemService(SubscriptionManager::class.java) ?: return emptyList() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1 || !hasSubscriptionPermission(context)) { + return emptyList() + } + val subscriptionManager = context.getSystemService(SUBSCRIPTION_SERVICE) ?: return emptyList() val infos = runCatching { - if (hasSubscriptionPermission(context)) { - subscriptionManager.activeSubscriptionInfoList.orEmpty() - } else { - emptyList() - } - }.getOrDefault(emptyList()) + subscriptionManager.javaClass.getMethod("getActiveSubscriptionInfoList").invoke(subscriptionManager) as? List<*> + }.getOrNull().orEmpty() - return infos.map { info -> - val customNumber = readCustomPhoneNumber(context, info.simSlotIndex) - val systemNumber = readSystemPhoneNumber(context, subscriptionManager, info) + return infos.mapNotNull { info -> + if (info == null) return@mapNotNull null + val slotIndex = info.readIntMethod("getSimSlotIndex") ?: return@mapNotNull null + val subscriptionId = info.readIntMethod("getSubscriptionId") + val customNumber = readCustomPhoneNumber(context, slotIndex) + val systemNumber = readSystemPhoneNumber(context, subscriptionManager, info, subscriptionId) val effective = customNumber?.takeIf { it.isNotBlank() } ?: systemNumber?.takeIf { it.isNotBlank() } + val displayName = info.readStringMethod("getDisplayName") + ?.takeIf { it.isNotBlank() } + ?: "SIM ${slotIndex + 1}" GatewaySimProfile( - subscriptionId = info.subscriptionId, - slotIndex = info.simSlotIndex, - displayName = info.displayName?.toString()?.takeIf { it.isNotBlank() } ?: "SIM ${info.simSlotIndex + 1}", + subscriptionId = subscriptionId, + slotIndex = slotIndex, + displayName = displayName, systemPhoneNumber = systemNumber, customPhoneNumber = customNumber, effectivePhoneNumber = effective, @@ -350,22 +354,48 @@ object GatewaySimSupport { } @SuppressLint("MissingPermission") - private fun readSystemPhoneNumber(context: Context, subscriptionManager: SubscriptionManager, info: SubscriptionInfo): String? { + private fun readSystemPhoneNumber(context: Context, subscriptionManager: Any, info: Any, subscriptionId: Int?): String? { val fromSubscriptionManager = runCatching { when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> subscriptionManager.getPhoneNumber(info.subscriptionId) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + subscriptionId != null && + subscriptionId != INVALID_SUBSCRIPTION_ID -> + subscriptionManager.javaClass + .getMethod("getPhoneNumber", Int::class.javaPrimitiveType!!) + .invoke(subscriptionManager, subscriptionId) as? String else -> null } }.getOrNull()?.trim().takeIf { !it.isNullOrBlank() } - val fromInfo = info.number?.trim().takeIf { !it.isNullOrBlank() } + val fromInfo = info.readStringMethod("getNumber")?.trim().takeIf { !it.isNullOrBlank() } val fromTelephonyManager = runCatching { - context.getSystemService(TelephonyManager::class.java) - ?.createForSubscriptionId(info.subscriptionId) - ?.line1Number + val telephonyManager = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager + ?: return@runCatching null + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && + subscriptionId != null && + subscriptionId != INVALID_SUBSCRIPTION_ID -> + telephonyManager.createForSubscriptionId(subscriptionId).line1Number + else -> telephonyManager.line1Number + } }.getOrNull()?.trim().takeIf { !it.isNullOrBlank() } return fromSubscriptionManager ?: fromInfo ?: fromTelephonyManager } + + private fun Any.readIntMethod(methodName: String): Int? { + return runCatching { + val method = javaClass.getMethod(methodName) + (method.invoke(this) as? Number)?.toInt() + }.getOrNull() + } + + private fun Any.readStringMethod(methodName: String): String? { + return runCatching { + val method = javaClass.getMethod(methodName) + val value = method.invoke(this) ?: return@runCatching null + (value as? CharSequence)?.toString() ?: value.toString() + }.getOrNull() + } } diff --git a/android_gateway/app/src/main/java/com/remotemessage/gateway/PermissionAndRoleHelper.kt b/android_gateway/app/src/main/java/com/remotemessage/gateway/PermissionAndRoleHelper.kt index f348329..2deb4fb 100644 --- a/android_gateway/app/src/main/java/com/remotemessage/gateway/PermissionAndRoleHelper.kt +++ b/android_gateway/app/src/main/java/com/remotemessage/gateway/PermissionAndRoleHelper.kt @@ -16,7 +16,7 @@ import androidx.core.content.ContextCompat object PermissionAndRoleHelper { fun isDefaultSmsApp(context: Context): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val roleManager = context.getSystemService(android.app.role.RoleManager::class.java) + val roleManager = context.getSystemService(Context.ROLE_SERVICE) as? android.app.role.RoleManager roleManager != null && roleManager.isRoleAvailable(android.app.role.RoleManager.ROLE_SMS) && roleManager.isRoleHeld(android.app.role.RoleManager.ROLE_SMS) @@ -28,7 +28,7 @@ object PermissionAndRoleHelper { fun buildRequestDefaultSmsRoleIntent(activity: Activity): Intent? { if (isDefaultSmsApp(activity)) return null if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val roleManager = activity.getSystemService(android.app.role.RoleManager::class.java) + val roleManager = activity.getSystemService(Context.ROLE_SERVICE) as? android.app.role.RoleManager if (roleManager != null && roleManager.isRoleAvailable(android.app.role.RoleManager.ROLE_SMS) && !roleManager.isRoleHeld(android.app.role.RoleManager.ROLE_SMS)) { return roleManager.createRequestRoleIntent(android.app.role.RoleManager.ROLE_SMS) } diff --git a/android_gateway/app/src/main/java/com/remotemessage/gateway/RespondViaMessageService.kt b/android_gateway/app/src/main/java/com/remotemessage/gateway/RespondViaMessageService.kt index 6015d4e..b590fc6 100644 --- a/android_gateway/app/src/main/java/com/remotemessage/gateway/RespondViaMessageService.kt +++ b/android_gateway/app/src/main/java/com/remotemessage/gateway/RespondViaMessageService.kt @@ -3,11 +3,13 @@ package com.remotemessage.gateway import android.app.Service import android.content.Context import android.content.Intent +import android.os.Build import android.os.IBinder -import android.telephony.SubscriptionManager import android.telephony.SmsManager class RespondViaMessageService : Service() { + private val invalidSubscriptionId = -1 + override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -41,11 +43,7 @@ class RespondViaMessageService : Service() { if (recipients.isEmpty()) return val resolvedSim = GatewaySimSupport.resolveForIntent(this, intent) - val smsManager = when (val subId = resolvedSim.subscriptionId) { - null -> SmsManager.getDefault() - SubscriptionManager.INVALID_SUBSCRIPTION_ID -> SmsManager.getDefault() - else -> SmsManager.getSmsManagerForSubscriptionId(subId) - } + val smsManager = smsManagerForSubscriptionId(resolvedSim.subscriptionId) recipients.forEach { phone -> val parts = smsManager.divideMessage(body) @@ -56,4 +54,20 @@ class RespondViaMessageService : Service() { } } } + + private fun smsManagerForSubscriptionId(subscriptionId: Int?): SmsManager { + if (subscriptionId == null || subscriptionId == invalidSubscriptionId) { + return SmsManager.getDefault() + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + val specificManager = runCatching { + val method = SmsManager::class.java.getMethod("getSmsManagerForSubscriptionId", Int::class.javaPrimitiveType!!) + method.invoke(null, subscriptionId) as? SmsManager + }.getOrNull() + if (specificManager != null) { + return specificManager + } + } + return SmsManager.getDefault() + } }