feat: Current version

This commit is contained in:
Kumi 2025-05-20 19:17:03 +02:00
commit 0428ab7113
60 changed files with 3322 additions and 0 deletions

15
.gitignore vendored Normal file
View file

@ -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

3
.idea/.gitignore generated vendored Normal file
View file

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

6
.idea/AndroidProjectSystem.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

10
.idea/deploymentTargetSelector.xml generated Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

19
.idea/gradle.xml generated Normal file
View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

6
.idea/kotlinc.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.0.21" />
</component>
</project>

10
.idea/migrations.xml generated Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

9
.idea/misc.xml generated Normal file
View file

@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

17
.idea/runConfigurations.xml generated Normal file
View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

1
app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

97
app/build.gradle.kts Normal file
View file

@ -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)
}

21
app/proguard-rules.pro vendored Normal file
View file

@ -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

View file

@ -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)
}
}

View file

@ -0,0 +1,51 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="website.kumi.matrixsms">
<uses-feature
android:name="android.hardware.telephony"
android:required="true"
tools:ignore="UnnecessaryRequiredFeature" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MatrixSMS">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".BridgeService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
<receiver
android:name=".SmsReceiver"
android:exported="true"
android:permission="android.permission.BROADCAST_SMS">
<intent-filter>
<action android:name="android.provider.Telephony.SMS_RECEIVED" />
</intent-filter>
</receiver>
</application>
</manifest>

View file

@ -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("# ", "<h1>").replace("\n\n", "</h1>")
.replace("- ", "<li>").replace("\n", "</li>\n<li>")
.replace("`", "<code>").replace("`", "</code>")
}
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
)

View file

@ -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<String, String>) {
val jsonString = gson.toJson(tokens)
sharedPreferences.edit() { putString(KEY_LAST_EVENT_IDS, jsonString) }
}
fun loadLastEventIds(): Map<String, String> {
val jsonString = sharedPreferences.getString(KEY_LAST_EVENT_IDS, null)
return if (jsonString != null) {
try {
val type = object : TypeToken<Map<String, String>>() {}.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"
}
}

View file

@ -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
}
}
}

View file

@ -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<String>): 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"
}
}

View file

@ -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<String, String> =
ConcurrentHashMap(configRepository.loadLastEventIds())
private suspend fun <T> 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<Any> {
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<String> {
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<MatrixEvent> {
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<String>,
@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"
}
}

View file

@ -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<RoomMapping> {
return roomMappingDao.getAllMappings()
}
}

View file

@ -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"
}
}

View file

@ -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<Any>
@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<String>? = null
)
data class InvitedRoomsResponse(
@SerializedName("invited_rooms")
val invitedRooms: List<String>? = null
)
data class RoomMessagesResponse(
val chunk: List<MatrixEvent>? = 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
)

View file

@ -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
}
}
}
}

View file

@ -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
)

View file

@ -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<RoomMapping>
}

View file

@ -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
}

View file

@ -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)

View file

@ -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
)
}

View file

@ -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
)
*/
)

View file

@ -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<ConfigData> = _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
)

View file

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View file

@ -0,0 +1,11 @@
<!-- res/drawable/ic_notification.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#FFFFFF"
android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM9,11L7,11L7,9h2v2zM13,11h-2L11,9h2v2zM17,11h-2L15,9h2v2z"/>
</vector>

View file

@ -0,0 +1,11 @@
<!-- res/drawable/ic_notification.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#FFFFFF"
android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM9,11L7,11L7,9h2v2zM13,11h-2L11,9h2v2zM17,11h-2L15,9h2v2z"/>
</vector>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">MatrixSMS</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.MatrixSMS" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View file

@ -0,0 +1,17 @@
package website.kumi.matrixsms
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

6
build.gradle.kts Normal file
View file

@ -0,0 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}

23
gradle.properties Normal file
View file

@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

53
gradle/libs.versions.toml Normal file
View file

@ -0,0 +1,53 @@
[versions]
agp = "8.9.0"
converterGson = "2.11.0"
kotlin = "2.0.21"
coreKtx = "1.16.0"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
libphonenumber = "8.13.15"
lifecycleLivedataKtx = "2.8.7"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.10.1"
composeBom = "2024.09.00"
materialIconsCore = "1.7.8"
okhttp = "4.12.0"
retrofit = "2.11.0"
roomCompiler = "2.7.0"
roomKtx = "2.7.0"
roomRuntime = "2.7.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleLivedataKtx" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleLivedataKtx" }
androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "materialIconsCore" }
androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsCore" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomCompiler" }
androidx-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" }
converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
libphonenumber = { module = "com.googlecode.libphonenumber:libphonenumber", version.ref = "libphonenumber" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
retrofit2-retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomKtx" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,6 @@
#Mon Apr 21 14:05:21 CEST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

185
gradlew vendored Executable file
View file

@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

89
gradlew.bat vendored Normal file
View file

@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

24
settings.gradle.kts Normal file
View file

@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "MatrixSMS"
include(":app")