Repository: nizarmah/igatha Branch: main Commit: 5b3a4f3be121 Files: 128 Total size: 294.1 KB Directory structure: gitextract_sbi5etug/ ├── LICENSE ├── PRIVACY.md ├── README.md ├── android/ │ ├── .gitignore │ ├── .gitkeep │ ├── .idea/ │ │ ├── .gitignore │ │ ├── .name │ │ ├── AndroidProjectSystem.xml │ │ ├── compiler.xml │ │ ├── deploymentTargetSelector.xml │ │ ├── deviceManager.xml │ │ ├── gradle.xml │ │ ├── inspectionProfiles/ │ │ │ └── Project_Default.xml │ │ ├── kotlinc.xml │ │ ├── migrations.xml │ │ ├── misc.xml │ │ ├── runConfigurations.xml │ │ └── vcs.xml │ ├── app/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── proguard-rules.pro │ │ └── src/ │ │ ├── androidTest/ │ │ │ └── java/ │ │ │ └── com/ │ │ │ └── nizarmah/ │ │ │ └── igatha/ │ │ │ └── ExampleInstrumentedTest.kt │ │ ├── main/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── java/ │ │ │ │ └── com/ │ │ │ │ └── nizarmah/ │ │ │ │ └── igatha/ │ │ │ │ ├── Constants.kt │ │ │ │ ├── IgathaApp.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── model/ │ │ │ │ │ └── Device.kt │ │ │ │ ├── sensor/ │ │ │ │ │ ├── AcceleratorSensor.kt │ │ │ │ │ ├── BarometerSensor.kt │ │ │ │ │ ├── GyroscopeSensor.kt │ │ │ │ │ └── SensorType.kt │ │ │ │ ├── service/ │ │ │ │ │ ├── DisasterDetectionService.kt │ │ │ │ │ ├── DisasterDetector.kt │ │ │ │ │ ├── DisasterEventBus.kt │ │ │ │ │ ├── EmergencyManager.kt │ │ │ │ │ ├── FeedbackWorker.kt │ │ │ │ │ ├── ProximityScanner.kt │ │ │ │ │ ├── SOSBeacon.kt │ │ │ │ │ ├── SOSService.kt │ │ │ │ │ └── SirenPlayer.kt │ │ │ │ ├── ui/ │ │ │ │ │ ├── component/ │ │ │ │ │ │ ├── FeedbackButtonView.kt │ │ │ │ │ │ ├── PermissionHandler.kt │ │ │ │ │ │ ├── PersistentBanner.kt │ │ │ │ │ │ └── Section.kt │ │ │ │ │ ├── screen/ │ │ │ │ │ │ ├── ContentScreen.kt │ │ │ │ │ │ ├── FeedbackFormScreen.kt │ │ │ │ │ │ └── SettingsScreen.kt │ │ │ │ │ ├── theme/ │ │ │ │ │ │ ├── Color.kt │ │ │ │ │ │ ├── Theme.kt │ │ │ │ │ │ └── Type.kt │ │ │ │ │ └── view/ │ │ │ │ │ ├── ContentView.kt │ │ │ │ │ ├── DeviceDetailView.kt │ │ │ │ │ ├── DeviceListView.kt │ │ │ │ │ ├── DeviceRowView.kt │ │ │ │ │ ├── FeedbackFormView.kt │ │ │ │ │ └── SettingsView.kt │ │ │ │ ├── util/ │ │ │ │ │ ├── PermissionsHelper.kt │ │ │ │ │ ├── PermissionsManager.kt │ │ │ │ │ └── SettingsManager.kt │ │ │ │ └── viewmodel/ │ │ │ │ ├── ContentViewModel.kt │ │ │ │ ├── FeedbackFormViewModel.kt │ │ │ │ ├── FeedbackFormViewModelFactory.kt │ │ │ │ ├── SettingsViewModel.kt │ │ │ │ └── SettingsViewModelFactory.kt │ │ │ └── res/ │ │ │ ├── drawable/ │ │ │ │ ├── ic_im_okay.xml │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ ├── ic_launcher_foreground.xml │ │ │ │ ├── ic_need_help.xml │ │ │ │ ├── ic_notification.xml │ │ │ │ ├── ic_notification_alerting.xml │ │ │ │ ├── ic_notification_signaling.xml │ │ │ │ └── ic_stop_sos.xml │ │ │ ├── mipmap-anydpi-v26/ │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── values/ │ │ │ │ ├── colors.xml │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ ├── strings.xml │ │ │ │ └── themes.xml │ │ │ └── xml/ │ │ │ ├── backup_rules.xml │ │ │ └── data_extraction_rules.xml │ │ └── test/ │ │ └── java/ │ │ └── com/ │ │ └── nizarmah/ │ │ └── igatha/ │ │ └── ExampleUnitTest.kt │ ├── build.gradle.kts │ ├── gradle/ │ │ ├── libs.versions.toml │ │ └── wrapper/ │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties │ ├── gradle.properties │ ├── gradlew │ ├── gradlew.bat │ └── settings.gradle.kts ├── fastlane/ │ └── metadata/ │ └── android/ │ ├── de/ │ │ ├── full_description.txt │ │ └── short_description.txt │ └── en-US/ │ ├── full_description.txt │ └── short_description.txt └── ios/ ├── Igatha/ │ ├── AppDelegate.swift │ ├── Assets.xcassets/ │ │ ├── AccentColor.colorset/ │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset/ │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Constants.swift │ ├── Igatha.entitlements │ ├── IgathaApp.swift │ ├── Info.plist │ ├── Models/ │ │ └── Device.swift │ ├── Preview Content/ │ │ └── Preview Assets.xcassets/ │ │ └── Contents.json │ ├── Sensors/ │ │ ├── AccelerometerSensor.swift │ │ ├── BarometerSensor.swift │ │ ├── GyroscopeSensor.swift │ │ └── SensorType.swift │ ├── Services/ │ │ ├── DeepLinkHandler.swift │ │ ├── DisasterDetector.swift │ │ ├── EmergencyManager.swift │ │ ├── LocationManager.swift │ │ ├── NotificationManager.swift │ │ ├── ProximityScanner.swift │ │ ├── SOSBeacon.swift │ │ └── SirenPlayer.swift │ ├── ViewModels/ │ │ ├── ContentViewModel.swift │ │ ├── FeedbackFormViewModel.swift │ │ └── SettingsViewModel.swift │ └── Views/ │ ├── ContentView.swift │ ├── DeviceDetailView.swift │ ├── DeviceListView.swift │ ├── DeviceRowView.swift │ ├── FeedbackButtonView.swift │ ├── FeedbackFormView.swift │ └── SettingsView.swift └── Igatha.xcodeproj/ ├── project.pbxproj ├── project.xcworkspace/ │ └── contents.xcworkspacedata └── xcuserdata/ └── nizarmah.xcuserdatad/ └── xcschemes/ └── xcschememanagement.plist ================================================ FILE CONTENTS ================================================ ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 Nizar Mahmoud Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: PRIVACY.md ================================================ # Privacy Policy **Last Updated:** November 1, 2024 ## Introduction **Igatha** is committed to protecting your privacy. This Privacy Policy outlines how the app functions and the measures we take to ensure your information remains secure. ## How Igatha Works ### 1. Sensor Data - **Accelerometer Readings:** Used locally for detecting sudden movements to identify potential emergencies. - **Gyroscope Data:** Utilized locally to determine device orientation, aiding in emergency detection. - **Barometer Readings:** Monitors pressure changes locally to assist in disaster detection. - **Data Handling:** All sensor data is processed locally on your device and immediately discarded. No data is transmitted off your device. ### 2. Bluetooth - **Bluetooth Low Energy (BLE) Signals:** Used locally for broadcasting SOS beacons and scanning for nearby signals. - **Signal Strength:** Assessed locally to estimate the distance of detected signals. - **Temporary Device Identifiers:** Uses the first 8 characters of UUIDs for temporary identification purposes. - **Data Handling:** No persistent storage of detected devices. All Bluetooth interactions are handled locally without data retention or transmission. ### 3. Location Services - **Background Location Permission:** Required to enable sensor monitoring for automated disaster detection on iOS. - **Bluetooth Scanning Permission:** Required to enable bluetooth scanning for nearby devices on Android 11 and lower. - **Data Handling:** We do **not** transmit or store GPS coordinates or perform location tracking. Location permissions are solely used to facilitate sensor-based emergency detection locally on your device. ### 4. Foreground Services - **Background Permission:** Required to enable sensor monitoring for automated disaster detection and signaling SOS in the background on Android. ## Data Privacy - **Local Processing:** All data processing occurs on your device. No data is transmitted over the internet. - **No Data Collection:** Data is not transmitted off your device or persisted between app sessions. Once processed, it is immediately discarded. - **No Third-Party Integration:** Igatha does not use any analytics tools, tracking mechanisms, or third-party SDKs. - **No Cloud Services:** There is no cloud integration or external servers involved. ## Permissions Used - **Bluetooth:** Enables local SOS beacon transmission and detection. - **Location:** Required for background sensor operations to facilitate automated emergency detection on iOS. Required for bluetooth scanning on Android 11 and lower. - **Motion/Sensors:** Access to device motion and sensors for local disaster detection. - **Foreground Services:** Required to enable sensor monitoring for automated disaster detection and signaling SOS in the background on Android. ## Open Source Commitment Igatha is open-sourced under the [GNU General Public License v3.0](https://github.com/nizarmah/igatha/blob/main/LICENSE). You can review, modify, and contribute to the source code at [github.com/nizarmah/igatha](https://github.com/nizarmah/igatha). ## Contact Us If you have any questions or concerns about this Privacy Policy, please contact us at [nizarmah@hotmail.com](mailto:nizarmah@hotmail.com). ================================================ FILE: README.md ================================================ # Igatha Igatha is an open-source SOS signaling and recovery app designed for war zones and disaster areas, enabling offline emergency communication when traditional networks fail. ## Status - **iOS**: [v1.0](https://apps.apple.com/us/app/igatha/id6737691452) - **Android**: [v1.0](https://play.google.com/store/apps/details?id=com.nizarmah.igatha) ## Quickstart 1. Install the app using the links above. 1. Open the app and grant the necessary permissions. ## How to use Igatha ### Sending SOS signals (distress mode) #### Manual signaling 1. Open Igatha. 1. Ensure bluetooth is enabled. 1. Tap "_Send SOS_". #### Automatic signaling 1. Open Igatha. 1. Tap the gear icon (top right). 1. Enable "_Disaster Detection_". With disaster detection, Igatha will now run in the background, monitoring your device's sensors. When a potential disaster is detected, you'll receive an "_Are you okay?_" notification: * If you respond with "_Need help_" or don't respond in 2 minutes, it will automatically broadcast an SOS. * If you respond with "_I'm okay_", it will ignore the event. ### Helping others (recovery mode) If you're safe and want to help others: 1. Open Igatha. 1. Ensure bluetooth is enabled (on Android 11 or lower, also enable Location). 1. Check "_People seeking help_". 1. Move towards locations where displayed distances decrease. 1. Listen carefully for audible sirens. ## How Igatha works ### Bluetooth low energy (BLE) Igatha uses Bluetooth Low Energy (BLE) to: 1. Broadcast SOS signals. 1. Scan for nearby SOS broadcasts. 1. Estimate approximate distance to the signal source based on signal strength. No internet or GPS is required, preventing signal jamming or manipulation. ### SOS signal composition The SOS signal combines: 1. BLE advertisement: broadcasts a pseudonymized identifier. 1. Audible siren: generated via device speakers to help responders locate you. Responders can toggle additional signals, like flashlight or vibration, remotely. (planned feature) ### Disaster detection sensors Igatha detects disasters using device sensors: 1. Accelerometer: measures sudden motion changes. 1. Gyroscope: detect orientation and rotation shifts. 1. Barometer (if available): detects atmospheric pressure changes, reducing false positives. Disaster detection triggers when multiple sensors simultaneously detect abrupt changes. Location permissions are required for "_Disaster Detection_". ## Battery usage Igatha minimizes battery use by leveraging BLE and optimized sensor monitoring. The app can continuously broadcast for extended periods during emergencies. ## Limitations ### Early stage * This is a Minimum Viable Product (MVP) with significant room for improvement * Testing has been limited to controlled environments * While not guaranteed to work in all scenarios, it provides a potential lifeline where no alternatives exist ### Signal range * BLE range: typically 10-30 meters indoors, further outdoors, limited by rubble and building materials. * Optional extensions: Third-party BLE receivers can extend range significantly. ## Why open source? Igatha is open-sourced for: 1. **Transparency**: In crisis situations, people need to trust the tools they use. Open source allows anyone to verify the app's security and privacy measures. 2. **Accessibility**: Making the code freely available ensures the technology can be used, studied, and adapted by anyone who needs it. 3. **Community Impact**: War and disaster response tools should be community resources, not commercial products. Open sourcing enables collaborative improvement and adaptation for different crisis scenarios. ## Contributing Contributions are vital for improving Igatha: * Testing and bug reports * Documentation * Translations * Feature enhancements * Code optimization * Security reviews * Distribution To contribute, open an issue or submit a pull request. ## Privacy & security * Completely offline; no data collection or internet connectivity. * Uses pseudonymized identifiers for privacy. ## Contact For questions, suggestions, or feedback, please open an issue in the repository. ================================================ FILE: android/.gitignore ================================================ *.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 ================================================ FILE: android/.gitkeep ================================================ ================================================ FILE: android/.idea/.gitignore ================================================ # Default ignored files /shelf/ /workspace.xml ================================================ FILE: android/.idea/.name ================================================ Igatha ================================================ FILE: android/.idea/AndroidProjectSystem.xml ================================================ ================================================ FILE: android/.idea/compiler.xml ================================================ ================================================ FILE: android/.idea/deploymentTargetSelector.xml ================================================ ================================================ FILE: android/.idea/deviceManager.xml ================================================ ================================================ FILE: android/.idea/gradle.xml ================================================ ================================================ FILE: android/.idea/inspectionProfiles/Project_Default.xml ================================================ ================================================ FILE: android/.idea/kotlinc.xml ================================================ ================================================ FILE: android/.idea/migrations.xml ================================================ ================================================ FILE: android/.idea/misc.xml ================================================ ================================================ FILE: android/.idea/runConfigurations.xml ================================================ ================================================ FILE: android/.idea/vcs.xml ================================================ ================================================ FILE: android/app/.gitignore ================================================ /build ================================================ FILE: android/app/build.gradle.kts ================================================ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) id("kotlin-parcelize") } android { namespace = "com.nizarmah.igatha" compileSdk = 35 defaultConfig { applicationId = "com.nizarmah.igatha" minSdk = 26 targetSdk = 35 versionCode = 2 versionName = "1.1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { jvmTarget = "11" } buildFeatures { compose = true } dependenciesInfo { // Exclude dependency block from APKs for open source compliance. // @see https://android.izzysoft.de/articles/named/iod-scan-apkchecks#blobs includeInApk = false // Include dependency block in AAB for playstore compliance. // @see https://developer.android.com/build/dependencies#dependency-info-play includeInBundle = true } } dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.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.navigation.compose) implementation(libs.gson) implementation(libs.androidx.work.runtime.ktx) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) } ================================================ FILE: android/app/proguard-rules.pro ================================================ # 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 ================================================ FILE: android/app/src/androidTest/java/com/nizarmah/igatha/ExampleInstrumentedTest.kt ================================================ package com.nizarmah.igatha 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("com.nizarmah.igatha", appContext.packageName) } } ================================================ FILE: android/app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/Constants.kt ================================================ package com.nizarmah.igatha import android.os.ParcelUuid object Constants { val SOS_BEACON_SERVICE_UUID: ParcelUuid = ParcelUuid.fromString("00001802-0000-1000-8000-00805F9B34FB") // 1802 // update interval for sensor readings in microseconds const val SENSOR_UPDATE_INTERVAL: Int = 100_000 // threshold for sudden changes in linear acceleration // 3.0 g ~= dropping your phone on a hard surface const val SENSOR_ACCELERATION_THRESHOLD: Double = 3.0 // threshold for sudden changes in rotation // 6.0 r/s ~= almost a full rotation in 1 second const val SENSOR_ROTATION_THRESHOLD: Double = 6.0 // threshold for sudden changes in atmospheric pressure // 0.1 kPa ~= altitude change of approx. 8 to 12 meters const val SENSOR_PRESSURE_THRESHOLD: Double = 0.1 // key for shared preferences collection const val SHARED_PREFERENCES_KEY: String = "com.nizarmah.igatha.preferences" // key for disaster detector enabled setting in preferences const val DISASTER_DETECTION_ENABLED_KEY: String = "disasterDetectionEnabled" // time window for temporally correlating sensor readings // if all thresholds exceed in 1.5s then we have an incident const val DISASTER_TEMPORAL_CORRELATION_TIME_WINDOW: Double = 1.5 // grace period (seconds) before an incident response is triggered const val DISASTER_RESPONSE_GRACE_PERIOD: Double = 120.0 // percentage of how much a new value should affect the old value const val RSSI_EXPONENTIAL_MOVING_AVERAGE_SMOOTHING_FACTOR: Double = 0.18 const val DISASTER_MONITORING_NOTIFICATION_ID: Int = 1 const val DISASTER_MONITORING_NOTIFICATION_KEY: String = "DISASTER_MONITORING" const val DISASTER_RESPONSE_NOTIFICATION_ID: Int = 2 const val DISASTER_RESPONSE_NOTIFICATION_KEY: String = "DISASTER_RESPONSE" const val DISTRESS_ACTIVE_NOTIFICATION_ID: Int = 3 const val DISTRESS_ACTIVE_NOTIFICATION_KEY: String = "DISTRESS_ACTIVE" const val FEEDBACK_NOTIFICATION_ID: Int = 4 const val FEEDBACK_NOTIFICATION_KEY: String = "FEEDBACK_REQUEST" const val ACTION_IGNORE_ALERT: String = "com.nizarmah.igatha.actions.IGNORE_ALERT" const val ACTION_START_SOS: String = "com.nizarmah.igatha.actions.START_SOS" const val ACTION_STOP_SOS: String = "com.nizarmah.igatha.actions.STOP_SOS" // Deep links object DeepLink { const val SCHEME = "igatha" object Settings { const val VALUE = "settings" } } // Notification constants object Notifications { object Feedback { const val ID = "feedbackRequest" val LINK = DeepLink.Settings // Delay before notification is shown (3 days in seconds) const val TRIGGER_DELAY: Long = 3 * 24 * 60 * 60 // Key for timestamp of when feedback request was scheduled const val TIMESTAMP_KEY = "feedbackScheduledTimestamp" } } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/IgathaApp.kt ================================================ package com.nizarmah.igatha import android.app.Application import android.content.SharedPreferences import com.nizarmah.igatha.util.PermissionsManager import com.nizarmah.igatha.util.SettingsManager import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.ExistingWorkPolicy import java.util.concurrent.TimeUnit import com.nizarmah.igatha.service.FeedbackWorker import androidx.core.content.edit class IgathaApp : Application() { override fun onCreate() { super.onCreate() SettingsManager.init(this) PermissionsManager.init(this) var sharedPrefs = getSharedPreferences( Constants.SHARED_PREFERENCES_KEY, MODE_PRIVATE ) var workManager = WorkManager.getInstance(this) scheduleFeedbackNotification( PermissionsManager.notificationsPermitted.value, sharedPrefs, workManager ) } // scheduleFeedbackNotification is used to schedule the feedback notification. private fun scheduleFeedbackNotification( notificationsPermitted: Boolean, sharedPrefs: SharedPreferences, workManager: WorkManager ) { if ( // Check if notifications are not permitted !notificationsPermitted || // Check if feedback notification was already scheduled sharedPrefs.contains(Constants.Notifications.Feedback.TIMESTAMP_KEY) ) { return } // Schedule feedback notification once using WorkManager val feedbackWorkRequest = OneTimeWorkRequestBuilder() .setInitialDelay( Constants.Notifications.Feedback.TRIGGER_DELAY, TimeUnit.SECONDS ) .build() // Enqueue the work request workManager .enqueueUniqueWork( Constants.Notifications.Feedback.ID, ExistingWorkPolicy.KEEP, feedbackWorkRequest ) // Mark feedback notification as scheduled sharedPrefs.edit { putLong( Constants.Notifications.Feedback.TIMESTAMP_KEY, System.currentTimeMillis() ) } } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/MainActivity.kt ================================================ package com.nizarmah.igatha import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import com.nizarmah.igatha.ui.component.PermissionHandler import com.nizarmah.igatha.ui.screen.ContentScreen import com.nizarmah.igatha.ui.theme.IgathaTheme import com.nizarmah.igatha.util.PermissionsManager class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { IgathaTheme( // Disable dynamic wallpaper-based theming // so our static red primary is used instead dynamicColor = false ) { MainScreen() } } } override fun onResume() { super.onResume() PermissionsManager.refreshPermissions(this) } } @Composable fun MainScreen() { Scaffold( bottomBar = { PermissionHandler( modifier = Modifier.fillMaxWidth() ) }, content = { paddingValues -> ContentScreen( modifier = Modifier .padding(paddingValues) .fillMaxSize() ) } ) } @Preview(showBackground = true) @Composable fun MainActivityPreview() { MainActivity() } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/model/Device.kt ================================================ package com.nizarmah.igatha.model import android.os.Parcelable import kotlinx.parcelize.Parcelize import java.util.* import kotlin.math.pow import kotlin.math.roundToInt import com.nizarmah.igatha.Constants fun UUID.shortName(): String { return this.toString().substring(0, 8).uppercase() } @Parcelize data class Device( val id: String, val shortName: String, var rssi: Double, var lastSeen: Date = Date() ) : Parcelable { constructor( id: UUID, rssi: Double, lastSeen: Date = Date() ) : this( id.toString().uppercase(), id.shortName(), rssi, lastSeen ) fun update(rssi: Double, lastSeen: Date = Date()) { val oldRSSI = this.rssi val newRSSI = rssi // smoothing factor val alpha = Constants.RSSI_EXPONENTIAL_MOVING_AVERAGE_SMOOTHING_FACTOR // smoothen the RSSI with exponential moving average val smoothedRSSI = alpha * newRSSI + (1 - alpha) * oldRSSI this.rssi = smoothedRSSI this.lastSeen = lastSeen } fun estimateDistance(pathLossExponent: PathLossExponent = PathLossExponent.URBAN): Double { // 1 meter ~= -59.0 RSSI val txPower = -59.0 // path-loss exponent val n: Double = pathLossExponent.value val distance = 10.0.pow((txPower - rssi) / (10.0 * n)) // round for simplicity return (distance * 1000.0).roundToInt() / 1000.0 } } enum class PathLossExponent(val value: Double) { // path-loss exponent (n) // free open spaces // n = 2.0 FREE_SPACE(2.0), // indoor environments // n = 3.0 INDOOR(3.0), // dense urban environments // n = 4.0 URBAN(4.0) } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/sensor/AcceleratorSensor.kt ================================================ package com.nizarmah.igatha.sensor import android.content.Context import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager import android.util.Log import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlin.math.sqrt import com.nizarmah.igatha.sensor.Sensor as InternalSensor class AcceleratorSensor( context: Context, override val threshold: Double, // Threshold in g's private val updateInterval: Int // in microseconds ) : InternalSensor, SensorEventListener { private val sensorManager: SensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager override val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) override val sensorType: SensorType = SensorType.ACCELEROMETER override val isAvailable: Boolean get() = sensor != null private val _events = MutableSharedFlow( replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) override val events: SharedFlow = _events.asSharedFlow() override fun startUpdates() { if (!isAvailable) { Log.w(TAG, "Accelerometer sensor not available") return } sensor?.let { accelerometer -> sensorManager.registerListener( this, accelerometer, updateInterval ) Log.d(TAG, "AcceleratorSensor: started updates") } } override fun stopUpdates() { sensorManager.unregisterListener(this) Log.d(TAG, "AcceleratorSensor: stopped updates") } override fun onSensorChanged(event: SensorEvent) { if (event.sensor.type != Sensor.TYPE_ACCELEROMETER) return val x = event.values[0] val y = event.values[1] val z = event.values[2] // Normalize acceleration to g's val totalAcceleration = sqrt(x * x + y * y + z * z) / SensorManager.GRAVITY_EARTH if (totalAcceleration > threshold) { val sensorEvent = SensorCapturedEvent( sensorType = sensorType, eventTime = event.timestamp ) _events.tryEmit(sensorEvent) } } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) { // Not used in this implementation } companion object { private const val TAG = "AcceleratorSensor" } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/sensor/BarometerSensor.kt ================================================ package com.nizarmah.igatha.sensor import android.content.Context import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager import android.util.Log import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlin.math.abs import com.nizarmah.igatha.sensor.Sensor as InternalSensor class BarometerSensor( context: Context, override val threshold: Double, private val updateInterval: Int // in microseconds ) : InternalSensor, SensorEventListener { private val sensorManager: SensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager override val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_PRESSURE) override val sensorType: SensorType = SensorType.BAROMETER override val isAvailable: Boolean get() = sensor != null private var initialPressure: Float? = null private val _events = MutableSharedFlow( replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) override val events: SharedFlow = _events.asSharedFlow() override fun startUpdates() { if (!isAvailable) { Log.w(TAG, "Barometer sensor not available") return } initialPressure = null sensor?.let { barometer -> sensorManager.registerListener( this, barometer, updateInterval ) Log.d(TAG, "BarometerSensor: started updates") } } override fun stopUpdates() { sensorManager.unregisterListener(this) initialPressure = null Log.d(TAG, "BarometerSensor: stopped updates") } override fun onSensorChanged(event: SensorEvent) { if (event.sensor.type != Sensor.TYPE_PRESSURE) return val pressure = event.values[0] // pressure in hPa (millibar) if (initialPressure == null) { initialPressure = pressure return } val pressureChange = abs(pressure - (initialPressure ?: return)) if (pressureChange > threshold) { val sensorEvent = SensorCapturedEvent( sensorType = sensorType, eventTime = event.timestamp ) _events.tryEmit(sensorEvent) } } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) { // Not used in this implementation } companion object { private const val TAG = "BarometerSensor" } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/sensor/GyroscopeSensor.kt ================================================ package com.nizarmah.igatha.sensor import android.content.Context import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager import android.util.Log import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlin.math.sqrt import com.nizarmah.igatha.sensor.Sensor as InternalSensor class GyroscopeSensor( context: Context, override val threshold: Double, // Threshold in rad/s private val updateInterval: Int // in microseconds ) : InternalSensor, SensorEventListener { private val sensorManager: SensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager override val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) override val sensorType: SensorType = SensorType.GYROSCOPE override val isAvailable: Boolean get() = sensor != null private val _events = MutableSharedFlow( replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) override val events: SharedFlow = _events.asSharedFlow() private var filteredRotationRate = 0.0 private val alpha = 0.8 // Low-pass filter coefficient override fun startUpdates() { if (!isAvailable) { Log.w(TAG, "Gyroscope sensor not available") return } sensor?.let { gyroscope -> sensorManager.registerListener( this, gyroscope, updateInterval ) Log.d(TAG, "GyroscopeSensor: started updates") } } override fun stopUpdates() { sensorManager.unregisterListener(this) Log.d(TAG, "GyroscopeSensor: stopped updates") } override fun onSensorChanged(event: SensorEvent) { if (event.sensor.type != Sensor.TYPE_GYROSCOPE) return val x = event.values[0] val y = event.values[1] val z = event.values[2] val rawRotationRate = sqrt(x * x + y * y + z * z) // Apply low-pass filter filteredRotationRate = alpha * filteredRotationRate + (1 - alpha) * rawRotationRate if (filteredRotationRate > threshold) { val sensorEvent = SensorCapturedEvent( sensorType = sensorType, eventTime = event.timestamp ) _events.tryEmit(sensorEvent) } } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) { // Not used in this implementation } companion object { private const val TAG = "GyroscopeSensor" } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/sensor/SensorType.kt ================================================ package com.nizarmah.igatha.sensor import android.hardware.Sensor import kotlinx.coroutines.flow.SharedFlow enum class SensorType { ACCELEROMETER, GYROSCOPE, BAROMETER } interface AnySensor { val isAvailable: Boolean fun startUpdates() fun stopUpdates() } interface Sensor : AnySensor { val sensor: Sensor? val threshold: Double val sensorType: SensorType // A shared flow to emit events when the threshold is exceeded val events: SharedFlow } data class SensorCapturedEvent( val sensorType: SensorType, val eventTime: Long ) ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/service/DisasterDetectionService.kt ================================================ package com.nizarmah.igatha.service import android.app.* import android.content.Intent import android.os.IBinder import androidx.core.app.NotificationCompat import com.nizarmah.igatha.Constants import com.nizarmah.igatha.R import kotlinx.coroutines.* class DisasterDetectionService : Service() { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private lateinit var emergencyManager: EmergencyManager private var confirmationJob: Job? = null override fun onCreate() { super.onCreate() // Initialize inside onCreate because context is not available earlier emergencyManager = EmergencyManager.getInstance(applicationContext) val started = emergencyManager.startDetector() // If the detector did not start, stop the service if (!started) { stopSelf() } // Start the service in the foreground with a low-priority notification val notification = createNotification() startForeground(Constants.DISASTER_MONITORING_NOTIFICATION_ID, notification) // Observe disaster detection events scope.launch { DisasterEventBus.disasterDetectedFlow.collect { onDisasterDetected() } } } private fun onDisasterDetected() { // Show notification to the user showDisasterDetectedNotification() // Start confirmation timer startConfirmationTimer() } private fun showDisasterDetectedNotification() { val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager val channelId = createHighPriorityNotificationChannel() // Intent for "I'm Okay" action val imOkayIntent = Intent(this, DisasterDetectionService::class.java).apply { action = Constants.ACTION_IGNORE_ALERT } val imOkayPendingIntent = PendingIntent.getService( this, 0, imOkayIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) // Intent for "Need Help" action val needHelpIntent = Intent(this, DisasterDetectionService::class.java).apply { action = Constants.ACTION_START_SOS } val needHelpPendingIntent = PendingIntent.getService( this, 1, needHelpIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) // Build the notification val notificationBuilder = NotificationCompat.Builder(this, channelId) .setSmallIcon(R.drawable.ic_notification_alerting) .setContentTitle("Are you okay?") .setContentText("Detected a possible disaster.") .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(NotificationCompat.CATEGORY_ALARM) .setAutoCancel(true) .addAction(R.drawable.ic_im_okay, "I'm Okay", imOkayPendingIntent) .addAction(R.drawable.ic_need_help, "Need Help", needHelpPendingIntent) // Show the notification notificationManager.notify(Constants.DISASTER_RESPONSE_NOTIFICATION_ID, notificationBuilder.build()) } private fun startConfirmationTimer() { confirmationJob?.cancel() confirmationJob = scope.launch { delay((Constants.DISASTER_RESPONSE_GRACE_PERIOD * 1000).toLong()) startSOSService() } } private fun cancelConfirmationTimer() { confirmationJob?.cancel() confirmationJob = null } private fun startSOSService() { val sosIntent = Intent(this, SOSService::class.java) startForegroundService(sosIntent) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { cancelConfirmationTimer() if (intent?.action != null) { // Dismiss the alert notification val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager notificationManager.cancel(Constants.DISASTER_RESPONSE_NOTIFICATION_ID) } when (intent?.action) { Constants.ACTION_START_SOS -> { // Start SOS immediately startSOSService() } } return START_STICKY } override fun onDestroy() { super.onDestroy() // Clean up resources cancelConfirmationTimer() emergencyManager.stopDetector() scope.cancel() } override fun onBind(intent: Intent?): IBinder? { return null } private fun createNotification(): Notification { val channelId = createNotificationChannel() val notificationBuilder = NotificationCompat.Builder(this, channelId) .setContentTitle("Monitoring for disasters") .setSmallIcon(R.drawable.ic_notification) .setPriority(NotificationCompat.PRIORITY_LOW) .setOngoing(true) return notificationBuilder.build() } private fun createNotificationChannel(): String { val channelId = Constants.DISASTER_MONITORING_NOTIFICATION_KEY val channelName = "Disaster Detection Service" val channel = NotificationChannel( channelId, channelName, NotificationManager.IMPORTANCE_LOW ) val notificationManager = getSystemService(NotificationManager::class.java) notificationManager.createNotificationChannel(channel) return channelId } private fun createHighPriorityNotificationChannel(): String { val channelId = Constants.DISASTER_RESPONSE_NOTIFICATION_KEY val channelName = "Emergency Alerts" val channel = NotificationChannel( channelId, channelName, NotificationManager.IMPORTANCE_HIGH ) channel.enableVibration(true) channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC val notificationManager = getSystemService(NotificationManager::class.java) notificationManager.createNotificationChannel(channel) return channelId } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/service/DisasterDetector.kt ================================================ package com.nizarmah.igatha.service import android.content.Context import com.nizarmah.igatha.Constants import com.nizarmah.igatha.sensor.* import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import com.nizarmah.igatha.sensor.Sensor as InternalSensor class DisasterDetector( context: Context, accelerationThreshold: Double, rotationThreshold: Double, pressureThreshold: Double, private val eventTimeWindow: Long // in milliseconds ) { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val eventTimes = mutableMapOf() private val accelerometerSensor = AcceleratorSensor( context, threshold = accelerationThreshold, updateInterval = Constants.SENSOR_UPDATE_INTERVAL ) private val gyroscopeSensor = GyroscopeSensor( context, threshold = rotationThreshold, updateInterval = Constants.SENSOR_UPDATE_INTERVAL ) private val barometerSensor = BarometerSensor( context, threshold = pressureThreshold, updateInterval = Constants.SENSOR_UPDATE_INTERVAL ) private val _isAvailable = MutableStateFlow(true) val isAvailable: StateFlow = _isAvailable.asStateFlow() private val _isActive = MutableStateFlow(false) val isActive: StateFlow = _isActive.asStateFlow() fun startDetection() { scope.launch { collectSensorEvents(accelerometerSensor) } scope.launch { collectSensorEvents(gyroscopeSensor) } scope.launch { collectSensorEvents(barometerSensor) } _isActive.value = true } fun stopDetection() { _isActive.value = false accelerometerSensor.stopUpdates() gyroscopeSensor.stopUpdates() barometerSensor.stopUpdates() scope.coroutineContext.cancelChildren() } private suspend fun collectSensorEvents(sensor: InternalSensor) { sensor.startUpdates() sensor.events.collect { event -> eventTimes[event.sensorType] = System.currentTimeMillis() checkForIncident() } } private fun checkForIncident() { val currentTime = System.currentTimeMillis() if ( (barometerSensor.isAvailable && eventTimes.size < 3) || // Some android devices don't have barometers (!barometerSensor.isAvailable && eventTimes.size < 2) ) return if ( (barometerSensor.isAvailable && eventTimes.values.all { currentTime - it <= eventTimeWindow }) || // If no barometer, apply aggressive filtering (!barometerSensor.isAvailable && eventTimes.values.all { currentTime - it <= eventTimeWindow / 3.2 }) ) { // Apply more aggressive filtering if barometer is missing if ( !barometerSensor.isAvailable && eventTimes.values.all { currentTime - it > eventTimeWindow / 2 } ) { return } // Disaster detected scope.launch { DisasterEventBus.emitDisasterDetected() } eventTimes.clear() } } fun deinit() { stopDetection() scope.cancel() } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/service/DisasterEventBus.kt ================================================ package com.nizarmah.igatha.service import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow object DisasterEventBus { private val _disasterDetectedFlow = MutableSharedFlow(replay = 0) val disasterDetectedFlow: SharedFlow = _disasterDetectedFlow suspend fun emitDisasterDetected() { _disasterDetectedFlow.emit(Unit) } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/service/EmergencyManager.kt ================================================ package com.nizarmah.igatha.service import android.content.Context import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.* import com.nizarmah.igatha.Constants import com.nizarmah.igatha.util.PermissionsManager import com.nizarmah.igatha.util.SettingsManager import kotlinx.coroutines.cancel class EmergencyManager private constructor(context: Context) { private val appContext = context.applicationContext companion object { @Volatile private var instance: EmergencyManager? = null fun getInstance(ctx: Context?): EmergencyManager { val appCtx = ctx?.applicationContext ?: throw IllegalStateException("EmergencyManager requested with null Context") return instance ?: synchronized(this) { instance ?: EmergencyManager(appCtx).also { instance = it } } } } private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) // Components initialization private val sosBeacon = SOSBeacon(appContext) private val sirenPlayer = SirenPlayer(appContext) private val disasterDetector = DisasterDetector( context = appContext, accelerationThreshold = Constants.SENSOR_ACCELERATION_THRESHOLD, rotationThreshold = Constants.SENSOR_ROTATION_THRESHOLD, pressureThreshold = Constants.SENSOR_PRESSURE_THRESHOLD, eventTimeWindow = Constants.DISASTER_TEMPORAL_CORRELATION_TIME_WINDOW.toLong() ) // State management val isSOSAvailable: StateFlow = combine( sosBeacon.isAvailable, sirenPlayer.isAvailable, PermissionsManager.sosPermitted ) { beacon, siren, permitted -> beacon && siren && permitted }.stateIn(scope, SharingStarted.Eagerly, false) val isSOSActive: StateFlow = combine( sosBeacon.isActive, sirenPlayer.isActive ) { beacon, siren -> beacon || siren }.stateIn(scope, SharingStarted.Eagerly, false) val isDetectorAvailable: StateFlow = combine( disasterDetector.isAvailable, isSOSAvailable, PermissionsManager.disasterDetectionPermitted ) { detector, sos, permitted -> detector && sos && permitted }.stateIn(scope, SharingStarted.Eagerly, false) val isDetectorEnabled: StateFlow = combine( isDetectorAvailable, SettingsManager.disasterDetectionEnabled ) { available, enabled -> available && enabled }.stateIn(scope, SharingStarted.Eagerly, false) val isDetectorActive: StateFlow = disasterDetector.isActive // Core functionality fun startSOS() { if (!isSOSAvailable.value || isSOSActive.value) return sosBeacon.startBroadcasting() sirenPlayer.startSiren() } fun stopSOS() { sosBeacon.stopBroadcasting() sirenPlayer.stopSiren() } fun startDetector(): Boolean { if (!isDetectorEnabled.value) return false if (isDetectorActive.value) return true disasterDetector.startDetection() return true } fun stopDetector() { disasterDetector.stopDetection() } fun deinit() { stopSOS() stopDetector() sirenPlayer.deinit() sosBeacon.deinit() disasterDetector.deinit() scope.cancel() } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/service/FeedbackWorker.kt ================================================ package com.nizarmah.igatha.service import android.Manifest import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.net.Uri import androidx.annotation.RequiresPermission import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.nizarmah.igatha.Constants import com.nizarmah.igatha.R class FeedbackWorker( context: Context, workerParams: WorkerParameters ) : CoroutineWorker(context, workerParams) { @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) override suspend fun doWork(): Result { val channel = createNotificationChannel() val notificationManager = applicationContext.getSystemService(NotificationManager::class.java) notificationManager?.createNotificationChannel(channel) val notification = sendFeedbackNotification() // Display the notification NotificationManagerCompat.from(applicationContext) .notify(Constants.FEEDBACK_NOTIFICATION_ID, notification) return Result.success() } // createNotificationChannel creates or updates the notification channel for feedback private fun createNotificationChannel(): NotificationChannel { val channelId = Constants.FEEDBACK_NOTIFICATION_KEY val channelName = "Feedback Requests" val channel = NotificationChannel( channelId, channelName, NotificationManager.IMPORTANCE_DEFAULT ) return channel } // sendFeedbackNotification builds and displays the feedback notification private fun sendFeedbackNotification(): Notification { val channelId = Constants.FEEDBACK_NOTIFICATION_KEY // Build deep link intent using the feedback notification link constant val contentUri = Uri.Builder() .scheme(Constants.DeepLink.SCHEME) .authority(Constants.Notifications.Feedback.LINK.VALUE) .build() // Build the pending intent val intent = Intent(Intent.ACTION_VIEW, contentUri) val pendingIntent = PendingIntent.getActivity( applicationContext, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) // Build the notification val notification = NotificationCompat.Builder(applicationContext, channelId) .setSmallIcon(R.drawable.ic_notification) .setContentTitle("Tell us why you use Igatha") .setContentText("Help us improve it for you and others") .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setAutoCancel(true) .setContentIntent(pendingIntent) .build() return notification } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/service/ProximityScanner.kt ================================================ package com.nizarmah.igatha.service import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothManager import android.bluetooth.le.BluetoothLeScanner import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanSettings import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.util.Log import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import com.nizarmah.igatha.Constants import com.nizarmah.igatha.model.Device import java.util.Date import java.util.UUID class ProximityScanner(private val context: Context) { private val _isActive = MutableStateFlow(false) val isActive: StateFlow = _isActive.asStateFlow() private val _isAvailable = MutableStateFlow(false) val isAvailable: StateFlow = _isAvailable.asStateFlow() private val _scannedDevices = MutableStateFlow(null) val scannedDevices: StateFlow = _scannedDevices.asStateFlow() private var bluetoothLeScanner: BluetoothLeScanner? = null private val scanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { super.onScanResult(callbackType, result) val macAddress = result.device.address val uuid = UUID.nameUUIDFromBytes(macAddress.toByteArray()) val device = Device( id = uuid, rssi = result.rssi.toDouble(), lastSeen = Date() ) _scannedDevices.value = device } override fun onScanFailed(errorCode: Int) { super.onScanFailed(errorCode) Log.e(TAG, "Scanning failed with error: $errorCode") stopScanning() } } private val bluetoothStateReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (BluetoothAdapter.ACTION_STATE_CHANGED != intent?.action) return updateAvailability() } } init { initialize() // Register for Bluetooth state changes val filter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) context.registerReceiver(bluetoothStateReceiver, filter) } fun deinit() { context.unregisterReceiver(bluetoothStateReceiver) stopScanning() } private fun initialize() { val bluetoothAdapter = getBluetoothAdapter() if (bluetoothAdapter == null) { _isAvailable.value = false return } bluetoothLeScanner = bluetoothAdapter.bluetoothLeScanner updateAvailability() } fun startScanning() { if (!_isAvailable.value || _isActive.value) { return } val scanFilters = listOf( ScanFilter.Builder() // TODO: Handle iOS overflow for bg advertisement // See: http://www.davidgyoungtech.com/2020/05/07/hacking-the-overflow-area .setServiceUuid(Constants.SOS_BEACON_SERVICE_UUID) .build() ) val scanSettings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() try { bluetoothLeScanner?.startScan(scanFilters, scanSettings, scanCallback) _isActive.value = true } catch (e: SecurityException) { Log.e(TAG, "SecurityException in startScanning: ${e.message}") _isActive.value = false } } fun stopScanning() { if (!_isAvailable.value || !_isActive.value) return try { bluetoothLeScanner?.stopScan(scanCallback) } catch (e: SecurityException) { Log.e(TAG, "SecurityException in stopScanning: ${e.message}") } _isActive.value = false } private fun updateAvailability() { val bluetoothAdapter = getBluetoothAdapter() if (bluetoothAdapter == null) { _isAvailable.value = false return } try { _isAvailable.value = bluetoothAdapter.isEnabled if (!_isAvailable.value && _isActive.value) { stopScanning() } } catch (e: SecurityException) { _isAvailable.value = false Log.e(TAG, "SecurityException in updateAvailability: ${e.message}") } } private fun getBluetoothAdapter(): BluetoothAdapter? { val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager return bluetoothManager.adapter } companion object { private const val TAG = "ProximityScanner" } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/service/SOSBeacon.kt ================================================ package com.nizarmah.igatha.service import android.Manifest import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothManager import android.bluetooth.le.* import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager import android.os.Build import android.util.Log import androidx.core.content.ContextCompat import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import com.nizarmah.igatha.Constants class SOSBeacon(private val context: Context) { // StateFlows to expose state changes private val _isActive = MutableStateFlow(false) val isActive: StateFlow = _isActive.asStateFlow() private val _isAvailable = MutableStateFlow(false) val isAvailable: StateFlow = _isAvailable.asStateFlow() private var bluetoothLeAdvertiser: BluetoothLeAdvertiser? = null private val advertiseCallback = object : AdvertiseCallback() { override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { super.onStartSuccess(settingsInEffect) _isActive.value = true } override fun onStartFailure(errorCode: Int) { super.onStartFailure(errorCode) _isActive.value = false } } private val bluetoothStateReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (BluetoothAdapter.ACTION_STATE_CHANGED != intent?.action) return updateAvailability() } } init { initialize() // Register the Bluetooth state receiver val filter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) context.registerReceiver(bluetoothStateReceiver, filter) } fun deinit() { context.unregisterReceiver(bluetoothStateReceiver) stopBroadcasting() } private fun initialize() { val bluetoothAdapter = getBluetoothAdapter() if (bluetoothAdapter == null) { _isAvailable.value = false return } bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser updateAvailability() } fun startBroadcasting() { if (!_isAvailable.value || _isActive.value) { return } val settings = AdvertiseSettings.Builder() .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH) .setConnectable(false) .setTimeout(0) .build() val advertiseData = AdvertiseData.Builder() .addServiceUuid(Constants.SOS_BEACON_SERVICE_UUID) .setIncludeDeviceName(false) .build() try { bluetoothLeAdvertiser?.startAdvertising(settings, advertiseData, advertiseCallback) } catch (e: SecurityException) { _isActive.value = false Log.e(TAG, "SecurityException in startBroadcasting: ${e.message}") } } fun stopBroadcasting() { try { bluetoothLeAdvertiser?.stopAdvertising(advertiseCallback) } catch (e: SecurityException) { Log.e(TAG, "SecurityException in stopBroadcasting: ${e.message}") } _isActive.value = false } private fun updateAvailability() { val bluetoothAdapter = getBluetoothAdapter() if (bluetoothAdapter == null) { _isAvailable.value = false return } val isEnabled: Boolean val isSupported: Boolean try { isEnabled = bluetoothAdapter.isEnabled isSupported = bluetoothAdapter.isMultipleAdvertisementSupported } catch (e: SecurityException) { _isAvailable.value = false Log.e(TAG, "SecurityException in updateAvailability: ${e.message}") return } _isAvailable.value = isEnabled && isSupported if (!_isAvailable.value && _isActive.value) { stopBroadcasting() } } private fun getBluetoothAdapter(): BluetoothAdapter? { if (!hasBluetoothPermissions()) { Log.e(TAG, "Missing Bluetooth permissions") return null } val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val bluetoothAdapter = bluetoothManager.adapter if (bluetoothAdapter == null) { Log.e(TAG, "Bluetooth is not supported on this device") return null } return bluetoothAdapter } private fun hasBluetoothPermissions(): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // For Android 12 and above ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADVERTISE) == PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED } else { // For below Android 12 true // Permissions are granted at install time } } companion object { private const val TAG = "SOSBeacon" } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/service/SOSService.kt ================================================ package com.nizarmah.igatha.service import android.app.* import android.content.Intent import android.os.IBinder import androidx.core.app.NotificationCompat import com.nizarmah.igatha.Constants import com.nizarmah.igatha.R import kotlinx.coroutines.* class SOSService : Service() { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private lateinit var emergencyManager: EmergencyManager override fun onCreate() { super.onCreate() // Initialize inside onCreate because context is not available earlier emergencyManager = EmergencyManager.getInstance(applicationContext) startSOS() } private fun startSOS() { emergencyManager.startSOS() // Show notification with "Stop SOS" action showSOSNotification() } private fun stopSOS() { emergencyManager.stopSOS() // Cancel SOS notification val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager notificationManager.cancel(Constants.DISTRESS_ACTIVE_NOTIFICATION_ID) // Stop the service stopSelf() } private fun showSOSNotification() { val channelId = createNotificationChannel() // Intent to stop SOS val stopSOSIntent = Intent(this, SOSService::class.java).apply { action = Constants.ACTION_STOP_SOS } val stopSOSPendingIntent = PendingIntent.getService( this, 0, stopSOSIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) // Build the notification val notificationBuilder = NotificationCompat.Builder(this, channelId) .setSmallIcon(R.drawable.ic_notification_signaling) .setContentTitle("Broadcasting SOS") .setPriority(NotificationCompat.PRIORITY_HIGH) .setOngoing(true) .addAction(R.drawable.ic_stop_sos, "Stop", stopSOSPendingIntent) // Start the service in the foreground startForeground(Constants.DISTRESS_ACTIVE_NOTIFICATION_ID, notificationBuilder.build()) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { Constants.ACTION_STOP_SOS -> { // Stop SOS procedures stopSOS() } } return START_STICKY } override fun onDestroy() { super.onDestroy() // Clean up resources stopSOS() scope.cancel() } override fun onBind(intent: Intent?): IBinder? { return null } private fun createNotificationChannel(): String { val channelId = Constants.DISTRESS_ACTIVE_NOTIFICATION_KEY val channelName = "SOS Service" val channel = NotificationChannel( channelId, channelName, NotificationManager.IMPORTANCE_HIGH ) val notificationManager = getSystemService(NotificationManager::class.java) notificationManager.createNotificationChannel(channel) return channelId } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/service/SirenPlayer.kt ================================================ package com.nizarmah.igatha.service import android.content.Context import android.media.* import android.util.Log import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlin.math.PI import kotlin.math.sin class SirenPlayer(context: Context) { // StateFlows for isActive and isAvailable private val _isActive = MutableStateFlow(false) val isActive: StateFlow = _isActive.asStateFlow() private val _isAvailable = MutableStateFlow(false) val isAvailable: StateFlow = _isAvailable.asStateFlow() private var audioTrack: AudioTrack? = null private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager init { // assume supported _isAvailable.value = true } fun deinit() { stopSiren() } fun startSiren() { if (!_isAvailable.value || _isActive.value) return try { val audioAttributes = AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .build() val sampleRate = 44100 val buffer = createSirenBuffer() audioTrack = AudioTrack.Builder() .setAudioAttributes(audioAttributes) .setAudioFormat( AudioFormat.Builder() .setEncoding(AudioFormat.ENCODING_PCM_16BIT) .setSampleRate(sampleRate) .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) .build() ) .setTransferMode(AudioTrack.MODE_STATIC) .setBufferSizeInBytes(buffer.size * 2) .build() // Route audio to built-in speaker audioManager.mode = AudioManager.MODE_IN_COMMUNICATION audioManager.isSpeakerphoneOn = true val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) val speakerDevice = devices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER } if (speakerDevice != null) { audioTrack?.preferredDevice = speakerDevice } else { Log.e(TAG, "Speaker device not found") } audioTrack?.let { track -> track.write(buffer, 0, buffer.size) track.setLoopPoints(0, buffer.size / track.channelCount, -1) track.play() _isActive.value = true Log.d(TAG, "Siren started") } ?: run { _isAvailable.value = false Log.e(TAG, "Failed to create AudioTrack") } } catch (e: Exception) { _isAvailable.value = false Log.e(TAG, "Exception in startSiren: ${e.message}", e) } } fun stopSiren() { try { // Reset audio mode and routing audioManager.mode = AudioManager.MODE_NORMAL audioManager.isSpeakerphoneOn = false audioTrack?.let { track -> track.stop() track.release() Log.d(TAG, "Siren stopped") } audioTrack = null _isActive.value = false } catch (e: Exception) { Log.e(TAG, "Exception in stopSiren: ${e.message}", e) } } private fun createSirenBuffer(): ShortArray { return try { val duration = 2.0 // seconds val sampleRate = 44100 // Hz val totalSamples = (sampleRate * duration).toInt() val buffer = ShortArray(totalSamples) val frequencyStart = 600.0f // Hz val frequencyEnd = 1200.0f // Hz val amplitude = 0.8f // Volume (0.0 to 1.0) for (sampleIndex in 0 until totalSamples) { val t = sampleIndex / sampleRate.toFloat() // Modulate frequency to create siren effect val modulation = sin(PI.toFloat() * t / duration.toFloat()) val frequency = frequencyStart + modulation * (frequencyEnd - frequencyStart) val sample = sin(2.0f * PI.toFloat() * frequency * t) * amplitude // Convert to 16-bit PCM value val pcmValue = (sample * Short.MAX_VALUE).toInt().toShort() buffer[sampleIndex] = pcmValue } buffer } catch (e: Exception) { Log.e(TAG, "Exception in createSirenBuffer: ${e.message}", e) _isAvailable.value = false ShortArray(0) } } companion object { private const val TAG = "SirenPlayer" } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/ui/component/FeedbackButtonView.kt ================================================ package com.nizarmah.igatha.ui.component import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.nizarmah.igatha.ui.theme.IgathaTheme import com.nizarmah.igatha.ui.theme.colors /** * A styled button for navigation to the feedback form. * Matches the iOS design with gradient background and heart icon. */ @Composable fun FeedbackButtonView( onClick: () -> Unit, modifier: Modifier = Modifier ) { // Create a pink-to-purple gradient to match iOS val gradient = Brush.linearGradient( colors = listOf( MaterialTheme.colors.pink.copy(alpha = 0.6f), MaterialTheme.colors.purple.copy(alpha = 0.6f) ), start = Offset.Zero, end = Offset.Infinite ) Surface( modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(4.dp)) .clickable { onClick() }, shadowElevation = 4.dp ) { Row( modifier = Modifier .background(gradient) .padding(vertical = 16.dp, horizontal = 20.dp) .fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { // Heart outline icon Icon( imageVector = Icons.Outlined.FavoriteBorder, contentDescription = "Feedback", modifier = Modifier.size(30.dp), tint = Color.White ) // Text content Column( verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( text = "Tell us why you use Igatha", style = MaterialTheme.typography.titleMedium, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, color = Color.White ) Text( text = "It helps us make it more reliable.", style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.9f) ) } } // Chevron Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Go to feedback form", tint = Color.White ) } } } @Preview(showBackground = true) @Composable fun FeedbackButtonViewPreview() { IgathaTheme { Box( modifier = Modifier.padding(16.dp) ) { FeedbackButtonView( onClick = {} ) } } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/ui/component/PermissionHandler.kt ================================================ package com.nizarmah.igatha.ui.component import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothManager import android.content.Context import android.content.Intent import android.net.Uri import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Column import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import com.nizarmah.igatha.util.PermissionsHelper import com.nizarmah.igatha.util.PermissionsManager @Composable fun PermissionHandler( modifier: Modifier = Modifier ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val permissionsGranted by PermissionsManager.permissionsGranted.collectAsState() // Launcher to request multiple permissions val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions(), onResult = { permissions -> PermissionsManager.refreshPermissions(context) } ) // Check Bluetooth status and prompt if disabled val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter var isBluetoothEnabled by remember { mutableStateOf(bluetoothAdapter?.isEnabled == true) } // Request permissions on first launch LaunchedEffect(Unit) { if (!permissionsGranted) { permissionLauncher.launch( PermissionsHelper.getSOSPermissions() + PermissionsHelper.getProximityScanPermissions() + PermissionsHelper.getDisasterDetectionPermissions() ) } } // Monitor Bluetooth state changes DisposableEffect(Unit) { val receiver = object : android.content.BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == BluetoothAdapter.ACTION_STATE_CHANGED) { val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) isBluetoothEnabled = state == BluetoothAdapter.STATE_ON } } } val filter = android.content.IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) context.registerReceiver(receiver, filter) // Lifecycle observer for onResume val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { // Refresh permissions and Bluetooth status PermissionsManager.refreshPermissions(context) isBluetoothEnabled = bluetoothAdapter?.isEnabled == true } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { context.unregisterReceiver(receiver) lifecycleOwner.lifecycle.removeObserver(observer) } } Column( modifier = modifier ) { if (!permissionsGranted) { PersistentBanner( message = "Permissions are missing.", actionLabel = "Settings", onActionClick = { // Open app settings val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data = Uri.fromParts("package", context.packageName, null) flags = Intent.FLAG_ACTIVITY_NEW_TASK } context.startActivity(intent) } ) } else if (!isBluetoothEnabled) { PersistentBanner( message = "Bluetooth is required.", actionLabel = "Enable", onActionClick = { // Open bluetooth settings val intent = Intent(Settings.ACTION_BLUETOOTH_SETTINGS).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } context.startActivity(intent) } ) } } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/ui/component/PersistentBanner.kt ================================================ package com.nizarmah.igatha.ui.component import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @Composable fun PersistentBanner( message: String, actionLabel: String, onActionClick: () -> Unit, backgroundColor: Color = Color(0xFF323232), messageColor: Color = Color.White, actionTextColor: Color = Color(0xFFBB86FC) ) { Surface( modifier = Modifier .fillMaxWidth() .padding(horizontal = 0.dp, vertical = 0.dp), // Minimal external padding color = backgroundColor, // Directly set the background color ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), // Internal padding for content verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = message, style = MaterialTheme.typography.bodyMedium, color = messageColor ) TextButton(onClick = onActionClick) { Text( text = actionLabel, color = actionTextColor ) } } } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/ui/component/Section.kt ================================================ package com.nizarmah.igatha.ui.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun Section( header: String? = null, footer: String? = null, padding: Modifier = Modifier.padding(vertical = 8.dp), content: @Composable () -> Unit, ) { Column(modifier = padding) { header?.let { SectionHeader(text = it) } Surface( color = MaterialTheme.colorScheme.surface, shape = MaterialTheme.shapes.medium, modifier = Modifier .padding(horizontal = 16.dp) .padding(vertical = 0.dp) ) { Column { content() } } footer?.let { SectionFooter(text = it) } } } @Composable fun SectionHeader(text: String) { Text( text = text.uppercase(), style = MaterialTheme.typography.bodySmall.copy( lineHeight = MaterialTheme.typography.bodySmall.fontSize.times(1.4f) ), color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp) ) } @Composable fun SectionFooter(text: String) { Text( text = text, style = MaterialTheme.typography.bodySmall.copy( lineHeight = MaterialTheme.typography.bodySmall.fontSize.times(1.4f) ), color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp) ) } @Composable fun SectionItem( content: @Composable RowScope.() -> Unit ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { content() } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/ui/screen/ContentScreen.kt ================================================ package com.nizarmah.igatha.ui.screen import android.net.Uri import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.NavType import androidx.navigation.navArgument import androidx.navigation.navDeepLink import com.google.gson.Gson import com.nizarmah.igatha.Constants import com.nizarmah.igatha.model.Device import com.nizarmah.igatha.ui.view.ContentView import com.nizarmah.igatha.ui.view.DeviceDetailView import com.nizarmah.igatha.viewmodel.ContentViewModel @Composable fun ContentScreen(modifier: Modifier) { val navController = rememberNavController() val viewModel: ContentViewModel = viewModel() val isSOSAvailable by viewModel.isSOSAvailable.collectAsState() val isSOSActive by viewModel.isSOSActive.collectAsState() val activeAlert by viewModel.activeAlert.collectAsState() val devices by viewModel.devices.collectAsState() NavHost( modifier = modifier, navController = navController, startDestination = "home", enterTransition = { slideIntoContainer( towards = AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(300) ) }, exitTransition = { slideOutOfContainer( towards = AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(300) ) }, popEnterTransition = { slideIntoContainer( towards = AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300) ) }, popExitTransition = { slideOutOfContainer( towards = AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300) ) } ) { composable("home") { ContentView( isSOSAvailable = isSOSAvailable, isSOSActive = isSOSActive, devices = devices, activeAlert = activeAlert, onSOSClick = { if (isSOSActive) { viewModel.stopSOS() } else { viewModel.showSOSConfirmation() } }, onConfirmSOS = { viewModel.dismissAlert() viewModel.startSOS() }, onDismissAlert = { viewModel.dismissAlert() }, onSettingsClick = { navController.navigate("settings") }, onDeviceClick = { device -> val deviceJson = Gson().toJson(device) navController.navigate("device/$deviceJson") } ) } composable( route = "settings", deepLinks = listOf( navDeepLink { uriPattern = Uri.Builder() .scheme(Constants.DeepLink.SCHEME) .authority(Constants.DeepLink.Settings.VALUE) .build().toString() } ) ) { SettingsScreen( onBackClick = { navController.popBackStack() }, onFeedbackClick = { navController.navigate("feedback") } ) } composable("feedback") { FeedbackFormScreen( onNavigateBack = { navController.popBackStack() } ) } composable( route = "device/{device}", arguments = listOf( navArgument("device") { type = NavType.StringType } ) ) { backStackEntry -> val deviceJson = backStackEntry.arguments?.getString("device") val device = Gson().fromJson(deviceJson, Device::class.java) DeviceDetailView( device = device, onBackClick = { navController.popBackStack() } ) } } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/ui/screen/FeedbackFormScreen.kt ================================================ package com.nizarmah.igatha.ui.screen import android.app.Application import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel import com.nizarmah.igatha.ui.view.FeedbackFormView import com.nizarmah.igatha.viewmodel.FeedbackFormViewModel import com.nizarmah.igatha.viewmodel.FeedbackFormViewModelFactory import com.nizarmah.igatha.viewmodel.FormState import com.nizarmah.igatha.viewmodel.SubmissionResult import kotlinx.coroutines.launch /** * Feedback form screen to understand why people use Igatha. * This screen is responsible for coordination between the view and viewmodel. */ @Composable fun FeedbackFormScreen( onNavigateBack: () -> Unit ) { // Create ViewModel with Factory val application = LocalContext.current.applicationContext as Application val viewModel: FeedbackFormViewModel = viewModel( factory = FeedbackFormViewModelFactory(application) ) // Collect state from ViewModel val formState by viewModel.formState.collectAsState() val submissionResult by viewModel.submissionResult.collectAsState() val hasCustomUsage by viewModel.hasCustomUsage.collectAsState() val customUsage by viewModel.customUsage.collectAsState() val ideas by viewModel.ideas.collectAsState() val email by viewModel.email.collectAsState() // For actions that need coroutine scope val scope = rememberCoroutineScope() // Use the FeedbackFormView from ui.view FeedbackFormView( formState = formState, customUsage = customUsage, ideas = ideas, email = email, hasCustomUsage = hasCustomUsage, isUsageReasonSelected = { viewModel.isUsageReasonSelected(it) }, onUsageReasonToggle = { viewModel.toggleUsageReason(it) }, onCustomUsageChange = { viewModel.updateCustomUsage(it) }, onIdeasChange = { viewModel.updateIdeas(it) }, onEmailChange = { viewModel.updateEmail(it) }, onSubmit = { if (formState == FormState.Idle) { scope.launch { viewModel.submit() } } }, onBackClick = onNavigateBack ) // Alert dialogs for submission results submissionResult?.let { result -> when (result) { is SubmissionResult.Success -> { AlertDialog( onDismissRequest = { viewModel.dismissAlert() onNavigateBack() }, title = { Text("Thank you!") }, text = { Text("Your feedback helps us improve Igatha.") }, confirmButton = { TextButton( onClick = { viewModel.dismissAlert() onNavigateBack() } ) { Text("Done", color = MaterialTheme.colorScheme.primary) } } ) } is SubmissionResult.Error -> { AlertDialog( onDismissRequest = { viewModel.dismissAlert() }, title = { Text("Error") }, text = { Text(result.message) }, confirmButton = { TextButton(onClick = { viewModel.dismissAlert() }) { Text("OK", color = MaterialTheme.colorScheme.primary) } } ) } } } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/ui/screen/SettingsScreen.kt ================================================ package com.nizarmah.igatha.ui.screen import android.app.Application import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.collectAsState import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel import com.nizarmah.igatha.ui.view.SettingsView import com.nizarmah.igatha.viewmodel.SettingsViewModel import com.nizarmah.igatha.viewmodel.SettingsViewModelFactory @Composable fun SettingsScreen( onBackClick: () -> Unit, onFeedbackClick: () -> Unit ) { val context = LocalContext.current.applicationContext as Application val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModelFactory(context)) val disasterDetectionEnabled by viewModel.disasterDetectionEnabled.collectAsState() val isDisasterDetectionAvailable by viewModel.isDisasterDetectionAvailable.collectAsState() SettingsView( disasterDetectionEnabled = disasterDetectionEnabled, isDisasterDetectionAvailable = isDisasterDetectionAvailable, onDisasterDetectionEnabledChanged = { enabled -> viewModel.setDisasterDetectionEnabled(enabled) }, onBackClick = onBackClick, onFeedbackClick = onFeedbackClick ) } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/ui/theme/Color.kt ================================================ package com.nizarmah.igatha.ui.theme import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color val Gray = Color(0xFF8E8E93) // iOS colors // @see https://developer.apple.com/design/human-interface-guidelines/color#iOS-iPadOS-system-colors val RedLight = Color(255, 59, 48) val RedDark = Color(255, 69, 58) val PinkLight = Color(255, 45, 85) val PinkDark = Color(255, 55, 95) val PurpleLight = Color(175, 82, 222) val PurpleDark = Color(191, 90, 242) /** * Data class representing iOS color palette with light/dark variants */ data class ColorScheme( val red: Color = RedLight, val pink: Color = PinkLight, val purple: Color = PurpleLight ) /** * Light mode iOS colors */ val LightColors = ColorScheme( red = RedLight, pink = PinkLight, purple = PurpleLight ) /** * Dark mode iOS colors */ val DarkColors = ColorScheme( red = RedDark, pink = PinkDark, purple = PurpleDark ) /** * CompositionLocal to provide iOS colors down the tree */ val LocalColors = staticCompositionLocalOf { LightColors } /** * Extension property for accessing iOS colors from MaterialTheme */ val MaterialTheme.colors: ColorScheme @Composable @ReadOnlyComposable get() = LocalColors.current ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/ui/theme/Theme.kt ================================================ package com.nizarmah.igatha.ui.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme 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.CompositionLocalProvider import androidx.compose.ui.platform.LocalContext private val BaseLightColorScheme = lightColorScheme() private val BaseDarkColorScheme = darkColorScheme() private val LightColorScheme = BaseLightColorScheme.copy( primary = RedLight, ) private val DarkColorScheme = BaseDarkColorScheme.copy( primary = RedDark, ) @Composable fun IgathaTheme( 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 } // Select the appropriate iOS colors based on theme val colors = if (darkTheme) DarkColors else LightColors // Provide both Material and iOS colors down the tree CompositionLocalProvider( LocalColors provides colors ) { MaterialTheme( colorScheme = colorScheme, typography = Typography, content = content ) } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/ui/theme/Type.kt ================================================ package com.nizarmah.igatha.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 ) */ ) ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/ui/view/ContentView.kt ================================================ package com.nizarmah.igatha.ui.view import androidx.compose.foundation.layout.* 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.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.nizarmah.igatha.ui.theme.Gray import com.nizarmah.igatha.ui.theme.IgathaTheme import com.nizarmah.igatha.model.Device import com.nizarmah.igatha.viewmodel.AlertType import java.util.Date import java.util.UUID @OptIn(ExperimentalMaterial3Api::class) @Composable fun ContentView( isSOSAvailable: Boolean, isSOSActive: Boolean, devices: List, activeAlert: AlertType?, onSOSClick: () -> Unit, onConfirmSOS: () -> Unit, onDismissAlert: () -> Unit, onSettingsClick: () -> Unit, onDeviceClick: (Device) -> Unit ) { Scaffold( topBar = { TopAppBar( title = {}, actions = { IconButton(onClick = onSettingsClick) { Icon( imageVector = Icons.Default.Settings, contentDescription = "Settings" ) } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainer ) ) } ) { paddingValues -> Column( modifier = Modifier .padding(paddingValues) .fillMaxSize() ) { Box( modifier = Modifier .weight(1f) .fillMaxWidth() ) { DeviceListView( devices = devices, onDeviceClick = onDeviceClick ) } SOSButton( isSOSAvailable = isSOSAvailable, isSOSActive = isSOSActive, onSOSClick = onSOSClick ) } } // Handle alerts activeAlert?.let { alert -> when (alert) { is AlertType.SOSConfirmation -> { AlertDialog( onDismissRequest = onDismissAlert, title = { Text("Are you sure?") }, text = { Text( text = "This will broadcast your location and start a loud siren.", lineHeight = MaterialTheme.typography.bodyMedium.fontSize.times(1.4f) ) }, confirmButton = { TextButton( onClick = onConfirmSOS ) { Text("Yes") } }, dismissButton = { TextButton( onClick = onDismissAlert ) { Text("Cancel") } } ) } } } } @Composable fun SOSButton( isSOSAvailable: Boolean = true, isSOSActive: Boolean = false, onSOSClick: () -> Unit = {} ) { Button( onClick = onSOSClick, enabled = isSOSAvailable, modifier = Modifier .padding(16.dp) .fillMaxWidth() .height(50.dp) .alpha(if (isSOSAvailable) 1f else 0.75f), shape = RoundedCornerShape(10.dp), colors = ButtonDefaults.buttonColors( containerColor = when { isSOSActive -> Gray else -> MaterialTheme.colorScheme.primary }, contentColor = Color.White, disabledContainerColor = MaterialTheme.colorScheme.primary, disabledContentColor = Color.White ) ) { Text( text = when { !isSOSAvailable -> "SOS Unavailable" isSOSActive -> "Stop SOS" else -> "Send SOS" }, fontWeight = FontWeight.Bold ) } } @Preview(showBackground = true) @Composable fun ContentViewPreview() { IgathaTheme { ContentView( isSOSAvailable = true, isSOSActive = false, devices = listOf( Device( id = UUID.randomUUID(), rssi = -75.0, lastSeen = Date() ), Device( id = UUID.randomUUID(), rssi = -85.0, lastSeen = Date() ) ), activeAlert = null, onSOSClick = {}, onConfirmSOS = {}, onDismissAlert = {}, onSettingsClick = {}, onDeviceClick = { device -> } ) } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/ui/view/DeviceDetailView.kt ================================================ package com.nizarmah.igatha.ui.view import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.sharp.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.nizarmah.igatha.model.Device import com.nizarmah.igatha.ui.theme.IgathaTheme import com.nizarmah.igatha.ui.component.Section import com.nizarmah.igatha.ui.component.SectionItem import java.util.Date import java.util.Locale import java.util.UUID @OptIn(ExperimentalMaterial3Api::class) @Composable fun DeviceDetailView( device: Device, onBackClick: () -> Unit ) { val timeSinceLastSeen = remember(device.lastSeen) { calculateTimeSinceLastSeen(device.lastSeen) } Scaffold( topBar = { TopAppBar( title = { Text("Device Details") }, navigationIcon = { IconButton(onClick = onBackClick) { Icon( imageVector = Icons.AutoMirrored.Sharp.ArrowBack, contentDescription = "Go back" ) } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainer ) ) } ) { paddingValues -> Column( modifier = Modifier .padding(paddingValues) .fillMaxSize() .background(MaterialTheme.colorScheme.surfaceContainer) ) { LazyColumn { item { Section( header = "Identity", footer = "Identity is pseudonymized for privacy." ) { SectionItem { Text(text = "Name", style = MaterialTheme.typography.bodyMedium) Spacer(modifier = Modifier.padding(4.dp)) Text( text = device.shortName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) SectionItem { Text(text = "ID", style = MaterialTheme.typography.bodyMedium) Spacer(modifier = Modifier.padding(4.dp)) Text( text = device.id, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary, fontFamily = FontFamily.Monospace, textAlign = TextAlign.End, modifier = Modifier.fillMaxWidth(), letterSpacing = (-0.5).sp ) } } } item { Section( header = "Location", footer = "Location is limited by used tech. Direction is not available. Distance is approximate and varies due to signal fluctuations. It is for general guidance only." ) { SectionItem { Text(text = "Distance", style = MaterialTheme.typography.bodyMedium) Spacer(modifier = Modifier.padding(4.dp)) Text( text = String.format( Locale.getDefault(), "%.1f meters away", device.estimateDistance() ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = FontFamily.Monospace ) } } } item { Section( header = "Status", footer = "Status shows if the device is active and in range." ) { SectionItem { Text(text = "Last Seen", style = MaterialTheme.typography.bodyMedium) Spacer(modifier = Modifier.padding(4.dp)) Text( text = timeSinceLastSeen, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } } } } } fun calculateTimeSinceLastSeen(lastSeen: Date): String { val now = Date() val diff = now.time - lastSeen.time val seconds = diff / 1000 val minutes = seconds / 60 val hours = minutes / 60 val days = hours / 24 val weeks = days / 7 return when { seconds < 60 -> "$seconds second${if (seconds != 1L) "s" else ""} ago" minutes < 60 -> "$minutes minute${if (minutes != 1L) "s" else ""} ago" hours < 24 -> "$hours hour${if (hours != 1L) "s" else ""} ago" days < 7 -> "$days day${if (days != 1L) "s" else ""} ago" else -> "$weeks week${if (weeks != 1L) "s" else ""} ago" } } @Preview(showBackground = true) @Composable fun DeviceDetailViewPreview() { IgathaTheme { DeviceDetailView( device = Device( id = UUID.randomUUID(), rssi = -40.0 ), onBackClick = { // do nothing } ) } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/ui/view/DeviceListView.kt ================================================ package com.nizarmah.igatha.ui.view import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.nizarmah.igatha.model.Device import com.nizarmah.igatha.ui.component.Section import com.nizarmah.igatha.ui.theme.IgathaTheme import java.util.Date import java.util.UUID @Composable fun DeviceListView(devices: List, onDeviceClick: (Device) -> Unit) { LazyColumn( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surfaceContainer) ) { item { Section ( header = "People seeking help", footer = "Note: Distance is approximate and varies due to signal fluctuations. It is for general guidance only. Proximity scanning requires location on Android 11 or lower." ) { if (devices.isEmpty()) { Text( "No devices found nearby.", color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier .fillMaxWidth() .padding(16.dp) ) } else { devices.forEachIndexed { index, device -> DeviceRowView( device = device, onClick = { onDeviceClick(device) } ) if (index < devices.size - 1) { HorizontalDivider( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant ) } } } } } } } @Composable @Preview(showBackground = true) fun DeviceListViewPreview() { val mockDevices = listOf( Device(id = UUID.randomUUID(), rssi = -40.0), Device(id = UUID.randomUUID(), rssi = -60.0, lastSeen = Date(System.currentTimeMillis() - 600_000)), Device(id = UUID.randomUUID(), rssi = -75.0), Device(id = UUID.randomUUID(), rssi = -85.0) ) IgathaTheme { DeviceListView(devices = mockDevices) {} } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/ui/view/DeviceRowView.kt ================================================ package com.nizarmah.igatha.ui.view import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.sharp.KeyboardArrowRight import androidx.compose.material.icons.filled.Person import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.nizarmah.igatha.model.Device import com.nizarmah.igatha.ui.theme.IgathaTheme import java.util.* @Composable fun DeviceRowView(device: Device, onClick: () -> Unit = {}) { val isStale = device.lastSeen.before( Date(System.currentTimeMillis() - 300_000) ) // 5 minutes ago Row( modifier = Modifier .fillMaxWidth() .clickable { onClick() } .padding(16.dp) .alpha(if (isStale) 0.4f else 1.0f), verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = Icons.Default.Person, contentDescription = "Device Icon", tint = MaterialTheme.colorScheme.onSecondary, modifier = Modifier .size(40.dp) .background( color = MaterialTheme.colorScheme.secondary, shape = CircleShape ) .padding(5.dp) ) Spacer(modifier = Modifier.width(16.dp)) Column( modifier = Modifier.weight(1f) ) { Text( text = device.shortName, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface ) Spacer(modifier = Modifier.height(4.dp)) Text( text = String.format( Locale.getDefault(), "%.1f meters away", device.estimateDistance()), style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant ) } Icon( imageVector = Icons.AutoMirrored.Sharp.KeyboardArrowRight, contentDescription = "See device details", tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } @Composable @Preview(showBackground = true) fun DeviceRowViewPreview() { val previewDevice = Device( id = UUID.randomUUID(), rssi = -60.0 ) IgathaTheme { DeviceRowView(device = previewDevice) } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/ui/view/FeedbackFormView.kt ================================================ package com.nizarmah.igatha.ui.view import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.sharp.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.nizarmah.igatha.ui.component.Section import com.nizarmah.igatha.ui.theme.IgathaTheme import com.nizarmah.igatha.viewmodel.FormState import com.nizarmah.igatha.viewmodel.UsageReason @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun FeedbackFormView( formState: FormState, customUsage: String, ideas: String, email: String, hasCustomUsage: Boolean, isUsageReasonSelected: (UsageReason) -> Boolean, onUsageReasonToggle: (UsageReason) -> Unit, onCustomUsageChange: (String) -> Unit, onIdeasChange: (String) -> Unit, onEmailChange: (String) -> Unit, onSubmit: () -> Unit, onBackClick: () -> Unit ) { // Focus and keyboard management val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current Scaffold( topBar = { TopAppBar( title = { Text("Share Feedback") }, navigationIcon = { IconButton(onClick = onBackClick) { Icon( imageVector = Icons.AutoMirrored.Sharp.ArrowBack, contentDescription = "Go back" ) } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainer ) ) } ) { paddingValues -> Column( modifier = Modifier .padding(paddingValues) .fillMaxSize() .background(MaterialTheme.colorScheme.surfaceContainer) ) { LazyColumn { // Usage reasons section item { Section( header = "Why do you use Igatha?", footer = "Select all that apply." ) { Column { // Usage reason items UsageReason.allCases.forEachIndexed { index, reason -> UsageReasonItem( reason = reason, isSelected = isUsageReasonSelected(reason), onToggle = { onUsageReasonToggle(reason) } ) // Add divider between items (except for the last one) if (index < UsageReason.allCases.size - 1) { HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) } } // Custom reason text field (conditional) if (hasCustomUsage) { HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) TextField( value = customUsage, onValueChange = onCustomUsageChange, modifier = Modifier.fillMaxWidth(), placeholder = { Text("Please describe") }, colors = TextFieldDefaults.colors( unfocusedContainerColor = MaterialTheme.colorScheme.surface, focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedIndicatorColor = MaterialTheme.colorScheme.surfaceVariant, focusedIndicatorColor = MaterialTheme.colorScheme.primary ), singleLine = true, keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Next ), keyboardActions = KeyboardActions( onNext = { focusManager.moveFocus(FocusDirection.Down) } ) ) } } } } // Ideas section item { Section( header = "What would make Igatha more helpful?", footer = "Optional. If you have any ideas, don't hesitate." ) { TextField( value = ideas, onValueChange = onIdeasChange, modifier = Modifier .fillMaxWidth() .height(120.dp), colors = TextFieldDefaults.colors( unfocusedContainerColor = MaterialTheme.colorScheme.surface, focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedIndicatorColor = MaterialTheme.colorScheme.surfaceVariant, focusedIndicatorColor = MaterialTheme.colorScheme.primary ), keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Next ), keyboardActions = KeyboardActions( onNext = { focusManager.moveFocus(FocusDirection.Down) } ) ) } } // Email section item { Section( header = "Your email", footer = "Optional. We'll only contact you for clarifications." ) { TextField( value = email, onValueChange = onEmailChange, modifier = Modifier.fillMaxWidth(), placeholder = { Text("Email address") }, colors = TextFieldDefaults.colors( unfocusedContainerColor = MaterialTheme.colorScheme.surface, focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedIndicatorColor = MaterialTheme.colorScheme.surfaceVariant, focusedIndicatorColor = MaterialTheme.colorScheme.primary ), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Email, capitalization = KeyboardCapitalization.None, imeAction = ImeAction.Done ), keyboardActions = KeyboardActions( onDone = { keyboardController?.hide() focusManager.clearFocus() } ), singleLine = true ) } } // Submit button section item { Section( padding = Modifier.padding(vertical = 16.dp) ) { Button( onClick = { keyboardController?.hide() focusManager.clearFocus() onSubmit() }, modifier = Modifier .fillMaxWidth(), enabled = formState == FormState.Idle, shape = RoundedCornerShape(8.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.surface, contentColor = MaterialTheme.colorScheme.primary ), contentPadding = PaddingValues(16.dp) ) { Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { Text( text = "Submit", style = MaterialTheme.typography.bodyLarge ) // Show progress indicator if submitting if (formState == FormState.Submitting) { CircularProgressIndicator( modifier = Modifier.size(20.dp), strokeWidth = 2.dp ) } } } } } // Spacer item { Spacer(modifier = Modifier.height(16.dp)) } } } } } @Composable private fun UsageReasonItem( reason: UsageReason, isSelected: Boolean, onToggle: () -> Unit ) { // State that tracks the visual selection state, initially set to the actual selection state val (visuallySelected, setVisuallySelected) = androidx.compose.runtime.remember(isSelected) { androidx.compose.runtime.mutableStateOf(isSelected) } // Update the visual state when the actual selection state changes androidx.compose.runtime.LaunchedEffect(isSelected) { setVisuallySelected(isSelected) } Row( modifier = Modifier .fillMaxWidth() .clickable { // Immediately update visual state for responsive feedback setVisuallySelected(!visuallySelected) // Then trigger the actual model update onToggle() } .padding(vertical = 12.dp, horizontal = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { // Reason content Column(modifier = Modifier.weight(1f)) { Text( text = reason.displayString, style = MaterialTheme.typography.bodyLarge ) // Add spacing between title and example Spacer(modifier = Modifier.height(4.dp)) // Subtext reason.exampleString?.let { example -> Text( text = example, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, lineHeight = MaterialTheme.typography.bodySmall.fontSize.times(1.4f) ) } } // Checkmark for selected reasons - use visuallySelected for immediate feedback if (visuallySelected) { Icon( imageVector = Icons.Default.Check, contentDescription = "Selected", tint = MaterialTheme.colorScheme.primary ) } } } @Preview(showBackground = true) @Composable fun FeedbackFormViewPreview() { IgathaTheme { FeedbackFormView( formState = FormState.Idle, customUsage = "I use it for my personal safety", ideas = "Would be nice to have offline maps", email = "user@example.com", hasCustomUsage = true, isUsageReasonSelected = { it in setOf(UsageReason.DisasterPreparedness, UsageReason.Other) }, onUsageReasonToggle = {}, onCustomUsageChange = {}, onIdeasChange = {}, onEmailChange = {}, onSubmit = {}, onBackClick = {} ) } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/ui/view/SettingsView.kt ================================================ package com.nizarmah.igatha.ui.view import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.sharp.ArrowBack import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.nizarmah.igatha.ui.component.FeedbackButtonView import com.nizarmah.igatha.ui.component.Section import com.nizarmah.igatha.ui.component.SectionItem import com.nizarmah.igatha.ui.theme.IgathaTheme @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsView( disasterDetectionEnabled: Boolean, isDisasterDetectionAvailable: Boolean, onDisasterDetectionEnabledChanged: (Boolean) -> Unit, onBackClick: () -> Unit, onFeedbackClick: () -> Unit = {} ) { Scaffold( topBar = { TopAppBar( title = { Text("Settings") }, navigationIcon = { IconButton(onClick = onBackClick) { Icon( imageVector = Icons.AutoMirrored.Sharp.ArrowBack, contentDescription = "Go back" ) } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainer ) ) } ) { paddingValues -> Column( modifier = Modifier .padding(paddingValues) .fillMaxSize() .background(MaterialTheme.colorScheme.surfaceContainer) ) { LazyColumn { item { Section( header = "Background Services", footer = "Services might require additional permissions." ) { SectionItem { Column(modifier = Modifier.weight(1f)) { Text( text = "Disaster Detection", style = MaterialTheme.typography.bodyLarge ) Spacer(modifier = Modifier.height(4.dp)) Text( text = "Detects disasters and sends SOS when the app is not in use. This may increase battery consumption.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary, lineHeight = MaterialTheme.typography.bodySmall.fontSize.times(1.4f) ) } Spacer(modifier = Modifier.padding(2.dp)) Switch( checked = disasterDetectionEnabled, onCheckedChange = onDisasterDetectionEnabledChanged, enabled = isDisasterDetectionAvailable ) } } } item { Section( header = "Feedback", footer = "Your feedback helps us improve Igatha, for everyone." ) { Box(modifier = Modifier.padding(horizontal = 0.dp)) { FeedbackButtonView( onClick = onFeedbackClick ) } } } } } } } @Preview(showBackground = true) @Composable fun SettingsViewPreview() { IgathaTheme { SettingsView( disasterDetectionEnabled = true, isDisasterDetectionAvailable = true, onDisasterDetectionEnabledChanged = {}, onBackClick = {} ) } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/util/PermissionsHelper.kt ================================================ package com.nizarmah.igatha.util import android.Manifest import android.os.Build import kotlin.collections.plus object PermissionsHelper { fun getNotificationsPermissions(): Array { var permissions = emptyArray() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { permissions += arrayOf( Manifest.permission.POST_NOTIFICATIONS ) } return permissions } fun getSOSPermissions(): Array { var permissions = arrayOf( // SOS Beacon Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN, // Siren Player Manifest.permission.MODIFY_AUDIO_SETTINGS ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { permissions += arrayOf( // SOS Beacon Manifest.permission.BLUETOOTH_ADVERTISE ) } permissions += getServicePermissions() return permissions } fun getProximityScanPermissions(): Array { var permissions = arrayOf( // ProximityScanner Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN, ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { permissions += arrayOf( // ProximityScanner Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT, ) } if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { permissions += arrayOf( // ProximityScanner Manifest.permission.ACCESS_COARSE_LOCATION ) } return permissions } fun getDisasterDetectionPermissions(): Array { var permissions = emptyArray() permissions += getServicePermissions() return permissions } private fun getServicePermissions(): Array { var permissions = emptyArray() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { permissions += arrayOf( Manifest.permission.FOREGROUND_SERVICE ) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { permissions += arrayOf( Manifest.permission.HIGH_SAMPLING_RATE_SENSORS ) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { permissions += arrayOf( Manifest.permission.FOREGROUND_SERVICE_HEALTH ) } // Foreground services need a notification // So, check notification permissions as well permissions += getNotificationsPermissions() return permissions } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/util/PermissionsManager.kt ================================================ package com.nizarmah.igatha.util import android.content.Context import android.content.pm.PackageManager import androidx.core.content.ContextCompat import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn object PermissionsManager { private val _notificationsPermitted = MutableStateFlow(false) val notificationsPermitted: StateFlow = _notificationsPermitted.asStateFlow() private val _sosPermitted = MutableStateFlow(false) val sosPermitted: StateFlow = _sosPermitted.asStateFlow() private val _disasterDetectionPermitted = MutableStateFlow(false) val disasterDetectionPermitted: StateFlow = _disasterDetectionPermitted.asStateFlow() private val _proximityScanPermitted = MutableStateFlow(false) val proximityScanPermitted: StateFlow = _proximityScanPermitted.asStateFlow() @OptIn(DelicateCoroutinesApi::class) val permissionsGranted: StateFlow = combine( sosPermitted, disasterDetectionPermitted, proximityScanPermitted, notificationsPermitted ) { sos, disaster, proximity, notifications -> sos && disaster && proximity && notifications }.stateIn(GlobalScope, SharingStarted.Eagerly, false) // Initialize by checking current permissions fun init(context: Context) { refreshPermissions(context) } // Function to refresh the current permission state fun refreshPermissions(context: Context) { _notificationsPermitted.value = hasPermissions(context, PermissionsHelper.getNotificationsPermissions()) _sosPermitted.value = hasPermissions(context, PermissionsHelper.getSOSPermissions()) _proximityScanPermitted.value = hasPermissions(context, PermissionsHelper.getProximityScanPermissions()) _disasterDetectionPermitted.value = hasPermissions(context, PermissionsHelper.getDisasterDetectionPermissions()) } // Helper function to check permissions private fun hasPermissions(context: Context, permissions: Array): Boolean { return permissions.all { permission -> ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED } } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/util/SettingsManager.kt ================================================ package com.nizarmah.igatha.util import android.content.Context import android.content.SharedPreferences import com.nizarmah.igatha.Constants import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow object SettingsManager { private val _disasterDetectionEnabled = MutableStateFlow(true) val disasterDetectionEnabled: StateFlow = _disasterDetectionEnabled.asStateFlow() private lateinit var sharedPreferences: SharedPreferences fun init(context: Context) { sharedPreferences = getSharedPreferences(context) // Initialize the StateFlow with the current value val enabled = sharedPreferences.getBoolean(Constants.DISASTER_DETECTION_ENABLED_KEY, true) _disasterDetectionEnabled.value = enabled // Listen for changes in SharedPreferences sharedPreferences.registerOnSharedPreferenceChangeListener { prefs, key -> if (key == Constants.DISASTER_DETECTION_ENABLED_KEY) { val newValue = prefs.getBoolean(key, true) _disasterDetectionEnabled.value = newValue } } } fun setDisasterDetectionEnabled(enabled: Boolean) { _disasterDetectionEnabled.value = enabled sharedPreferences.edit().putBoolean( Constants.DISASTER_DETECTION_ENABLED_KEY, enabled).apply() } private fun getSharedPreferences(context: Context): SharedPreferences { return context.getSharedPreferences( Constants.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE ) } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/viewmodel/ContentViewModel.kt ================================================ package com.nizarmah.igatha.viewmodel import android.app.Application import android.content.Intent import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.nizarmah.igatha.Constants import com.nizarmah.igatha.model.Device import com.nizarmah.igatha.service.DisasterDetectionService import com.nizarmah.igatha.service.EmergencyManager import com.nizarmah.igatha.service.ProximityScanner import com.nizarmah.igatha.service.SOSService import com.nizarmah.igatha.util.PermissionsManager import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch class ContentViewModel(app: Application) : AndroidViewModel(app) { private val emergencyManager = EmergencyManager.getInstance(getApplication()) private val proximityScanner = ProximityScanner(getApplication()) // SOS state val isSOSAvailable: StateFlow = emergencyManager.isSOSAvailable val isSOSActive: StateFlow = emergencyManager.isSOSActive // ProximityScanner state val isProximityScanAvailable: StateFlow = combine( proximityScanner.isAvailable, PermissionsManager.proximityScanPermitted ) { isAvailable, isPermitted -> isAvailable && isPermitted }.stateIn(viewModelScope, SharingStarted.Eagerly, false) // Active alert state private val _activeAlert = MutableStateFlow(null) val activeAlert: StateFlow = _activeAlert.asStateFlow() private val _devicesMap = MutableStateFlow>(emptyMap()) val devices: StateFlow> = _devicesMap.asStateFlow() .map { devicesMap -> devicesMap.values.sortedByDescending { it.rssi } } .stateIn( viewModelScope, SharingStarted.Eagerly, emptyList() ) init { // Observe the disaster detection availability viewModelScope.launch { emergencyManager.isDetectorEnabled.collect { available -> if (available) { startDisasterDetectionService() } else { stopDisasterDetectionService() } } } // Observe proximity scanner's scanned devices viewModelScope.launch { proximityScanner.scannedDevices.collect { device -> device?.let { updateDevice(it) } } } // Start or stop the scanner based on availability viewModelScope.launch { isProximityScanAvailable.collect { available -> if (available) { proximityScanner.startScanning() } else { proximityScanner.stopScanning() } } } } private fun startDisasterDetectionService() { val context = getApplication() val serviceIntent = Intent(context, DisasterDetectionService::class.java) context.startForegroundService(serviceIntent) } private fun stopDisasterDetectionService() { val context = getApplication() val serviceIntent = Intent(context, DisasterDetectionService::class.java) context.stopService(serviceIntent) } private fun startSOSService() { val context = getApplication() val serviceIntent = Intent(context, SOSService::class.java) context.startForegroundService(serviceIntent) } private fun stopSOSService() { val context = getApplication() val serviceIntent = Intent(context, SOSService::class.java).apply { action = Constants.ACTION_STOP_SOS } context.startService(serviceIntent) } fun startSOS() { if (!isSOSAvailable.value || isSOSActive.value) { return } startSOSService() } fun stopSOS() { stopSOSService() } fun showSOSConfirmation() { if (!isSOSAvailable.value || isSOSActive.value) { return } _activeAlert.value = AlertType.SOSConfirmation } fun dismissAlert() { _activeAlert.value = null } private fun updateDevice(device: Device) { _devicesMap.update { currentMap -> val updatedMap = currentMap.toMutableMap() updatedMap[device.id] = device updatedMap } } override fun onCleared() { super.onCleared() proximityScanner.deinit() } } sealed class AlertType(id: Int) { object SOSConfirmation : AlertType(1) } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/viewmodel/FeedbackFormViewModel.kt ================================================ package com.nizarmah.igatha.viewmodel import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONObject import java.net.HttpURLConnection import java.net.URL import java.net.URLEncoder import java.util.UUID /** * FeedbackFormViewModel handles all logic for the feedback form view. */ class FeedbackFormViewModel( app: Application, private val feedbackService: FeedbackService = UsageFeedbackGoogleForm(Dispatchers.IO) ) : AndroidViewModel(app) { // Form state private val _formState = MutableStateFlow(FormState.Idle) val formState: StateFlow = _formState.asStateFlow() // Submission result private val _submissionResult = MutableStateFlow(null) val submissionResult: StateFlow = _submissionResult.asStateFlow() // Form data private val _usageReasons = MutableStateFlow>(emptySet()) // Form bindings private val _customUsage = MutableStateFlow("") val customUsage: StateFlow = _customUsage.asStateFlow() private val _ideas = MutableStateFlow("") val ideas: StateFlow = _ideas.asStateFlow() private val _email = MutableStateFlow("") val email: StateFlow = _email.asStateFlow() // Checks if the user selected the "other" usage reason val hasCustomUsage: StateFlow = MutableStateFlow(false).apply { viewModelScope.launch { _usageReasons.collect { reasons -> value = reasons.contains(UsageReason.Other) } } } // Updates the form data fun updateCustomUsage(value: String) { _customUsage.value = value } fun updateIdeas(value: String) { _ideas.value = value } fun updateEmail(value: String) { _email.value = value } // Checks if the usage reason is selected fun isUsageReasonSelected(reason: UsageReason): Boolean { return _usageReasons.value.contains(reason) } // Toggles the usage reason fun toggleUsageReason(reason: UsageReason) { val currentReasons = _usageReasons.value.toMutableSet() if (currentReasons.contains(reason)) { currentReasons.remove(reason) } else { currentReasons.add(reason) } _usageReasons.value = currentReasons } // Dismisses submission result alert fun dismissAlert() { _submissionResult.value = null } // Validates the form and returns the error if it's invalid fun validateForm(): String? { // Form is valid if at least one usage reason is selected if (_usageReasons.value.isEmpty()) { return "Please select at least one usage reason." } // If "other" is selected, custom usage should not be empty if (_usageReasons.value.contains(UsageReason.Other) && _customUsage.value.trim().isEmpty()) { return "Please specify why you chose 'other'." } return null } // Submits the feedback form fun submit() { // Validate the form and return the error if it's invalid val errorMessage = validateForm() if (errorMessage != null) { _submissionResult.value = SubmissionResult.Error(errorMessage) return } // Update form state to submitting _formState.value = FormState.Submitting // Create a feedback object val feedback = Feedback( usageReasons = _usageReasons.value, customUsage = _customUsage.value, ideas = _ideas.value, email = _email.value ) // Submit the form asynchronously viewModelScope.launch { try { // Try to submit the form feedbackService.submit(feedback) // Show the success alert _submissionResult.value = SubmissionResult.Success } catch (e: Exception) { // Show the error alert with a more descriptive message val errorMessage = when { e.message.isNullOrBlank() -> "Connection error. Check your internet connection." else -> e.message } // Generate a truncated reference ID val refId = UUID.randomUUID().toString().substring(0, 8) // Log the error with full details for troubleshooting (shows in logcat) // TODO: Replace local logging with an error reporting service android.util.Log.e( "FeedbackForm", "Error submitting form - Ref: $refId - Details: $errorMessage", e ) _submissionResult.value = SubmissionResult.Error( "Your feedback could not be submitted. Please try again later. (Ref: $refId)" ) } finally { // Update form state to idle _formState.value = FormState.Idle } } } } /** * Interface for feedback services to abstract the submission process. */ interface FeedbackService { suspend fun submit(feedback: Feedback) } /** * UsageFeedbackGoogleForm has the submission logic for https://forms.gle/rcu3MZjPYww7Fbnh7. * This class performs network operations in a coroutine context. */ class UsageFeedbackGoogleForm(private val ioDispatcher: CoroutineDispatcher) : FeedbackService { // Form URL for the POST request private val formUrl = "https://docs.google.com/forms/u/0/d/e/1FAIpQLSdCdNYIaPcg2-eAs1Mlvwoa6P5Ijqfdb1hmWlaA-poIKpMDtQ/formResponse" // Feedback field identifier private val feedbackField = "entry.457989095" // Submits the feedback form override suspend fun submit(feedback: Feedback) = withContext(ioDispatcher) { // Create a URL connection val url = URL(formUrl) val connection = url.openConnection() as HttpURLConnection try { // Set up the connection connection.requestMethod = "POST" connection.doOutput = true connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") connection.connectTimeout = 15000 // 15 seconds timeout connection.readTimeout = 15000 // 15 seconds timeout // Create the form data val postData = "${feedbackField}=${URLEncoder.encode(feedback.toJson(), "UTF-8")}" // Send the request connection.outputStream.use { os -> val input = postData.toByteArray(Charsets.UTF_8) os.write(input, 0, input.size) } // Check the response code val responseCode = connection.responseCode if (responseCode != HttpURLConnection.HTTP_OK && responseCode != HttpURLConnection.HTTP_MOVED_TEMP) { throw Exception("HTTP error: $responseCode") } } catch (e: Exception) { throw Exception("Network error: ${e.message ?: e.javaClass.simpleName}") } finally { connection.disconnect() } } } /** * Feedback stores the form data and converts it to a JSON string. */ data class Feedback( val usageReasons: Set, val customUsage: String, val ideas: String, val email: String ) { // Constant form data private val device = "android" private val key = "android-usage-feedback-v1.0.0" // Converts the form data to a JSON string fun toJson(): String { val jsonObject = JSONObject().apply { put("usage", JSONObject().apply { put("reasons", usageReasons.map { it.displayString }) put("custom", customUsage) }) put("ideas", ideas) put("email", email) put("device", device) put("key", key) } return jsonObject.toString() } } /** * FormState stores the state of the form. */ sealed class FormState { object Idle : FormState() object Submitting : FormState() } /** * SubmissionResult stores the result of the form submission. */ sealed class SubmissionResult { object Success : SubmissionResult() data class Error(val message: String) : SubmissionResult() } /** * UsageReason stores all usage reasons and their examples. */ enum class UsageReason { DisasterPreparedness, AdventureTravel, Caregiving, WorkplaceSafety, RegionalConflict, Other; // Display string is the primary text for the usage reason val displayString: String get() = when (this) { DisasterPreparedness -> "Disaster Preparedness" AdventureTravel -> "Adventure & Travel" Caregiving -> "Caregiving" WorkplaceSafety -> "Workplace Safety" RegionalConflict -> "Regional Conflict or Instability" Other -> "Other" } // Example string is the subtext for the usage reason val exampleString: String? get() = when (this) { DisasterPreparedness -> "e.g., earthquakes, floods, conflicts" AdventureTravel -> "e.g., hiking, biking, remote trips" Caregiving -> "e.g., monitoring elderly or dependents" WorkplaceSafety -> "e.g., construction, mining, field jobs" RegionalConflict -> "e.g., war, civil unrest, political instability" Other -> "Please specify below" } companion object { // All cases for iteration val allCases: List = entries.toList() } } /* Notes [1]: I am aware that the Google form can be spammed. I care most about emails, and this helps me reach users directly. I made sure there's no long term or financial damage from spam. This cheap implementation to get emails here outweighs any other. */ ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/viewmodel/FeedbackFormViewModelFactory.kt ================================================ package com.nizarmah.igatha.viewmodel import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider /** * Factory for creating a FeedbackFormViewModel with a constructor that takes an application. */ class FeedbackFormViewModelFactory(private val application: Application) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(FeedbackFormViewModel::class.java)) { return FeedbackFormViewModel(application) as T } throw IllegalArgumentException("Unknown ViewModel class") } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/viewmodel/SettingsViewModel.kt ================================================ package com.nizarmah.igatha.viewmodel import android.app.Application import androidx.lifecycle.AndroidViewModel import com.nizarmah.igatha.util.SettingsManager import kotlinx.coroutines.flow.StateFlow import com.nizarmah.igatha.service.EmergencyManager class SettingsViewModel(app: Application) : AndroidViewModel(app) { val disasterDetectionEnabled: StateFlow = SettingsManager.disasterDetectionEnabled val isDisasterDetectionAvailable: StateFlow = EmergencyManager.getInstance(getApplication()).isDetectorAvailable fun setDisasterDetectionEnabled(enabled: Boolean) { SettingsManager.setDisasterDetectionEnabled(enabled) } } ================================================ FILE: android/app/src/main/java/com/nizarmah/igatha/viewmodel/SettingsViewModelFactory.kt ================================================ package com.nizarmah.igatha.viewmodel import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider class SettingsViewModelFactory(private val application: Application) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(SettingsViewModel::class.java)) { @Suppress("UNCHECKED_CAST") return SettingsViewModel(application) as T } throw IllegalArgumentException("Unknown ViewModel class") } } ================================================ FILE: android/app/src/main/res/drawable/ic_im_okay.xml ================================================ ================================================ FILE: android/app/src/main/res/drawable/ic_launcher_background.xml ================================================ ================================================ FILE: android/app/src/main/res/drawable/ic_launcher_foreground.xml ================================================ ================================================ FILE: android/app/src/main/res/drawable/ic_need_help.xml ================================================ ================================================ FILE: android/app/src/main/res/drawable/ic_notification.xml ================================================ ================================================ FILE: android/app/src/main/res/drawable/ic_notification_alerting.xml ================================================ ================================================ FILE: android/app/src/main/res/drawable/ic_notification_signaling.xml ================================================ ================================================ FILE: android/app/src/main/res/drawable/ic_stop_sos.xml ================================================ ================================================ FILE: android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: android/app/src/main/res/values/colors.xml ================================================ #FFBB86FC #FF6200EE #FF3700B3 #FF03DAC5 #FF018786 #FF000000 #FFFFFFFF ================================================ FILE: android/app/src/main/res/values/ic_launcher_background.xml ================================================ #FF3B30 ================================================ FILE: android/app/src/main/res/values/strings.xml ================================================ Igatha ================================================ FILE: android/app/src/main/res/values/themes.xml ================================================