commit 0428ab71131cd33ab733214c137362beec91ed2e Author: Kumi Date: Tue May 20 19:17:03 2025 +0200 feat: Current version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..c224ad5 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..da79037 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,97 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("kotlin-kapt") + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "website.kumi.matrixsms" + compileSdk = 35 + + defaultConfig { + applicationId = "website.kumi.matrixsms" + minSdk = 29 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.4.3" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + // Core Android + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + + // Compose + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.material.icons.core) + implementation(libs.androidx.material.icons.extended) + + // ViewModel + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.livedata.ktx) + implementation(libs.androidx.runtime.livedata) + + // Room + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + kapt(libs.androidx.room.compiler) + + // Network + implementation(libs.retrofit2.retrofit) + implementation(libs.converter.gson) + implementation(libs.okhttp) + + // Phone number formatting + implementation(libs.libphonenumber) + + // Testing + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + + // Debug + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/website/kumi/matrixsms/ExampleInstrumentedTest.kt b/app/src/androidTest/java/website/kumi/matrixsms/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..6715a85 --- /dev/null +++ b/app/src/androidTest/java/website/kumi/matrixsms/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package website.kumi.matrixsms + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("website.kumi.matrixsms", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4ff88d5 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/website/kumi/matrixsms/BridgeService.kt b/app/src/main/java/website/kumi/matrixsms/BridgeService.kt new file mode 100644 index 0000000..db46bb3 --- /dev/null +++ b/app/src/main/java/website/kumi/matrixsms/BridgeService.kt @@ -0,0 +1,705 @@ +package website.kumi.matrixsms + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.provider.Telephony +import android.telephony.SmsManager +import android.telephony.SmsMessage +import android.util.Log +import android.widget.Toast +import androidx.core.app.NotificationCompat +import com.google.gson.Gson +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import website.kumi.matrixsms.api.MatrixEvent +import website.kumi.matrixsms.api.MatrixMessage + +class BridgeService : Service() { + private lateinit var configRepository: ConfigRepository + private lateinit var contactManager: ContactManager + private lateinit var roomMapper: RoomMapper + + // Make these nullable to handle initialization failures + private var matrixClient: MatrixClient? = null + private var pollingHandler: Handler? = null + private var managementRoomId: String? = null + + private val pollingRunnable = object : Runnable { + override fun run() { + CoroutineScope(Dispatchers.IO).launch { + pollMatrixForNewMessages() + } + pollingHandler?.postDelayed(this, configRepository.getPollingInterval() * 1000) + } + } + + override fun onCreate() { + super.onCreate() + startForeground() + + // Initialize repositories and managers + configRepository = ConfigRepository(applicationContext) + contactManager = ContactManager(applicationContext) + roomMapper = RoomMapper(applicationContext) + + // Always initialize the handler + pollingHandler = Handler(Looper.getMainLooper()) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d(TAG, "onStartCommand: ${intent?.action}") + + when (intent?.action) { + ACTION_START_SERVICE -> { + Log.d(TAG, "Received START_SERVICE action") + startBridge() + } + ACTION_STOP_SERVICE -> { + Log.d(TAG, "Received STOP_SERVICE action") + stopSelf() + return START_NOT_STICKY + } + ACTION_SMS_RECEIVED -> { + Log.d(TAG, "Received SMS_RECEIVED action") + handleSmsReceivedIntent(intent) + } + else -> { + Log.d(TAG, "Received default action, starting bridge") + startBridge() + } + } + + return START_STICKY + } + + private fun startBridge() { + // Try to initialize Matrix client if not already initialized + if (matrixClient == null) { + if (!initMatrixClient()) { + updateNotification("Matrix client initialization failed. Please check your settings.") + return + } + } + + // Start polling for Matrix messages + pollingHandler?.removeCallbacks(pollingRunnable) + pollingHandler?.post(pollingRunnable) + + updateNotification("Bridge is active and running") + } + + private fun initMatrixClient(): Boolean { + val homeserverUrl = configRepository.getHomeserverUrl() + val accessToken = configRepository.getAccessToken() + val recipientUserId = configRepository.getRecipientUserId() + + return if (homeserverUrl.isNotEmpty() && accessToken.isNotEmpty() && recipientUserId.isNotEmpty()) { + try { + matrixClient = MatrixClient(homeserverUrl, accessToken, recipientUserId, configRepository) + + // Setup management room in the background + CoroutineScope(Dispatchers.IO).launch { + try { + setupManagementRoom() + } catch (e: Exception) { + Log.e(TAG, "Failed to setup management room", e) + } + } + + true + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize Matrix client", e) + updateNotification("Matrix client initialization failed: ${e.message}") + false + } + } else { + Log.e(TAG, "Matrix client initialization failed: missing configuration") + updateNotification("Matrix client initialization failed: missing configuration") + false + } + } + + private fun updateNotification(message: String) { + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Matrix SMS Bridge") + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setSmallIcon(R.drawable.ic_notification) + .build() + + notificationManager.notify(NOTIFICATION_ID, notification) + } + + private fun startForeground() { + val channelId = createNotificationChannel() + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Matrix SMS Bridge") + .setContentText("Bridging SMS to Matrix") + .setPriority(NotificationCompat.PRIORITY_LOW) + .setSmallIcon(R.drawable.ic_notification) + .build() + + startForeground(NOTIFICATION_ID, notification) + } + + private fun createNotificationChannel(): String { + val channelName = "Matrix SMS Bridge Service" + val channel = NotificationChannel( + CHANNEL_ID, + channelName, + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Keeps the SMS to Matrix bridge running" + } + + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + + return CHANNEL_ID + } + + + // Add a preference key to store the management room ID + private fun saveManagementRoomId(roomId: String) { + val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit().putString(KEY_MANAGEMENT_ROOM_ID, roomId).apply() + Log.d(TAG, "Saved management room ID: $roomId") + } + + private fun getSavedManagementRoomId(): String? { + val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getString(KEY_MANAGEMENT_ROOM_ID, null) + } + + private suspend fun setupManagementRoom() { + val client = matrixClient ?: run { + Log.e(TAG, "Cannot setup management room: Matrix client not initialized") + return + } + + try { + // First check if we have a saved management room ID + val savedRoomId = getSavedManagementRoomId() + if (savedRoomId != null) { + Log.d(TAG, "Found saved management room ID: $savedRoomId") + + // Check if we're still in this room + val joinedRooms = client.getJoinedRooms() + if (joinedRooms.contains(savedRoomId)) { + Log.d(TAG, "Using existing saved management room: $savedRoomId") + managementRoomId = savedRoomId + return + } else { + Log.d(TAG, "Saved management room $savedRoomId no longer joined, will search or create new one") + } + } + + // If no saved room or not joined, try to find by state events + managementRoomId = findManagementRoom() + + if (managementRoomId != null) { + Log.d(TAG, "Found existing management room by state event: $managementRoomId") + saveManagementRoomId(managementRoomId!!) + return + } + + // If still no room found, create a new one + Log.d(TAG, "Creating new management room") + managementRoomId = client.createRoom( + roomName = "SMS Bridge Management", + topic = "Manage your SMS bridge connections", + isDirect = false + ) + + if (managementRoomId == null) { + Log.e(TAG, "Failed to create management room: returned room ID is null") + return + } + + // Set room state to mark it as management room + try { + client.setRoomStateEvent( + roomId = managementRoomId!!, + eventType = "org.matrix.sms_bridge.management", + stateKey = "", + content = ManagementRoomState(isManagementRoom = true) + ) + + // Save the new management room ID + saveManagementRoomId(managementRoomId!!) + + // Send welcome message + sendManagementRoomWelcomeMessage() + + Log.d(TAG, "Management room created successfully: $managementRoomId") + } catch (e: Exception) { + Log.e(TAG, "Failed to set management room state event", e) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to setup management room", e) + } + } + + private suspend fun findManagementRoom(): String? { + val client = matrixClient ?: return null + + try { + val rooms = client.getJoinedRooms() + Log.d(TAG, "Searching for management room among ${rooms.size} joined rooms") + + for (roomId in rooms) { + try { + val stateEvent = client.getRoomStateEvent( + roomId = roomId, + eventType = "org.matrix.sms_bridge.management", + stateKey = "" + ) + + if (stateEvent is ManagementRoomState && stateEvent.isManagementRoom) { + Log.d(TAG, "Found management room by state event: $roomId") + return roomId + } + } catch (e: Exception) { + // State event not found, not a management room + continue + } + } + + Log.d(TAG, "No management room found among joined rooms") + return null + } catch (e: Exception) { + Log.e(TAG, "Failed to find management room", e) + return null + } + } + + private suspend fun sendManagementRoomWelcomeMessage() { + val welcomeMessage = """ + # SMS Bridge Management Room + + Welcome to your SMS Bridge Management Room. You can use the following commands: + + - `!sms new +1234567890` - Create a new SMS conversation with the specified phone number + - `!sms help` - Show this help message + + Each SMS conversation will be bridged to a separate Matrix room. + """.trimIndent() + + matrixClient!!.sendTextMessage( + roomId = managementRoomId!!, + content = welcomeMessage, + format = "org.matrix.custom.html", + formattedBody = markdownToHtml(welcomeMessage) + ) + } + + private fun markdownToHtml(markdown: String): String { + // Simple markdown to HTML conversion for welcome message + return markdown + .replace("# ", "

").replace("\n\n", "

") + .replace("- ", "
  • ").replace("\n", "
  • \n
  • ") + .replace("`", "").replace("`", "") + } + + private suspend fun debugRoomState(roomId: String) { + val client = matrixClient ?: return + + try { + Log.d(TAG, "Dumping state for room $roomId") + val stateEvents = client.getAllRoomState(roomId) + + for (event in stateEvents) { + try { + val eventJson = Gson().toJson(event) + Log.d(TAG, "State event: $eventJson") + } catch (e: Exception) { + Log.e(TAG, "Failed to serialize state event", e) + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to dump room state", e) + } + } + + private suspend fun cleanupOrphanedManagementRooms() { + val client = matrixClient ?: return + + try { + val rooms = client.getJoinedRooms() + var managementRoomCount = 0 + + for (roomId in rooms) { + try { + val stateEvent = client.getRoomStateEvent( + roomId = roomId, + eventType = "org.matrix.sms_bridge.management", + stateKey = "" + ) + + if (stateEvent is ManagementRoomState && stateEvent.isManagementRoom) { + managementRoomCount++ + + // If this isn't our current management room and we have more than one, leave it + if (managementRoomCount > 1 && roomId != managementRoomId) { + Log.d(TAG, "Leaving orphaned management room: $roomId") + client.leaveRoom(roomId) + } + } + } catch (e: Exception) { + // Not a management room + continue + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to cleanup orphaned management rooms", e) + } + } + + private suspend fun processManagementRoomCommand(event: MatrixEvent, textContent: String) { + Log.d(TAG, "Processing management room command from ${event.sender}: $textContent") + + if (event.sender != configRepository.getRecipientUserId()) { + Log.d(TAG, "Ignoring command from non-recipient user: ${event.sender}") + return + } + + if (!textContent.startsWith("!sms")) { + Log.d(TAG, "Not an SMS command: $textContent") + return + } + + val parts = textContent.split(" ", limit = 3) + if (parts.size < 2) { + Log.d(TAG, "Invalid command format: $textContent") + sendManagementRoomMessage("Invalid command. Try `!sms help` for available commands.") + return + } + + when (parts[1].lowercase()) { + "new" -> { + if (parts.size < 3) { + Log.d(TAG, "Missing phone number in new command") + sendManagementRoomMessage("Please specify a phone number: `!sms new +1234567890`") + return + } + + val phoneNumber = parts[2].trim() + Log.d(TAG, "Creating new SMS room for number: $phoneNumber") + createNewSmsRoom(phoneNumber) + } + + "help" -> { + Log.d(TAG, "Sending help message") + sendManagementRoomWelcomeMessage() + } + + "debug" -> { + Log.d(TAG, "Running debug command") + val client = matrixClient ?: return + + val debugInfo = StringBuilder() + debugInfo.append("**Debug Information**\n\n") + + // Add basic info + debugInfo.append("Management Room ID: $managementRoomId\n") + debugInfo.append("Joined Rooms: ${client.getJoinedRooms().size}\n") + + // Add room mappings + val mappings = roomMapper.getAllMappings() + debugInfo.append("\n**Room Mappings (${mappings.size}):**\n") + for (mapping in mappings) { + debugInfo.append("- ${mapping.phoneNumber} -> ${mapping.roomId}\n") + } + + // Reset event tracking + client.resetAllLastEventIds() + debugInfo.append("\n*Event tracking reset. Next poll will fetch latest messages.*\n") + + sendManagementRoomMessage(debugInfo.toString()) + } + "poll" -> { + Log.d(TAG, "Forcing immediate message poll") + sendManagementRoomMessage("Forcing immediate message poll...") + + // Poll immediately + pollMatrixForNewMessages() + + sendManagementRoomMessage("Message poll completed.") + } + "cleanup" -> { + Log.d(TAG, "Cleaning up orphaned management rooms") + cleanupOrphanedManagementRooms() + sendManagementRoomMessage("Cleanup of orphaned management rooms completed.") + } + + else -> { + Log.d(TAG, "Unknown command: ${parts[1]}") + sendManagementRoomMessage("Unknown command: ${parts[1]}. Try `!sms help` for available commands.") + } + } + } + + private suspend fun createNewSmsRoom(phoneNumber: String) { + try { + // Validate and format phone number + val formattedNumber = contactManager.formatPhoneNumber(phoneNumber) + + // Check if room already exists + val existingRoomId = roomMapper.getRoomForPhoneNumber(formattedNumber) + if (existingRoomId != null) { + // Room already exists + val roomLink = matrixClient!!.getRoomCanonicalAlias(existingRoomId) + ?: existingRoomId + + sendManagementRoomMessage( + "A room for $formattedNumber already exists: $roomLink" + ) + return + } + + // Get contact name if available + val contactName = contactManager.getContactName(formattedNumber) + + // Create new room + val roomId = createRoomForSender(formattedNumber, contactName) + + // Send confirmation + val roomLink = matrixClient!!.getRoomCanonicalAlias(roomId) ?: roomId + sendManagementRoomMessage( + "Created new SMS conversation room for $formattedNumber: $roomLink" + ) + + } catch (e: Exception) { + Log.e(TAG, "Failed to create new SMS room", e) + sendManagementRoomMessage( + "Failed to create room: ${e.message}" + ) + } + } + + private suspend fun sendManagementRoomMessage(message: String) { + if (managementRoomId == null) return + + matrixClient!!.sendTextMessage( + roomId = managementRoomId!!, + content = message + ) + } + + private val smsReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Telephony.Sms.Intents.SMS_RECEIVED_ACTION) { + val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent) + for (message in messages) { + handleIncomingSms(message) + } + } + } + } + + private fun handleSmsReceivedIntent(intent: Intent) { + try { + val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent) + for (message in messages) { + handleIncomingSms(message) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to handle SMS received intent", e) + } + } + + private fun handleIncomingSms(message: SmsMessage) { + val sender = message.originatingAddress ?: return + val body = message.messageBody ?: return + + // Use contactManager to format the phone number + val formattedSender = contactManager.formatPhoneNumber(sender) + val contactName = contactManager.getContactName(formattedSender) + + CoroutineScope(Dispatchers.IO).launch { + try { + // Get or create room for this sender + val roomId = roomMapper.getRoomForPhoneNumber(formattedSender) + ?: createRoomForSender(formattedSender, contactName) + + // Send message to Matrix - use the sendSmsTextMessage method + matrixClient!!.sendSmsTextMessage(roomId, body, formattedSender) + + Log.d(TAG, "SMS from $formattedSender bridged to Matrix room $roomId") + } catch (e: Exception) { + Log.e(TAG, "Failed to bridge SMS to Matrix", e) + } + } + } + + private suspend fun createRoomForSender(phoneNumber: String, contactName: String?): String { + val roomName = contactName ?: phoneNumber + val roomId = matrixClient!!.createRoom( + roomName = roomName, + isDirect = true + ) + + // Store phone number in room state + matrixClient!!.setRoomPhoneNumberState(roomId, phoneNumber) + + // Save mapping locally + roomMapper.saveMapping(phoneNumber, roomId) + + Log.d(TAG, "Created new Matrix room $roomId for $phoneNumber") + + return roomId + } + + private suspend fun pollMatrixForNewMessages() { + val client = matrixClient + if (client == null) { + Log.e(TAG, "Matrix client not initialized, skipping polling") + return + } + + try { + // First, check management room specifically + if (managementRoomId != null) { + try { + Log.d(TAG, "Checking management room for messages: $managementRoomId") + val events = client.getNewMessages(managementRoomId!!) + Log.d(TAG, "Found ${events.size} new events in management room") + + for (event in events) { + try { + // Only process text messages + if (!event.isTextMessage()) { + continue + } + + val textContent = event.getTextContent() + if (textContent == null) { + Log.w(TAG, "Skipping message with no text content: ${event.eventId}") + continue + } + + Log.d(TAG, "Processing management room command: $textContent") + processManagementRoomCommand(event, textContent) + } catch (e: Exception) { + Log.e(TAG, "Error processing event in management room", e) + } + } + } catch (e: Exception) { + Log.e(TAG, "Error getting messages for management room", e) + } + } else { + Log.w(TAG, "No management room ID set, skipping management room check") + } + + // Then check other rooms for SMS messages + val rooms = client.getJoinedRooms() + + for (roomId in rooms) { + // Skip management room as we already processed it + if (roomId == managementRoomId) { + continue + } + + try { + val events = client.getNewMessages(roomId) + + for (event in events) { + try { + // Only process text messages + if (!event.isTextMessage()) { + continue + } + + val textContent = event.getTextContent() + if (textContent == null) { + Log.w(TAG, "Skipping message with no text content: ${event.eventId}") + continue + } + + // Only process messages from the recipient user + if (event.sender == configRepository.getRecipientUserId()) { + // Get phone number from room state + val phoneNumber = client.getRoomPhoneNumberState(roomId) + ?: roomMapper.getPhoneNumberForRoom(roomId) + + if (phoneNumber != null) { + // Send SMS + sendSms(phoneNumber, textContent) + Log.d(TAG, "Matrix message bridged to SMS for $phoneNumber") + } else { + Log.e(TAG, "Could not find phone number for room $roomId") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error processing event in room $roomId", e) + } + } + } catch (e: Exception) { + Log.e(TAG, "Error getting messages for room $roomId", e) + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to poll Matrix for new messages", e) + updateNotification("Failed to poll Matrix: ${e.message}") + } + } + + private fun sendSms(phoneNumber: String, content: String) { + try { + // No need to format the phone number here as it should already be properly formatted + // when stored in the room state or database + + val smsManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + this.getSystemService(SmsManager::class.java) + } else { + @Suppress("DEPRECATION") + SmsManager.getDefault() + } + + // Split message if it's too long + val parts = smsManager.divideMessage(content) + if (parts.size > 1) { + smsManager.sendMultipartTextMessage(phoneNumber, null, parts, null, null) + } else { + smsManager.sendTextMessage(phoneNumber, null, content, null, null) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to send SMS to $phoneNumber", e) + } + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + pollingHandler?.removeCallbacks(pollingRunnable) + try { unregisterReceiver(smsReceiver) } catch (e: Exception) { Log.w(TAG, "Receiver already unregistered or never registered.") } + super.onDestroy() + } + + companion object { + private const val NOTIFICATION_ID = 1001 + private const val TAG = "BridgeService" + private const val CHANNEL_ID = "MatrixSmsBridgeChannel" + private const val PREFS_NAME = "MatrixSmsBridgePrefs" + private const val KEY_MANAGEMENT_ROOM_ID = "management_room_id" + + const val ACTION_START_SERVICE = "website.kumi.matrixsms.START_SERVICE" + const val ACTION_STOP_SERVICE = "website.kumi.matrixsms.STOP_SERVICE" + const val ACTION_SMS_RECEIVED = "website.kumi.matrixsms.SMS_RECEIVED" + } +} + +data class ManagementRoomState( + val isManagementRoom: Boolean = true +) \ No newline at end of file diff --git a/app/src/main/java/website/kumi/matrixsms/ConfigRepository.kt b/app/src/main/java/website/kumi/matrixsms/ConfigRepository.kt new file mode 100644 index 0000000..724c4c7 --- /dev/null +++ b/app/src/main/java/website/kumi/matrixsms/ConfigRepository.kt @@ -0,0 +1,74 @@ +package website.kumi.matrixsms + +import android.content.Context +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import androidx.core.content.edit + +class ConfigRepository(context: Context) { + private val sharedPreferences = context.getSharedPreferences( + "matrix_sms_bridge_prefs", + Context.MODE_PRIVATE + ) + + private val gson = Gson() + + fun getHomeserverUrl(): String { + return sharedPreferences.getString(KEY_HOMESERVER_URL, "") ?: "" + } + + fun getAccessToken(): String { + return sharedPreferences.getString(KEY_ACCESS_TOKEN, "") ?: "" + } + + fun getRecipientUserId(): String { + return sharedPreferences.getString(KEY_RECIPIENT_USER_ID, "") ?: "" + } + + fun getPollingInterval(): Long { + return sharedPreferences.getLong(KEY_POLLING_INTERVAL, 30) + } + + fun saveConfig( + homeserverUrl: String, + accessToken: String, + recipientUserId: String, + pollingInterval: Long + ) { + sharedPreferences.edit().apply { + putString(KEY_HOMESERVER_URL, homeserverUrl) + putString(KEY_ACCESS_TOKEN, accessToken) + putString(KEY_RECIPIENT_USER_ID, recipientUserId) + putLong(KEY_POLLING_INTERVAL, pollingInterval) + apply() + } + } + + fun saveLastEventIds(tokens: Map) { + val jsonString = gson.toJson(tokens) + sharedPreferences.edit() { putString(KEY_LAST_EVENT_IDS, jsonString) } + } + + fun loadLastEventIds(): Map { + val jsonString = sharedPreferences.getString(KEY_LAST_EVENT_IDS, null) + return if (jsonString != null) { + try { + val type = object : TypeToken>() {}.type + gson.fromJson(jsonString, type) ?: emptyMap() + } catch (e: Exception) { + android.util.Log.e("ConfigRepository", "Failed to load lastEventIds", e) + emptyMap() + } + } else { + emptyMap() + } + } + + companion object { + private const val KEY_HOMESERVER_URL = "homeserver_url" + private const val KEY_ACCESS_TOKEN = "access_token" + private const val KEY_RECIPIENT_USER_ID = "recipient_user_id" + private const val KEY_POLLING_INTERVAL = "polling_interval" + private const val KEY_LAST_EVENT_IDS = "last_event_ids" + } +} \ No newline at end of file diff --git a/app/src/main/java/website/kumi/matrixsms/ContactManager.kt b/app/src/main/java/website/kumi/matrixsms/ContactManager.kt new file mode 100644 index 0000000..40fdbe3 --- /dev/null +++ b/app/src/main/java/website/kumi/matrixsms/ContactManager.kt @@ -0,0 +1,42 @@ +package website.kumi.matrixsms + +import android.annotation.SuppressLint +import android.content.Context +import android.net.Uri +import android.provider.ContactsContract +import com.google.i18n.phonenumbers.PhoneNumberUtil + +class ContactManager(private val context: Context) { + fun getContactName(phoneNumber: String): String? { + val uri = Uri.withAppendedPath( + ContactsContract.PhoneLookup.CONTENT_FILTER_URI, + Uri.encode(phoneNumber) + ) + + val projection = arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME) + + context.contentResolver.query(uri, projection, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + return cursor.getString(cursor.getColumnIndex(ContactsContract.PhoneLookup.DISPLAY_NAME)) + } + } + + return null + } + + fun formatPhoneNumber(phoneNumber: String): String { + // Check if it's a short code (typically 3-6 digits) + if (phoneNumber.length <= 6 && phoneNumber.all { it.isDigit() }) { + return phoneNumber + } + + // Otherwise, try to format as international number + return try { + val phoneUtil = PhoneNumberUtil.getInstance() + val parsedNumber = phoneUtil.parse(phoneNumber, null) + phoneUtil.format(parsedNumber, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL) + } catch (e: Exception) { + phoneNumber + } + } +} \ No newline at end of file diff --git a/app/src/main/java/website/kumi/matrixsms/MainActivity.kt b/app/src/main/java/website/kumi/matrixsms/MainActivity.kt new file mode 100644 index 0000000..6b3e9f8 --- /dev/null +++ b/app/src/main/java/website/kumi/matrixsms/MainActivity.kt @@ -0,0 +1,151 @@ +package website.kumi.matrixsms + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.os.Build +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.viewmodel.compose.viewModel +import website.kumi.matrixsms.ui.theme.MatrixSmsBridgeTheme +import website.kumi.matrixsms.ui.BridgeConfigScreen +import website.kumi.matrixsms.viewmodels.ConfigViewModel + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MatrixSmsBridgeTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + val configViewModel: ConfigViewModel = viewModel() + + BridgeConfigScreen( + configViewModel = configViewModel, + onSaveConfig = { + startBridgeService() + }, + onStopService = { + stopBridgeService() + } + ) + } + } + } + + checkPermissions() + } + + private fun startBridgeService() { + try { + Log.d(TAG, "Starting bridge service") + val serviceIntent = Intent(this, BridgeService::class.java) + serviceIntent.action = BridgeService.ACTION_START_SERVICE + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(serviceIntent) + } else { + startService(serviceIntent) + } + + // Force UI update + setContent { + MatrixSmsBridgeTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + val configViewModel: ConfigViewModel = viewModel() + + BridgeConfigScreen( + configViewModel = configViewModel, + onSaveConfig = { + startBridgeService() + }, + onStopService = { + stopBridgeService() + } + ) + } + } + } + + Toast.makeText(this, "SMS Bridge service started", Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Log.e(TAG, "Failed to start bridge service", e) + Toast.makeText(this, "Failed to start service: ${e.message}", Toast.LENGTH_LONG).show() + } + } + + private fun stopBridgeService() { + try { + Log.d(TAG, "Stopping bridge service") + val serviceIntent = Intent(this, BridgeService::class.java) + serviceIntent.action = BridgeService.ACTION_STOP_SERVICE + startService(serviceIntent) + + // Force UI update + setContent { + MatrixSmsBridgeTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + val configViewModel: ConfigViewModel = viewModel() + + BridgeConfigScreen( + configViewModel = configViewModel, + onSaveConfig = { + startBridgeService() + }, + onStopService = { + stopBridgeService() + } + ) + } + } + } + + Toast.makeText(this, "SMS Bridge service stopped", Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Log.e(TAG, "Failed to stop bridge service", e) + Toast.makeText(this, "Failed to stop service: ${e.message}", Toast.LENGTH_LONG).show() + } + } + + private fun checkPermissions() { + val requiredPermissions = arrayOf( + Manifest.permission.READ_SMS, + Manifest.permission.RECEIVE_SMS, + Manifest.permission.SEND_SMS, + Manifest.permission.READ_CONTACTS + ) + + if (!hasPermissions(requiredPermissions)) { + ActivityCompat.requestPermissions(this, requiredPermissions, PERMISSIONS_REQUEST_CODE) + } + } + + private fun hasPermissions(permissions: Array): Boolean { + return permissions.all { + ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED + } + } + + companion object { + private const val PERMISSIONS_REQUEST_CODE = 100 + private const val TAG = "MainActivity" + } +} \ No newline at end of file diff --git a/app/src/main/java/website/kumi/matrixsms/MatrixClient.kt b/app/src/main/java/website/kumi/matrixsms/MatrixClient.kt new file mode 100644 index 0000000..f54320b --- /dev/null +++ b/app/src/main/java/website/kumi/matrixsms/MatrixClient.kt @@ -0,0 +1,319 @@ +package website.kumi.matrixsms + +import android.util.Log +import com.google.gson.FieldNamingPolicy +import com.google.gson.GsonBuilder +import com.google.gson.annotations.SerializedName +import kotlinx.coroutines.delay +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import okhttp3.OkHttpClient +import website.kumi.matrixsms.api.MatrixApi +import website.kumi.matrixsms.api.MatrixEvent +import website.kumi.matrixsms.api.MatrixMessage +import website.kumi.matrixsms.api.PhoneNumberStateContent +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +class MatrixClient( + private val homeserverUrl: String, + private val accessToken: String, + private val recipientUserId: String, + private val configRepository: ConfigRepository +) { + private val gson = GsonBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .setLenient() + .create() + + private val retrofit = Retrofit.Builder() + .baseUrl(homeserverUrl) + .addConverterFactory(GsonConverterFactory.create(gson)) + .client(OkHttpClient.Builder() + .addInterceptor { chain -> + val request = chain.request().newBuilder() + .addHeader("Authorization", "Bearer $accessToken") + .build() + chain.proceed(request) + } + .build()) + .build() + + private val matrixApi = retrofit.create(MatrixApi::class.java) + private val lastEventIds: ConcurrentHashMap = + ConcurrentHashMap(configRepository.loadLastEventIds()) + + private suspend fun withRetry( + maxRetries: Int = 3, + initialDelayMillis: Long = 1000, + maxDelayMillis: Long = 10000, + factor: Double = 2.0, + block: suspend () -> T + ): T { + var currentDelay = initialDelayMillis + repeat(maxRetries) { attempt -> + try { + return block() + } catch (e: Exception) { + // If this is the last attempt, throw the exception + if (attempt == maxRetries - 1) throw e + + Log.w(TAG, "API call failed (attempt ${attempt + 1}/$maxRetries), retrying in ${currentDelay}ms", e) + + // Wait before next retry + delay(currentDelay) + + // Increase delay for next retry with exponential backoff + currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelayMillis) + } + } + + // This should never be reached due to the throw in the catch block + throw IllegalStateException("Retry mechanism failed") + } + + suspend fun createRoom( + roomName: String, + topic: String? = null, + isDirect: Boolean = true + ): String { + val createRoomRequest = CreateRoomRequest( + name = roomName, + topic = topic, + invite = listOf(recipientUserId), + isDirect = isDirect + ) + + val response = matrixApi.createRoom(createRoomRequest) + Log.i(TAG, "Room created with ID: ${response.roomId}") + Log.d(TAG, "Room creation response: $response") + return response.roomId!! + } + + suspend fun sendTextMessage( + roomId: String, + content: String, + sender: String = "SMS Bridge", // Default sender name for management messages + msgtype: String = "m.text", + format: String? = null, + formattedBody: String? = null + ): String { + val txnId = UUID.randomUUID().toString() + val message = if (format != null && formattedBody != null) { + FormattedTextMessageContent( + msgtype = msgtype, + body = content, + format = format, + formattedBody = formattedBody + ) + } else { + TextMessageContent( + msgtype = msgtype, + body = content + ) + } + + val response = matrixApi.sendMessage(roomId, "m.room.message", txnId, message) + return response.eventId!! + } + + suspend fun sendSmsTextMessage( + roomId: String, + content: String, + sender: String + ): String { + val txnId = UUID.randomUUID().toString() + val message = TextMessageContent( + msgtype = "m.text", + body = content + ) + + val response = matrixApi.sendMessage(roomId, "m.room.message", txnId, message) + return response.eventId!! + } + + suspend fun setRoomStateEvent( + roomId: String, + eventType: String, + stateKey: String, + content: Any + ) { + matrixApi.sendStateEvent(roomId, eventType, stateKey, content) + } + + suspend fun getRoomStateEvent( + roomId: String, + eventType: String, + stateKey: String + ): Any? { + return try { + withRetry { + Log.d(TAG, "Getting state event $eventType from room $roomId") + val response = matrixApi.getStateEvent(roomId, eventType, stateKey) + Log.d(TAG, "Got state event: $response") + response + } + } catch (e: Exception) { + Log.e(TAG, "Failed to get state event $eventType from room $roomId", e) + null + } + } + + suspend fun getAllRoomState(roomId: String): List { + return try { + withRetry { + val response = matrixApi.getRoomState(roomId) + Log.d(TAG, "Got ${response.size} state events for room $roomId") + response + } + } catch (e: Exception) { + Log.e(TAG, "Failed to get state events for room $roomId", e) + emptyList() + } + } + + suspend fun setRoomPhoneNumberState(roomId: String, phoneNumber: String) { + val stateContent = PhoneNumberStateContent(phoneNumber) + setRoomStateEvent( + roomId = roomId, + eventType = "org.matrix.sms_bridge.phone_number", + stateKey = "", + content = stateContent + ) + } + + suspend fun leaveRoom(roomId: String): Boolean { + return try { + withRetry { + matrixApi.leaveRoom(roomId) + true + } + } catch (e: Exception) { + Log.e(TAG, "Failed to leave room $roomId", e) + false + } + } + + suspend fun getRoomPhoneNumberState(roomId: String): String? { + val stateEvent = getRoomStateEvent( + roomId = roomId, + eventType = "org.matrix.sms_bridge.phone_number", + stateKey = "" + ) as? PhoneNumberStateContent + + return stateEvent?.phoneNumber + } + + suspend fun getJoinedRooms(): List { + return try { + withRetry { + val response = matrixApi.getJoinedRooms() + response.joinedRooms ?: emptyList() + } + } catch (e: Exception) { + Log.e(TAG, "Failed to get joined rooms after retries", e) + emptyList() + } + } + + suspend fun getNewMessages(roomId: String): List { + return try { + withRetry { + // Load the 'since' token specifically for this call from the map + val since = lastEventIds[roomId] + Log.d(TAG, "Getting messages for room $roomId since ${since ?: "beginning"}") + + val response = matrixApi.getRoomMessages( + roomId = roomId, + since = since, + direction = "b", + limit = 50 + ) + + // Check response validity before updating + val newEndToken = response.end + val receivedChunk = response.chunk ?: emptyList() + + if (newEndToken != null && receivedChunk.isNotEmpty()) { + if (lastEventIds[roomId] != newEndToken) { + Log.d(TAG, "Got ${receivedChunk.size} messages, updating last event ID for $roomId from ${lastEventIds[roomId]} to $newEndToken") + lastEventIds[roomId] = newEndToken + configRepository.saveLastEventIds(lastEventIds) + } else { + Log.d(TAG, "Got ${receivedChunk.size} messages, but end token $newEndToken is same as stored. No update needed.") + } + } else if (receivedChunk.isEmpty()) { + Log.d(TAG, "No new messages for room $roomId.") + } else { + Log.w(TAG, "Received messages for room $roomId but no 'end' token in response. Cannot update lastEventId.") + } + + // Filter for message events only + val messageEvents = receivedChunk.filter { it.type == "m.room.message" } + Log.d(TAG, "Filtered to ${messageEvents.size} message events") + + messageEvents + } + } catch (e: Exception) { + Log.e(TAG, "Failed to get messages for room $roomId", e) + emptyList() + } + } + + fun resetLastEventId(roomId: String) { + lastEventIds.remove(roomId) + configRepository.saveLastEventIds(lastEventIds) + Log.d(TAG, "Reset and persisted last event ID for room $roomId") + } + + fun resetAllLastEventIds() { + lastEventIds.clear() + configRepository.saveLastEventIds(lastEventIds) + Log.d(TAG, "Reset and persisted all last event IDs") + } + + suspend fun getRoomCanonicalAlias(roomId: String): String? { + return try { + val response = matrixApi.getRoomState(roomId, "m.room.canonical_alias", "") + (response as? Map<*, *>)?.get("alias") as? String + } catch (e: Exception) { + null + } + } + + // Data classes for API requests/responses + data class CreateRoomRequest( + val name: String, + val topic: String? = null, + val invite: List, + @SerializedName("is_direct") + val isDirect: Boolean + ) + + data class TextMessageContent( + val msgtype: String, + val body: String + ) + + data class FormattedTextMessageContent( + val msgtype: String, + val body: String, + val format: String, + @SerializedName("formatted_body") + val formattedBody: String + ) + + data class PhoneNumberStateContent( + @SerializedName("phone_number") + val phoneNumber: String + ) + + data class ManagementRoomState( + @SerializedName("is_management_room") + val isManagementRoom: Boolean = true + ) + + companion object { + private const val TAG = "MatrixClient" + } +} \ No newline at end of file diff --git a/app/src/main/java/website/kumi/matrixsms/RoomMapper.kt b/app/src/main/java/website/kumi/matrixsms/RoomMapper.kt new file mode 100644 index 0000000..500c01b --- /dev/null +++ b/app/src/main/java/website/kumi/matrixsms/RoomMapper.kt @@ -0,0 +1,43 @@ +package website.kumi.matrixsms + +import android.content.Context +import androidx.room.Room +import website.kumi.matrixsms.db.AppDatabase +import website.kumi.matrixsms.db.RoomMapping + +class RoomMapper(context: Context) { + private val database = Room.databaseBuilder( + context, + AppDatabase::class.java, + "matrix-sms-bridge-db" + ).build() + + private val roomMappingDao = database.roomMappingDao() + + suspend fun getRoomForPhoneNumber(phoneNumber: String): String? { + return roomMappingDao.getRoomIdForPhoneNumber(phoneNumber) + } + + suspend fun getPhoneNumberForRoom(roomId: String): String? { + return roomMappingDao.getPhoneNumberForRoomId(roomId) + } + + suspend fun saveMapping(phoneNumber: String, roomId: String) { + val mapping = RoomMapping(phoneNumber = phoneNumber, roomId = roomId) + roomMappingDao.insert(mapping) + } + + suspend fun syncMappingsFromMatrix(matrixClient: MatrixClient) { + val rooms = matrixClient.getJoinedRooms() + for (roomId in rooms) { + val phoneNumber = matrixClient.getRoomPhoneNumberState(roomId) + if (phoneNumber != null) { + saveMapping(phoneNumber, roomId) + } + } + } + + suspend fun getAllMappings(): List { + return roomMappingDao.getAllMappings() + } +} \ No newline at end of file diff --git a/app/src/main/java/website/kumi/matrixsms/SmsReceiver.kt b/app/src/main/java/website/kumi/matrixsms/SmsReceiver.kt new file mode 100644 index 0000000..cc946af --- /dev/null +++ b/app/src/main/java/website/kumi/matrixsms/SmsReceiver.kt @@ -0,0 +1,38 @@ +package website.kumi.matrixsms + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.provider.Telephony +import androidx.core.content.ContextCompat +import android.app.ActivityManager + +class SmsReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Telephony.Sms.Intents.SMS_RECEIVED_ACTION) { + // Check if BridgeService is running + if (!isServiceRunning(context, BridgeService::class.java)) { + // Start the service if it's not running + val serviceIntent = Intent(context, BridgeService::class.java) + serviceIntent.action = BridgeService.ACTION_START_SERVICE + ContextCompat.startForegroundService(context, serviceIntent) + } + + // Forward the intent to BridgeService + val forwardIntent = Intent(context, BridgeService::class.java) + forwardIntent.action = BridgeService.ACTION_SMS_RECEIVED + forwardIntent.putExtras(intent) + context.startService(forwardIntent) + } + } + + private fun isServiceRunning(context: Context, serviceClass: Class<*>): Boolean { + val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + return manager.getRunningServices(Integer.MAX_VALUE) + .any { it.service.className == serviceClass.name } + } + + companion object { + const val ACTION_SMS_RECEIVED = "website.kumi.matrixsms.SMS_RECEIVED" + } +} \ No newline at end of file diff --git a/app/src/main/java/website/kumi/matrixsms/api/MatrixApi.kt b/app/src/main/java/website/kumi/matrixsms/api/MatrixApi.kt new file mode 100644 index 0000000..6b0a37d --- /dev/null +++ b/app/src/main/java/website/kumi/matrixsms/api/MatrixApi.kt @@ -0,0 +1,177 @@ +package website.kumi.matrixsms.api + +import com.google.gson.JsonElement +import com.google.gson.annotations.SerializedName +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query +import website.kumi.matrixsms.MatrixClient + +interface MatrixApi { + @POST("_matrix/client/r0/createRoom") + suspend fun createRoom(@Body createRoomRequest: MatrixClient.CreateRoomRequest): CreateRoomResponse + + @POST("_matrix/client/r0/rooms/{roomId}/leave") + suspend fun leaveRoom(@Path("roomId") roomId: String) + + @PUT("_matrix/client/r0/rooms/{roomId}/send/{eventType}/{txnId}") + suspend fun sendMessage( + @Path("roomId") roomId: String, + @Path("eventType") eventType: String, + @Path("txnId") txnId: String, + @Body content: Any + ): SendEventResponse + + @PUT("_matrix/client/r0/rooms/{roomId}/state/{eventType}/{stateKey}") + suspend fun sendStateEvent( + @Path("roomId") roomId: String, + @Path("eventType") eventType: String, + @Path("stateKey") stateKey: String, + @Body content: Any + ): SendEventResponse + + @GET("_matrix/client/r0/rooms/{roomId}/state/{eventType}/{stateKey}") + suspend fun getStateEvent( + @Path("roomId") roomId: String, + @Path("eventType") eventType: String, + @Path("stateKey") stateKey: String + ): PhoneNumberStateContent + + @GET("_matrix/client/r0/joined_rooms") + suspend fun getJoinedRooms(): JoinedRoomsResponse + + @GET("_matrix/client/r0/rooms/{roomId}/messages") + suspend fun getRoomMessages( + @Path("roomId") roomId: String, + @Query("from") since: String? = null, + @Query("dir") direction: String = "b", + @Query("limit") limit: Int = 50 + ): RoomMessagesResponse + + @GET("_matrix/client/r0/rooms/{roomId}/state") + suspend fun getRoomState( + @Path("roomId") roomId: String + ): List + + @GET("_matrix/client/r0/rooms/{roomId}/state/{eventType}/{stateKey}") + suspend fun getRoomState( + @Path("roomId") roomId: String, + @Path("eventType") eventType: String, + @Path("stateKey") stateKey: String + ): Any? + + @POST("_matrix/client/r0/rooms/{roomId}/join") + suspend fun joinRoom( + @Path("roomId") roomId: String, + ): JoinRoomResponse +} + +// Response data classes +data class CreateRoomResponse( + @SerializedName("room_id") + val roomId: String? = null +) + +data class SendEventResponse( + @SerializedName("event_id") + val eventId: String? = null +) + +data class JoinedRoomsResponse( + @SerializedName("joined_rooms") + val joinedRooms: List? = null +) + +data class InvitedRoomsResponse( + @SerializedName("invited_rooms") + val invitedRooms: List? = null +) + +data class RoomMessagesResponse( + val chunk: List? = null, + val start: String? = null, + val end: String? = null +) + +data class MatrixEvent( + @SerializedName("type") + val type: String, + + @SerializedName("content") + val content: JsonElement, + + @SerializedName("event_id") + val eventId: String, + + @SerializedName("sender") + val sender: String, + + @SerializedName("room_id") + val roomId: String, + + @SerializedName("origin_server_ts") + val originServerTs: Long +) { + // Helper method to extract text content from different message types + fun getTextContent(): String? { + return try { + if (type == "m.room.message") { + val contentObj = content.asJsonObject + if (contentObj.has("body")) { + return contentObj.get("body").asString + } + } + null + } catch (e: Exception) { + null + } + } + + // Helper method to determine if this is a text message + fun isTextMessage(): Boolean { + return try { + if (type == "m.room.message") { + val contentObj = content.asJsonObject + if (contentObj.has("msgtype")) { + val msgtype = contentObj.get("msgtype").asString + return msgtype == "m.text" || msgtype == "m.notice" + } + } + false + } catch (e: Exception) { + false + } + } +} + +data class MatrixMessage( + @SerializedName("type") + val type: String, + + @SerializedName("content") + val content: String, + + @SerializedName("event_id") + val eventId: String, + + @SerializedName("sender") + val sender: String, + + @SerializedName("room_id") + val roomId: String, + + @SerializedName("origin_server_ts") + val originServerTs: Long +) + +data class JoinRoomResponse( + @SerializedName("room_id") + val roomId: String? = null +) + +data class PhoneNumberStateContent( + val phoneNumber: String +) \ No newline at end of file diff --git a/app/src/main/java/website/kumi/matrixsms/db/AppDatabase.kt b/app/src/main/java/website/kumi/matrixsms/db/AppDatabase.kt new file mode 100644 index 0000000..76b8451 --- /dev/null +++ b/app/src/main/java/website/kumi/matrixsms/db/AppDatabase.kt @@ -0,0 +1,28 @@ +package website.kumi.matrixsms.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +@Database(entities = [RoomMapping::class], version = 1) +abstract class AppDatabase : RoomDatabase() { + abstract fun roomMappingDao(): RoomMappingDao + + companion object { + @Volatile + private var INSTANCE: AppDatabase? = null + + fun getDatabase(context: Context): AppDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "matrix_sms_bridge_database" + ).build() + INSTANCE = instance + instance + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/website/kumi/matrixsms/db/RoomMapping.kt b/app/src/main/java/website/kumi/matrixsms/db/RoomMapping.kt new file mode 100644 index 0000000..4bdf9e1 --- /dev/null +++ b/app/src/main/java/website/kumi/matrixsms/db/RoomMapping.kt @@ -0,0 +1,10 @@ +package website.kumi.matrixsms.db + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "room_mappings") +data class RoomMapping( + @PrimaryKey val phoneNumber: String, + val roomId: String +) \ No newline at end of file diff --git a/app/src/main/java/website/kumi/matrixsms/db/RoomMappingDao.kt b/app/src/main/java/website/kumi/matrixsms/db/RoomMappingDao.kt new file mode 100644 index 0000000..f459b45 --- /dev/null +++ b/app/src/main/java/website/kumi/matrixsms/db/RoomMappingDao.kt @@ -0,0 +1,24 @@ +package website.kumi.matrixsms.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface RoomMappingDao { + @Query("SELECT roomId FROM room_mappings WHERE phoneNumber = :phoneNumber") + suspend fun getRoomIdForPhoneNumber(phoneNumber: String): String? + + @Query("SELECT phoneNumber FROM room_mappings WHERE roomId = :roomId") + suspend fun getPhoneNumberForRoomId(roomId: String): String? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(mapping: RoomMapping) + + @Query("DELETE FROM room_mappings WHERE phoneNumber = :phoneNumber") + suspend fun deleteByPhoneNumber(phoneNumber: String) + + @Query("SELECT * FROM room_mappings") + suspend fun getAllMappings(): List +} \ No newline at end of file diff --git a/app/src/main/java/website/kumi/matrixsms/ui/BridgeConfigScreen.kt b/app/src/main/java/website/kumi/matrixsms/ui/BridgeConfigScreen.kt new file mode 100644 index 0000000..22d0883 --- /dev/null +++ b/app/src/main/java/website/kumi/matrixsms/ui/BridgeConfigScreen.kt @@ -0,0 +1,455 @@ +package website.kumi.matrixsms.ui + +import android.content.Context +import android.app.ActivityManager +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Message +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.outlined.Contacts +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Sms +import androidx.compose.material.icons.outlined.Timer +import androidx.compose.material.icons.rounded.Message +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import website.kumi.matrixsms.BridgeService +import website.kumi.matrixsms.viewmodels.ConfigViewModel +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.input.PasswordVisualTransformation +import kotlinx.coroutines.delay + + +@Composable +fun BridgeConfigScreen( + configViewModel: ConfigViewModel, + onSaveConfig: () -> Unit, + onStopService: () -> Unit +) { + val config by configViewModel.config.collectAsState() + + var homeserverUrl by remember { mutableStateOf(config.homeserverUrl) } + var accessToken by remember { mutableStateOf(config.accessToken) } + var recipientUserId by remember { mutableStateOf(config.recipientUserId) } + var pollingInterval by remember { mutableStateOf(config.pollingInterval.toString()) } + + // Update state when config changes + LaunchedEffect(config) { + homeserverUrl = config.homeserverUrl + accessToken = config.accessToken + recipientUserId = config.recipientUserId + pollingInterval = config.pollingInterval.toString() + } + + // Check if service is running (use recomposition to update UI) + val isServiceRunning = isServiceRunning() + + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Header Section + HeaderSection(isServiceRunning) + + // Divider + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + thickness = 1.dp, + modifier = Modifier.padding(vertical = 8.dp) + ) + + // Configuration Form + ConfigurationForm( + homeserverUrl = homeserverUrl, + accessToken = accessToken, + recipientUserId = recipientUserId, + pollingInterval = pollingInterval, + onHomeserverUrlChange = { homeserverUrl = it }, + onAccessTokenChange = { accessToken = it }, + onRecipientUserIdChange = { recipientUserId = it }, + onPollingIntervalChange = { pollingInterval = it } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Action Buttons + ActionButtons( + isServiceRunning = isServiceRunning, + onSaveConfig = { + configViewModel.saveConfig( + homeserverUrl = homeserverUrl, + accessToken = accessToken, + recipientUserId = recipientUserId, + pollingInterval = pollingInterval.toLongOrNull() ?: 30 + ) + onSaveConfig() + }, + onStopService = onStopService + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Help Section + HelpSection() + } + } +} + +@Composable +private fun HeaderSection(isServiceRunning: Boolean) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // App Logo + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.Message, + contentDescription = "Matrix SMS Bridge", + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(48.dp) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // App Title + Text( + text = "Matrix SMS Bridge", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Status Indicator + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background( + if (isServiceRunning) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.errorContainer + ) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background( + if (isServiceRunning) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.error + ) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = if (isServiceRunning) "Bridge Active" else "Bridge Inactive", + style = MaterialTheme.typography.labelMedium, + color = if (isServiceRunning) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onErrorContainer + ) + } + } +} + +@Composable +private fun ConfigurationForm( + homeserverUrl: String, + accessToken: String, + recipientUserId: String, + pollingInterval: String, + onHomeserverUrlChange: (String) -> Unit, + onAccessTokenChange: (String) -> Unit, + onRecipientUserIdChange: (String) -> Unit, + onPollingIntervalChange: (String) -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Bridge Configuration", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + + // Homeserver URL + OutlinedTextField( + value = homeserverUrl, + onValueChange = onHomeserverUrlChange, + label = { Text("Homeserver URL") }, + placeholder = { Text("https://matrix.example.org") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + leadingIcon = { + Icon( + imageVector = Icons.Outlined.Language, + contentDescription = null + ) + } + ) + + // Access Token + OutlinedTextField( + value = accessToken, + onValueChange = onAccessTokenChange, + label = { Text("Access Token") }, + placeholder = { Text("syt_...") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + leadingIcon = { + Icon( + imageVector = Icons.Outlined.Key, + contentDescription = null + ) + } + ) + + // Recipient User ID + OutlinedTextField( + value = recipientUserId, + onValueChange = onRecipientUserIdChange, + label = { Text("Recipient User ID") }, + placeholder = { Text("@user:example.org") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + leadingIcon = { + Icon( + imageVector = Icons.Outlined.Person, + contentDescription = null + ) + } + ) + + // Polling Interval + OutlinedTextField( + value = pollingInterval, + onValueChange = onPollingIntervalChange, + label = { Text("Polling Interval (seconds)") }, + placeholder = { Text("30") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + leadingIcon = { + Icon( + imageVector = Icons.Outlined.Timer, + contentDescription = null + ) + } + ) + } + } +} + +@Composable +private fun ActionButtons( + isServiceRunning: Boolean, + onSaveConfig: () -> Unit, + onStopService: () -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = onSaveConfig, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon( + imageVector = Icons.Filled.Save, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Save and ${if (isServiceRunning) "Restart" else "Start"} Bridge") + } + + if (isServiceRunning) { + OutlinedButton( + onClick = onStopService, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error) + ) { + Icon( + imageVector = Icons.Filled.Stop, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Stop Bridge") + } + } + } +} + +@Composable +private fun HelpSection() { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "How It Works", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "This app bridges SMS/MMS messages to Matrix and vice versa. Each contact gets their own Matrix room, and messages are synchronized in both directions.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Required Permissions", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + + Spacer(modifier = Modifier.height(4.dp)) + + PermissionItem( + icon = Icons.Outlined.Sms, + text = "SMS permissions to read and send messages" + ) + + PermissionItem( + icon = Icons.Outlined.Contacts, + text = "Contacts permission to show contact names" + ) + } + } +} + +@Composable +private fun PermissionItem(icon: ImageVector, text: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f), + modifier = Modifier.size(16.dp) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) + ) + } +} + +@Composable +private fun isServiceRunning(): Boolean { + val context = LocalContext.current + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + + // State for service status + var isRunning by remember { mutableStateOf(false) } + + // Check service status periodically + LaunchedEffect(Unit) { + while (true) { + isRunning = activityManager.getRunningServices(Integer.MAX_VALUE) + .any { it.service.className == BridgeService::class.java.name } + delay(1000) // Check every second + } + } + + return isRunning +} \ No newline at end of file diff --git a/app/src/main/java/website/kumi/matrixsms/ui/theme/Color.kt b/app/src/main/java/website/kumi/matrixsms/ui/theme/Color.kt new file mode 100644 index 0000000..d5de2d4 --- /dev/null +++ b/app/src/main/java/website/kumi/matrixsms/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package website.kumi.matrixsms.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/website/kumi/matrixsms/ui/theme/Theme.kt b/app/src/main/java/website/kumi/matrixsms/ui/theme/Theme.kt new file mode 100644 index 0000000..95281c2 --- /dev/null +++ b/app/src/main/java/website/kumi/matrixsms/ui/theme/Theme.kt @@ -0,0 +1,203 @@ +package website.kumi.matrixsms.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.core.view.WindowCompat + +// Matrix brand colors +val MatrixGreen = Color(0xFF0DBD8B) +val MatrixBlue = Color(0xFF368BD6) +val MatrixPurple = Color(0xFF7B1FA2) + +// Light theme colors +private val LightColorScheme = lightColorScheme( + primary = MatrixGreen, + onPrimary = Color.White, + primaryContainer = Color(0xFFCCF3E8), + onPrimaryContainer = Color(0xFF002116), + + secondary = MatrixBlue, + onSecondary = Color.White, + secondaryContainer = Color(0xFFD7E5F5), + onSecondaryContainer = Color(0xFF0A1A2E), + + tertiary = MatrixPurple, + onTertiary = Color.White, + tertiaryContainer = Color(0xFFF3D8F4), + onTertiaryContainer = Color(0xFF2A0F2F), + + background = Color(0xFFFAFAFA), + onBackground = Color(0xFF1A1C1E), + surface = Color(0xFFFAFAFA), + onSurface = Color(0xFF1A1C1E), + + error = Color(0xFFB3261E), + onError = Color.White, + errorContainer = Color(0xFFF9DEDC), + onErrorContainer = Color(0xFF410E0B) +) + +// Dark theme colors +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFF4DD0A5), + onPrimary = Color(0xFF003828), + primaryContainer = Color(0xFF00513C), + onPrimaryContainer = Color(0xFF8FF8D3), + + secondary = Color(0xFF82B7ED), + onSecondary = Color(0xFF0F2E4C), + secondaryContainer = Color(0xFF284468), + onSecondaryContainer = Color(0xFFD7E5F5), + + tertiary = Color(0xFFD8A7DE), + onTertiary = Color(0xFF3B1844), + tertiaryContainer = Color(0xFF533060), + onTertiaryContainer = Color(0xFFF3D8F4), + + background = Color(0xFF1A1C1E), + onBackground = Color(0xFFE2E2E5), + surface = Color(0xFF1A1C1E), + onSurface = Color(0xFFE2E2E5), + + error = Color(0xFFF2B8B5), + onError = Color(0xFF601410), + errorContainer = Color(0xFF8C1D18), + onErrorContainer = Color(0xFFF9DEDC) +) + +// Typography +val AppTypography = Typography( + headlineLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp + ), + headlineSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + titleMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + titleSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + bodySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp + ), + labelLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + labelMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) +) + +@Composable +fun MatrixSmsBridgeTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/website/kumi/matrixsms/ui/theme/Type.kt b/app/src/main/java/website/kumi/matrixsms/ui/theme/Type.kt new file mode 100644 index 0000000..4e87a40 --- /dev/null +++ b/app/src/main/java/website/kumi/matrixsms/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package website.kumi.matrixsms.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/website/kumi/matrixsms/viewmodels/ConfigViewModel.kt b/app/src/main/java/website/kumi/matrixsms/viewmodels/ConfigViewModel.kt new file mode 100644 index 0000000..6efacb5 --- /dev/null +++ b/app/src/main/java/website/kumi/matrixsms/viewmodels/ConfigViewModel.kt @@ -0,0 +1,56 @@ +package website.kumi.matrixsms.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import website.kumi.matrixsms.ConfigRepository + +class ConfigViewModel(application: Application) : AndroidViewModel(application) { + private val configRepository = ConfigRepository(application) + + private val _config = MutableStateFlow(ConfigData()) + val config: StateFlow = _config.asStateFlow() + + init { + loadConfig() + } + + private fun loadConfig() { + viewModelScope.launch { + _config.value = ConfigData( + homeserverUrl = configRepository.getHomeserverUrl(), + accessToken = configRepository.getAccessToken(), + recipientUserId = configRepository.getRecipientUserId(), + pollingInterval = configRepository.getPollingInterval() + ) + } + } + + fun saveConfig( + homeserverUrl: String, + accessToken: String, + recipientUserId: String, + pollingInterval: Long + ) { + viewModelScope.launch { + configRepository.saveConfig( + homeserverUrl, + accessToken, + recipientUserId, + pollingInterval + ) + loadConfig() + } + } +} + +data class ConfigData( + val homeserverUrl: String = "", + val accessToken: String = "", + val recipientUserId: String = "", + val pollingInterval: Long = 30 +) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..f5789b2 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..f5789b2 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..7ed287a --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + MatrixSMS + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..9663b4b --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +