Skip to content
Draft
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
2 changes: 1 addition & 1 deletion android_gateway/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ android {

defaultConfig {
applicationId "com.remotemessage.gateway"
minSdk 23
minSdk 21
targetSdk 34
versionCode ciVersionCode
versionName ciVersionName
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -277,7 +277,6 @@ object GatewaySimSupport {

private fun extractSubscriptionId(intent: Intent): Int? {
val candidates = listOf(
SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX,
"subscription",
"subscription_id",
"sub_id",
Expand All @@ -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 }
}
}
}
Expand Down Expand Up @@ -315,23 +314,28 @@ object GatewaySimSupport {
}

private fun readActiveProfiles(context: Context): List<GatewaySimProfile> {
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,
Expand All @@ -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()
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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()
}
}
Loading