Showing preview only (331K chars total). Download the full file or copy to clipboard to get everything.
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
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
================================================
FILE: android/.idea/compiler.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
================================================
FILE: android/.idea/deploymentTargetSelector.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>
================================================
FILE: android/.idea/deviceManager.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
================================================
FILE: android/.idea/gradle.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>
================================================
FILE: android/.idea/inspectionProfiles/Project_Default.xml
================================================
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>
================================================
FILE: android/.idea/kotlinc.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.0.0" />
</component>
</project>
================================================
FILE: android/.idea/migrations.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>
================================================
FILE: android/.idea/misc.xml
================================================
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
================================================
FILE: android/.idea/runConfigurations.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
================================================
FILE: android/.idea/vcs.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
================================================
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
================================================
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- FeedbackFormViewModel - Required for Google Forms submission -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- ProximityScanner | SOSBeacon -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<!-- ProximityScanner -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- See: https://source.android.com/docs/core/connect/bluetooth/ble#location-scanning -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" />
<!-- SOSBeacon -->
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<!-- SirenPlayer -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- SOSService | DisasterDetectionService -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH" />
<uses-permission android:name="android.permission.HIGH_SAMPLING_RATE_SENSORS" />
<!-- See: https://developer.android.com/develop/ui/views/notifications/notification-permission -->
<!-- See: com.nizarmah.igatha.util.PermissionsHelper -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"
android:minSdkVersion="33" />
<application
android:name=".IgathaApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Igatha"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Igatha">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep link handling -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="igatha" />
</intent-filter>
</activity>
<service
android:name=".service.DisasterDetectionService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="health"
android:stopWithTask="false" />
<service
android:name=".service.SOSService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="health"
android:stopWithTask="false" />
</application>
</manifest>
================================================
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<FeedbackWorker>()
.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<SensorCapturedEvent>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
override val events: SharedFlow<SensorCapturedEvent> = _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<SensorCapturedEvent>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
override val events: SharedFlow<SensorCapturedEvent> = _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<SensorCapturedEvent>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
override val events: SharedFlow<SensorCapturedEvent> = _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<SensorCapturedEvent>
}
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<SensorType, Long>()
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<Boolean> = _isAvailable.asStateFlow()
private val _isActive = MutableStateFlow(false)
val isActive: StateFlow<Boolean> = _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<Unit>(replay = 0)
val disasterDetectedFlow: SharedFlow<Unit> = _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<Boolean> = combine(
sosBeacon.isAvailable,
sirenPlayer.isAvailable,
PermissionsManager.sosPermitted
) { beacon, siren, permitted ->
beacon && siren && permitted
}.stateIn(scope, SharingStarted.Eagerly, false)
val isSOSActive: StateFlow<Boolean> = combine(
sosBeacon.isActive,
sirenPlayer.isActive
) { beacon, siren ->
beacon || siren
}.stateIn(scope, SharingStarted.Eagerly, false)
val isDetectorAvailable: StateFlow<Boolean> = combine(
disasterDetector.isAvailable,
isSOSAvailable,
PermissionsManager.disasterDetectionPermitted
) { detector, sos, permitted ->
detector && sos && permitted
}.stateIn(scope, SharingStarted.Eagerly, false)
val isDetectorEnabled: StateFlow<Boolean> = combine(
isDetectorAvailable,
SettingsManager.disasterDetectionEnabled
) { available, enabled ->
available && enabled
}.stateIn(scope, SharingStarted.Eagerly, false)
val isDetectorActive: StateFlow<Boolean> = 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<Boolean> = _isActive.asStateFlow()
private val _isAvailable = MutableStateFlow(false)
val isAvailable: StateFlow<Boolean> = _isAvailable.asStateFlow()
private val _scannedDevices = MutableStateFlow<Device?>(null)
val scannedDevices: StateFlow<Device?> = _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<Boolean> = _isActive.asStateFlow()
private val _isAvailable = MutableStateFlow(false)
val isAvailable: StateFlow<Boolean> = _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<Boolean> = _isActive.asStateFlow()
private val _isAvailable = MutableStateFlow(false)
val isAvailable: StateFlow<Boolean> = _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<Device>,
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<Device>, 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<String> {
var permissions = emptyArray<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions += arrayOf(
Manifest.permission.POST_NOTIFICATIONS
)
}
return permissions
}
fun getSOSPermissions(): Array<String> {
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<String> {
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<String> {
var permissions = emptyArray<String>()
permissions += getServicePermissions()
return permissions
}
private fun getServicePermissions(): Array<String> {
var permissions = emptyArray<String>()
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<Boolean> = _notificationsPermitted.asStateFlow()
private val _sosPermitted = MutableStateFlow(false)
val sosPermitted: StateFlow<Boolean> = _sosPermitted.asStateFlow()
private val _disasterDetectionPermitted = MutableStateFlow(false)
val disasterDetectionPermitted: StateFlow<Boolean> = _disasterDetectionPermitted.asStateFlow()
private val _proximityScanPermitted = MutableStateFlow(false)
val proximityScanPermitted: StateFlow<Boolean> = _proximityScanPermitted.asStateFlow()
@OptIn(DelicateCoroutinesApi::class)
val permissionsGranted: StateFlow<Boolean> = 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<String>): 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<Boolean> = _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<Boolean> = emergencyManager.isSOSAvailable
val isSOSActive: StateFlow<Boolean> = emergencyManager.isSOSActive
// ProximityScanner state
val isProximityScanAvailable: StateFlow<Boolean> = combine(
proximityScanner.isAvailable,
PermissionsManager.proximityScanPermitted
) { isAvailable, isPermitted ->
isAvailable && isPermitted
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
// Active alert state
private val _activeAlert = MutableStateFlow<AlertType?>(null)
val activeAlert: StateFlow<AlertType?> = _activeAlert.asStateFlow()
private val _devicesMap = MutableStateFlow<Map<String, Device>>(emptyMap())
val devices: StateFlow<List<Device>> = _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<Application>()
val serviceIntent = Intent(context, DisasterDetectionService::class.java)
context.startForegroundService(serviceIntent)
}
private fun stopDisasterDetectionService() {
val context = getApplication<Application>()
val serviceIntent = Intent(context, DisasterDetectionService::class.java)
context.stopService(serviceIntent)
}
private fun startSOSService() {
val context = getApplication<Application>()
val serviceIntent = Intent(context, SOSService::class.java)
context.startForegroundService(serviceIntent)
}
private fun stopSOSService() {
val context = getApplication<Application>()
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>(FormState.Idle)
val formState: StateFlow<FormState> = _formState.asStateFlow()
// Submission result
private val _submissionResult = MutableStateFlow<SubmissionResult?>(null)
val submissionResult: StateFlow<SubmissionResult?> = _submissionResult.asStateFlow()
// Form data
private val _usageReasons = MutableStateFlow<Set<UsageReason>>(emptySet())
// Form bindings
private val _customUsage = MutableStateFlow("")
val customUsage: StateFlow<String> = _customUsage.asStateFlow()
private val _ideas = MutableStateFlow("")
val ideas: StateFlow<String> = _ideas.asStateFlow()
private val _email = MutableStateFlow("")
val email: StateFlow<String> = _email.asStateFlow()
// Checks if the user selected the "other" usage reason
val hasCustomUsage: StateFlow<Boolean> = 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<UsageReason>,
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<UsageReason> = 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 <T : ViewModel> create(modelClass: Class<T>): 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<Boolean> = SettingsManager.disasterDetectionEnabled
val isDisasterDetectionAvailable: StateFlow<Boolean> = 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 <T : ViewModel> create(modelClass: Class<T>): 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
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z"/>
</vector>
================================================
FILE: android/app/src/main/res/drawable/ic_launcher_background.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
================================================
FILE: android/app/src/main/res/drawable/ic_launcher_foreground.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<group android:scaleX="0.522"
android:scaleY="0.522"
android:translateX="5.736"
android:translateY="5.736">
<path
android:fillColor="@android:color/white"
android:pathData="M20.79,9.23l-2,-3.46l-4.79,2.77l0,-5.54l-4,0l0,5.54l-4.79,-2.77l-2,3.46l4.79,2.77l-4.79,2.77l2,3.46l4.79,-2.77l0,5.54l4,0l0,-5.54l4.79,2.77l2,-3.46l-4.79,-2.77z"/>
</group>
</vector>
================================================
FILE: android/app/src/main/res/drawable/ic_need_help.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M13.5,7h-3c-1.1,0 -2,0.9 -2,2v6c0,1.1 0.9,2 2,2h3c1.1,0 2,-0.9 2,-2V9C15.5,7.9 14.6,7 13.5,7zM13.5,15h-3V9h3V15zM1,15h4v-2H3c-1.1,0 -2,-0.9 -2,-2V9c0,-1.1 0.9,-2 2,-2h4v2H3v2h2c1.1,0 2,0.9 2,2v2c0,1.1 -0.9,2 -2,2H1V15zM17,15h4v-2h-2c-1.1,0 -2,-0.9 -2,-2V9c0,-1.1 0.9,-2 2,-2h4v2h-4v2h2c1.1,0 2,0.9 2,2v2c0,1.1 -0.9,2 -2,2h-4V15z"/>
</vector>
================================================
FILE: android/app/src/main/res/drawable/ic_notification.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M20.79,9.23l-2,-3.46l-4.79,2.77l0,-5.54l-4,0l0,5.54l-4.79,-2.77l-2,3.46l4.79,2.77l-4.79,2.77l2,3.46l4.79,-2.77l0,5.54l4,0l0,-5.54l4.79,2.77l2,-3.46l-4.79,-2.77z"/>
</vector>
================================================
FILE: android/app/src/main/res/drawable/ic_notification_alerting.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M15.73,3L8.27,3L3,8.27v7.46L8.27,21h7.46L21,15.73L21,8.27L15.73,3zM12,17.3c-0.72,0 -1.3,-0.58 -1.3,-1.3 0,-0.72 0.58,-1.3 1.3,-1.3 0.72,0 1.3,0.58 1.3,1.3 0,0.72 -0.58,1.3 -1.3,1.3zM13,13h-2L11,7h2v6z"/>
</vector>
================================================
FILE: android/app/src/main/res/drawable/ic_notification_signaling.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M12,9c-3.15,0 -6,2.41 -6,6.15c0,2.49 2,5.44 6,8.85c4,-3.41 6,-6.36 6,-8.85C18,11.41 15.15,9 12,9zM12,16.5c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5s1.5,0.67 1.5,1.5S12.83,16.5 12,16.5zM12,4c1.93,0 3.68,0.78 4.95,2.05l-1.41,1.41C14.63,6.56 13.38,6 12,6S9.37,6.56 8.46,7.46L7.05,6.05C8.32,4.78 10.07,4 12,4zM19.78,3.23l-1.41,1.41C16.74,3.01 14.49,2 12.01,2S7.27,3.01 5.64,4.63L4.22,3.22C6.22,1.23 8.97,0 12.01,0S17.79,1.23 19.78,3.23z"/>
</vector>
================================================
FILE: android/app/src/main/res/drawable/ic_stop_sos.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12c0,5.52 4.48,10 10,10s10,-4.48 10,-10C22,6.48 17.52,2 12,2zM16,16H8V8h8V16z"/>
</vector>
================================================
FILE: android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
================================================
FILE: android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
================================================
FILE: android/app/src/main/res/values/colors.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
================================================
FILE: android/app/src/main/res/values/ic_launcher_background.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FF3B30</color>
</resources>
================================================
FILE: android/app/src/main/res/values/strings.xml
================================================
<resources>
<string name="app_name">Igatha</string>
</resources>
================================================
FILE: android/app/src/main/res/values/themes.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Igatha" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
================================================
FILE: android/app/src/main/res/xml/backup_rules.xml
================================================
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
================================================
FILE: android/app/src/main/res/xml/data_extraction_rules.xml
================================================
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
================================================
FILE: android/app/src/test/java/com/nizarmah/igatha/ExampleUnitTest.kt
================================================
package com.nizarmah.igatha
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
================================================
FILE: android/build.gradle.kts
================================================
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}
================================================
FILE: android/gradle/libs.versions.toml
================================================
[versions]
agp = "8.9.2"
gson = "2.10.1"
kotlin = "2.0.0"
coreKtx = "1.16.0"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.10.1"
navigationCompose = "2.8.9"
composeBom = "2025.04.01"
workRuntimeKtx = "2.10.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntimeKtx" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
================================================
FILE: android/gradle/wrapper/gradle-wrapper.properties
================================================
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
================================================
FILE: android/gradle.properties
================================================
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
================================================
FILE: android/gradlew
================================================
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote
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
Condensed preview — 128 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (325K chars).
[
{
"path": "LICENSE",
"chars": 1070,
"preview": "MIT License\n\nCopyright (c) 2025 Nizar Mahmoud\n\nPermission is hereby granted, free of charge, to any person obtaining a c"
},
{
"path": "PRIVACY.md",
"chars": 3255,
"preview": "# Privacy Policy\n\n**Last Updated:** November 1, 2024\n\n## Introduction\n\n**Igatha** is committed to protecting your privac"
},
{
"path": "README.md",
"chars": 4135,
"preview": "# Igatha\n\nIgatha is an open-source SOS signaling and recovery app designed for war zones and disaster areas, enabling of"
},
{
"path": "android/.gitignore",
"chars": 225,
"preview": "*.iml\n.gradle\n/local.properties\n/.idea/caches\n/.idea/libraries\n/.idea/modules.xml\n/.idea/workspace.xml\n/.idea/navEditor."
},
{
"path": "android/.gitkeep",
"chars": 0,
"preview": ""
},
{
"path": "android/.idea/.gitignore",
"chars": 47,
"preview": "# Default ignored files\n/shelf/\n/workspace.xml\n"
},
{
"path": "android/.idea/.name",
"chars": 6,
"preview": "Igatha"
},
{
"path": "android/.idea/AndroidProjectSystem.xml",
"chars": 212,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n <component name=\"AndroidProjectSystem\">\n <option name="
},
{
"path": "android/.idea/compiler.xml",
"chars": 169,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n <component name=\"CompilerConfiguration\">\n <bytecodeTar"
},
{
"path": "android/.idea/deploymentTargetSelector.xml",
"chars": 301,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n <component name=\"deploymentTargetSelector\">\n <selectio"
},
{
"path": "android/.idea/deviceManager.xml",
"chars": 351,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n <component name=\"DeviceTable\">\n <option name=\"columnSo"
},
{
"path": "android/.idea/gradle.xml",
"chars": 757,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n <component name=\"GradleMigrationSettings\" migrationVersio"
},
{
"path": "android/.idea/inspectionProfiles/Project_Default.xml",
"chars": 3600,
"preview": "<component name=\"InspectionProjectProfileManager\">\n <profile version=\"1.0\">\n <option name=\"myName\" value=\"Project De"
},
{
"path": "android/.idea/kotlinc.xml",
"chars": 175,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n <component name=\"KotlinJpsPluginSettings\">\n <option na"
},
{
"path": "android/.idea/migrations.xml",
"chars": 254,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n <component name=\"ProjectMigrations\">\n <option name=\"Mi"
},
{
"path": "android/.idea/misc.xml",
"chars": 409,
"preview": "<project version=\"4\">\n <component name=\"ExternalStorageConfigurationManager\" enabled=\"true\" />\n <component name=\"Proje"
},
{
"path": "android/.idea/runConfigurations.xml",
"chars": 964,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n <component name=\"RunConfigurationProducerService\">\n <o"
},
{
"path": "android/.idea/vcs.xml",
"chars": 183,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n <component name=\"VcsDirectoryMappings\">\n <mapping dire"
},
{
"path": "android/app/.gitignore",
"chars": 6,
"preview": "/build"
},
{
"path": "android/app/build.gradle.kts",
"chars": 2278,
"preview": "plugins {\n alias(libs.plugins.android.application)\n alias(libs.plugins.kotlin.android)\n alias(libs.plugins.kotl"
},
{
"path": "android/app/proguard-rules.pro",
"chars": 750,
"preview": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguar"
},
{
"path": "android/app/src/androidTest/java/com/nizarmah/igatha/ExampleInstrumentedTest.kt",
"chars": 665,
"preview": "package com.nizarmah.igatha\n\nimport androidx.test.platform.app.InstrumentationRegistry\nimport androidx.test.ext.junit.ru"
},
{
"path": "android/app/src/main/AndroidManifest.xml",
"chars": 3405,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\"\n xmlns:to"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/Constants.kt",
"chars": 2953,
"preview": "package com.nizarmah.igatha\n\nimport android.os.ParcelUuid\n\nobject Constants {\n val SOS_BEACON_SERVICE_UUID: ParcelUui"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/IgathaApp.kt",
"chars": 2285,
"preview": "package com.nizarmah.igatha\n\nimport android.app.Application\nimport android.content.SharedPreferences\nimport com.nizarmah"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/MainActivity.kt",
"chars": 1767,
"preview": "package com.nizarmah.igatha\n\nimport android.os.Bundle\nimport androidx.activity.ComponentActivity\nimport androidx.activit"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/model/Device.kt",
"chars": 1735,
"preview": "package com.nizarmah.igatha.model\n\nimport android.os.Parcelable\nimport kotlinx.parcelize.Parcelize\nimport java.util.*\nim"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/sensor/AcceleratorSensor.kt",
"chars": 2697,
"preview": "package com.nizarmah.igatha.sensor\n\nimport android.content.Context\nimport android.hardware.Sensor\nimport android.hardwar"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/sensor/BarometerSensor.kt",
"chars": 2771,
"preview": "package com.nizarmah.igatha.sensor\n\nimport android.content.Context\nimport android.hardware.Sensor\nimport android.hardwar"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/sensor/GyroscopeSensor.kt",
"chars": 2825,
"preview": "package com.nizarmah.igatha.sensor\n\nimport android.content.Context\nimport android.hardware.Sensor\nimport android.hardwar"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/sensor/SensorType.kt",
"chars": 606,
"preview": "package com.nizarmah.igatha.sensor\n\nimport android.hardware.Sensor\nimport kotlinx.coroutines.flow.SharedFlow\n\nenum class"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/service/DisasterDetectionService.kt",
"chars": 6103,
"preview": "package com.nizarmah.igatha.service\n\nimport android.app.*\nimport android.content.Intent\nimport android.os.IBinder\nimport"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/service/DisasterDetector.kt",
"chars": 3488,
"preview": "package com.nizarmah.igatha.service\n\nimport android.content.Context\nimport com.nizarmah.igatha.Constants\nimport com.niza"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/service/DisasterEventBus.kt",
"chars": 393,
"preview": "package com.nizarmah.igatha.service\n\nimport kotlinx.coroutines.flow.MutableSharedFlow\nimport kotlinx.coroutines.flow.Sha"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/service/EmergencyManager.kt",
"chars": 3448,
"preview": "package com.nizarmah.igatha.service\n\nimport android.content.Context\nimport kotlinx.coroutines.CoroutineScope\nimport kotl"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/service/FeedbackWorker.kt",
"chars": 3000,
"preview": "package com.nizarmah.igatha.service\n\nimport android.Manifest\nimport android.app.Notification\nimport android.app.Notifica"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/service/ProximityScanner.kt",
"chars": 4907,
"preview": "package com.nizarmah.igatha.service\n\nimport android.bluetooth.BluetoothAdapter\nimport android.bluetooth.BluetoothManager"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/service/SOSBeacon.kt",
"chars": 5385,
"preview": "package com.nizarmah.igatha.service\n\nimport android.Manifest\nimport android.bluetooth.BluetoothAdapter\nimport android.bl"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/service/SOSService.kt",
"chars": 3102,
"preview": "package com.nizarmah.igatha.service\n\nimport android.app.*\nimport android.content.Intent\nimport android.os.IBinder\nimport"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/service/SirenPlayer.kt",
"chars": 4812,
"preview": "package com.nizarmah.igatha.service\n\nimport android.content.Context\nimport android.media.*\nimport android.util.Log\nimpor"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/ui/component/FeedbackButtonView.kt",
"chars": 3789,
"preview": "package com.nizarmah.igatha.ui.component\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundati"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/ui/component/PermissionHandler.kt",
"chars": 4411,
"preview": "package com.nizarmah.igatha.ui.component\n\nimport android.bluetooth.BluetoothAdapter\nimport android.bluetooth.BluetoothMa"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/ui/component/PersistentBanner.kt",
"chars": 1531,
"preview": "package com.nizarmah.igatha.ui.component\n\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/ui/component/Section.kt",
"chars": 2398,
"preview": "package com.nizarmah.igatha.ui.component\n\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose."
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/ui/screen/ContentScreen.kt",
"chars": 4700,
"preview": "package com.nizarmah.igatha.ui.screen\n\nimport android.net.Uri\nimport androidx.compose.animation.AnimatedContentTransitio"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/ui/screen/FeedbackFormScreen.kt",
"chars": 3840,
"preview": "package com.nizarmah.igatha.ui.screen\n\nimport android.app.Application\nimport androidx.compose.material3.AlertDialog\nimpo"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/ui/screen/SettingsScreen.kt",
"chars": 1279,
"preview": "package com.nizarmah.igatha.ui.screen\n\nimport android.app.Application\nimport androidx.compose.runtime.Composable\nimport "
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/ui/theme/Color.kt",
"chars": 1416,
"preview": "package com.nizarmah.igatha.ui.theme\n\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.runtime.Co"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/ui/theme/Theme.kt",
"chars": 1739,
"preview": "package com.nizarmah.igatha.ui.theme\n\nimport android.os.Build\nimport androidx.compose.foundation.isSystemInDarkTheme\nimp"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/ui/theme/Type.kt",
"chars": 988,
"preview": "package com.nizarmah.igatha.ui.theme\n\nimport androidx.compose.material3.Typography\nimport androidx.compose.ui.text.TextS"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/ui/view/ContentView.kt",
"chars": 5435,
"preview": "package com.nizarmah.igatha.ui.view\n\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.layo"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/ui/view/DeviceDetailView.kt",
"chars": 7119,
"preview": "package com.nizarmah.igatha.ui.view\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.la"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/ui/view/DeviceListView.kt",
"chars": 2637,
"preview": "package com.nizarmah.igatha.ui.view\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.la"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/ui/view/DeviceRowView.kt",
"chars": 2902,
"preview": "package com.nizarmah.igatha.ui.view\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.cl"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/ui/view/FeedbackFormView.kt",
"chars": 13870,
"preview": "package com.nizarmah.igatha.ui.view\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.cl"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/ui/view/SettingsView.kt",
"chars": 4223,
"preview": "package com.nizarmah.igatha.ui.view\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.la"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/util/PermissionsHelper.kt",
"chars": 2840,
"preview": "package com.nizarmah.igatha.util\n\nimport android.Manifest\nimport android.os.Build\nimport kotlin.collections.plus\n\nobject"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/util/PermissionsManager.kt",
"chars": 2495,
"preview": "package com.nizarmah.igatha.util\n\nimport android.content.Context\nimport android.content.pm.PackageManager\nimport android"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/util/SettingsManager.kt",
"chars": 1626,
"preview": "package com.nizarmah.igatha.util\n\nimport android.content.Context\nimport android.content.SharedPreferences\nimport com.niz"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/viewmodel/ContentViewModel.kt",
"chars": 4596,
"preview": "package com.nizarmah.igatha.viewmodel\n\nimport android.app.Application\nimport android.content.Intent\nimport androidx.life"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/viewmodel/FeedbackFormViewModel.kt",
"chars": 10290,
"preview": "package com.nizarmah.igatha.viewmodel\n\nimport android.app.Application\nimport androidx.lifecycle.AndroidViewModel\nimport "
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/viewmodel/FeedbackFormViewModelFactory.kt",
"chars": 676,
"preview": "package com.nizarmah.igatha.viewmodel\n\nimport android.app.Application\nimport androidx.lifecycle.ViewModel\nimport android"
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/viewmodel/SettingsViewModel.kt",
"chars": 671,
"preview": "package com.nizarmah.igatha.viewmodel\n\nimport android.app.Application\nimport androidx.lifecycle.AndroidViewModel\nimport "
},
{
"path": "android/app/src/main/java/com/nizarmah/igatha/viewmodel/SettingsViewModelFactory.kt",
"chars": 570,
"preview": "package com.nizarmah.igatha.viewmodel\n\nimport android.app.Application\nimport androidx.lifecycle.ViewModel\nimport android"
},
{
"path": "android/app/src/main/res/drawable/ic_im_okay.xml",
"chars": 406,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\" android:height=\"24dp\" android:tint=\"#000000\" android:"
},
{
"path": "android/app/src/main/res/drawable/ic_launcher_background.xml",
"chars": 5606,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:wi"
},
{
"path": "android/app/src/main/res/drawable/ic_launcher_foreground.xml",
"chars": 609,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"108dp\"\n android:height=\"108dp\"\n"
},
{
"path": "android/app/src/main/res/drawable/ic_need_help.xml",
"chars": 612,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\" android:height=\"24dp\" android:tint=\"#000000\" android:"
},
{
"path": "android/app/src/main/res/drawable/ic_notification.xml",
"chars": 444,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\" android:height=\"24dp\" android:tint=\"#000000\" android:"
},
{
"path": "android/app/src/main/res/drawable/ic_notification_alerting.xml",
"chars": 484,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\" android:height=\"24dp\" android:tint=\"#000000\" android:"
},
{
"path": "android/app/src/main/res/drawable/ic_notification_signaling.xml",
"chars": 722,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\" android:height=\"24dp\" android:tint=\"#000000\" android:"
},
{
"path": "android/app/src/main/res/drawable/ic_stop_sos.xml",
"chars": 383,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\" android:height=\"24dp\" android:tint=\"#000000\" android:"
},
{
"path": "android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml",
"chars": 337,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n <b"
},
{
"path": "android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml",
"chars": 337,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n <b"
},
{
"path": "android/app/src/main/res/values/colors.xml",
"chars": 378,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <color name=\"purple_200\">#FFBB86FC</color>\n <color name=\"purpl"
},
{
"path": "android/app/src/main/res/values/ic_launcher_background.xml",
"chars": 120,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <color name=\"ic_launcher_background\">#FF3B30</color>\n</resources>"
},
{
"path": "android/app/src/main/res/values/strings.xml",
"chars": 68,
"preview": "<resources>\n <string name=\"app_name\">Igatha</string>\n</resources>"
},
{
"path": "android/app/src/main/res/values/themes.xml",
"chars": 148,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n\n <style name=\"Theme.Igatha\" parent=\"android:Theme.Material.Light."
},
{
"path": "android/app/src/main/res/xml/backup_rules.xml",
"chars": 478,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?><!--\n Sample backup rules file; uncomment and customize as necessary.\n See htt"
},
{
"path": "android/app/src/main/res/xml/data_extraction_rules.xml",
"chars": 551,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?><!--\n Sample data extraction rules file; uncomment and customize as necessary.\n "
},
{
"path": "android/app/src/test/java/com/nizarmah/igatha/ExampleUnitTest.kt",
"chars": 343,
"preview": "package com.nizarmah.igatha\n\nimport org.junit.Test\n\nimport org.junit.Assert.*\n\n/**\n * Example local unit test, which wil"
},
{
"path": "android/build.gradle.kts",
"chars": 269,
"preview": "// Top-level build file where you can add configuration options common to all sub-projects/modules.\nplugins {\n alias("
},
{
"path": "android/gradle/libs.versions.toml",
"chars": 2148,
"preview": "[versions]\nagp = \"8.9.2\"\ngson = \"2.10.1\"\nkotlin = \"2.0.0\"\ncoreKtx = \"1.16.0\"\njunit = \"4.13.2\"\njunitVersion = \"1.2.1\"\nesp"
},
{
"path": "android/gradle/wrapper/gradle-wrapper.properties",
"chars": 338,
"preview": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionSha256Sum=20f1b1176237254a6fc204d8434196fa1"
},
{
"path": "android/gradle.properties",
"chars": 1346,
"preview": "# Project-wide Gradle settings.\n# IDE (e.g. Android Studio) users:\n# Gradle settings configured through the IDE *will ov"
},
{
"path": "android/gradlew",
"chars": 8729,
"preview": "#!/bin/sh\n\n#\n# Copyright © 2015-2021 the original authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"Lice"
},
{
"path": "android/gradlew.bat",
"chars": 2966,
"preview": "@rem\r\n@rem Copyright 2015 the original author or authors.\r\n@rem\r\n@rem Licensed under the Apache License, Version 2.0 (th"
},
{
"path": "android/settings.gradle.kts",
"chars": 530,
"preview": "pluginManagement {\n repositories {\n google {\n content {\n includeGroupByRegex(\"com\\\\."
},
{
"path": "fastlane/metadata/android/de/full_description.txt",
"chars": 2360,
"preview": "<p><i>Igatha</i> ist eine Offline-SOS-App für Notfälle, wenn Kommunikationsnetzwerke ausfallen.</p><p><i>Igatha:</i> Ein"
},
{
"path": "fastlane/metadata/android/de/short_description.txt",
"chars": 41,
"preview": "SOS-Signalisierung und -Wiederherstellung"
},
{
"path": "fastlane/metadata/android/en-US/full_description.txt",
"chars": 2060,
"preview": "<p><i>Igatha</i> is an offline SOS app designed for emergencies when communication networks fail.</p><p><i>Igatha:</i> A"
},
{
"path": "fastlane/metadata/android/en-US/short_description.txt",
"chars": 28,
"preview": "SOS Signaling & Recovery"
},
{
"path": "ios/Igatha/AppDelegate.swift",
"chars": 1839,
"preview": "//\n// AppDelegate.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 14/10/2024.\n//\n\nimport SwiftUI\nimport UserNotific"
},
{
"path": "ios/Igatha/Assets.xcassets/AccentColor.colorset/Contents.json",
"chars": 223,
"preview": "{\n \"colors\" : [\n {\n \"color\" : {\n \"platform\" : \"universal\",\n \"reference\" : \"systemRedColor\"\n "
},
{
"path": "ios/Igatha/Assets.xcassets/AppIcon.appiconset/Contents.json",
"chars": 9796,
"preview": "{\n \"images\" : [\n {\n \"filename\" : \"AppIcon-20@2x.png\",\n \"idiom\" : \"universal\",\n \"platform\" : \"ios\",\n "
},
{
"path": "ios/Igatha/Assets.xcassets/Contents.json",
"chars": 63,
"preview": "{\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\n"
},
{
"path": "ios/Igatha/Constants.swift",
"chars": 2518,
"preview": "//\n// Constants.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 06/10/2024.\n//\n\nimport CoreBluetooth\n\nstruct Consta"
},
{
"path": "ios/Igatha/Igatha.entitlements",
"chars": 249,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "ios/Igatha/IgathaApp.swift",
"chars": 297,
"preview": "//\n// IgathaApp.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 05/10/2024.\n//\n\nimport SwiftUI\n\n@main\nstruct Igatha"
},
{
"path": "ios/Igatha/Info.plist",
"chars": 2109,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "ios/Igatha/Models/Device.swift",
"chars": 2051,
"preview": "//\n// Models.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 06/10/2024.\n//\n\nimport Foundation\nimport CoreBluetooth"
},
{
"path": "ios/Igatha/Preview Content/Preview Assets.xcassets/Contents.json",
"chars": 63,
"preview": "{\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\n"
},
{
"path": "ios/Igatha/Sensors/AccelerometerSensor.swift",
"chars": 1774,
"preview": "//\n// AccelerometerSensor.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 12/10/2024.\n//\n\nimport Foundation\nimport "
},
{
"path": "ios/Igatha/Sensors/BarometerSensor.swift",
"chars": 1712,
"preview": "//\n// BarometerSensor.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 12/10/2024.\n//\n\nimport Foundation\nimport Core"
},
{
"path": "ios/Igatha/Sensors/GyroscopeSensor.swift",
"chars": 1723,
"preview": "//\n// GyroscopeSensor.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 12/10/2024.\n//\n\nimport Foundation\nimport Core"
},
{
"path": "ios/Igatha/Sensors/SensorType.swift",
"chars": 593,
"preview": "//\n// SensorType.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 12/10/2024.\n//\n\nimport Foundation\n\nenum SensorType"
},
{
"path": "ios/Igatha/Services/DeepLinkHandler.swift",
"chars": 1330,
"preview": "//\n// DeepLinkHandler.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 22/04/2025.\n//\n\nimport Foundation\nimport Swif"
},
{
"path": "ios/Igatha/Services/DisasterDetector.swift",
"chars": 4366,
"preview": "//\n// DisasterDetector.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 12/10/2024.\n//\n\nimport Foundation\n\nclass Dis"
},
{
"path": "ios/Igatha/Services/EmergencyManager.swift",
"chars": 8479,
"preview": "//\n// EmergencyManager.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 14/10/2024.\n//\n\nimport SwiftUI\nimport UserNo"
},
{
"path": "ios/Igatha/Services/LocationManager.swift",
"chars": 2856,
"preview": "//\n// LocationManager.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 17/10/2024.\n//\n\nimport Foundation\nimport Core"
},
{
"path": "ios/Igatha/Services/NotificationManager.swift",
"chars": 4362,
"preview": "//\n// NotificationManager.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 22/04/2025.\n//\n\nimport Foundation\nimport "
},
{
"path": "ios/Igatha/Services/ProximityScanner.swift",
"chars": 2393,
"preview": "//\n// ProximityScanner.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 06/10/2024.\n//\n\nimport Foundation\nimport Cor"
},
{
"path": "ios/Igatha/Services/SOSBeacon.swift",
"chars": 2644,
"preview": "//\n// SOSBeacon.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 11/10/2024.\n//\n\nimport Foundation\nimport CoreBlueto"
},
{
"path": "ios/Igatha/Services/SirenPlayer.swift",
"chars": 5251,
"preview": "//\n// SirenPlayer.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 12/10/2024.\n//\n\nimport AVFoundation\n\nclass SirenP"
},
{
"path": "ios/Igatha/ViewModels/ContentViewModel.swift",
"chars": 3468,
"preview": "//\n// ViewModel.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 14/10/2024.\n//\n\nimport SwiftUI\n\nclass ContentViewMo"
},
{
"path": "ios/Igatha/ViewModels/FeedbackFormViewModel.swift",
"chars": 9261,
"preview": "//\n// FeedbackFormViewModel.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 20/04/2025.\n//\n\nimport SwiftUI\nimport F"
},
{
"path": "ios/Igatha/ViewModels/SettingsViewModel.swift",
"chars": 650,
"preview": "//\n// SettingsViewModel.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 17/10/2024.\n//\n\nimport SwiftUI\n\nclass Setti"
},
{
"path": "ios/Igatha/Views/ContentView.swift",
"chars": 3623,
"preview": "//\n// ContentView.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 05/10/2024.\n//\n\nimport SwiftUI\n\nstruct ContentVie"
},
{
"path": "ios/Igatha/Views/DeviceDetailView.swift",
"chars": 5638,
"preview": "//\n// DeviceDetailView.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 13/10/2024.\n//\n\nimport SwiftUI\n\nstruct Devic"
},
{
"path": "ios/Igatha/Views/DeviceListView.swift",
"chars": 1812,
"preview": "//\n// DeviceListView.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 13/10/2024.\n//\n\nimport SwiftUI\n\nstruct DeviceL"
},
{
"path": "ios/Igatha/Views/DeviceRowView.swift",
"chars": 1904,
"preview": "//\n// DeviceRowView.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 13/10/2024.\n//\n\nimport SwiftUI\n\nstruct DeviceRo"
},
{
"path": "ios/Igatha/Views/FeedbackButtonView.swift",
"chars": 2029,
"preview": "//\n// FeedbackRowView.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 20/04/2025.\n//\n\nimport SwiftUI\n\nstruct Feedba"
},
{
"path": "ios/Igatha/Views/FeedbackFormView.swift",
"chars": 4558,
"preview": "//\n// FeedbackFormView.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 20/04/2025.\n//\n\nimport SwiftUI\n\n// Feedback "
},
{
"path": "ios/Igatha/Views/SettingsView.swift",
"chars": 1611,
"preview": "//\n// SettingsView.swift\n// Igatha\n//\n// Created by Nizar Mahmoud on 14/10/2024.\n//\n\nimport SwiftUI\n\nstruct SettingsV"
},
{
"path": "ios/Igatha.xcodeproj/project.pbxproj",
"chars": 15015,
"preview": "// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {\n\t};\n\tobjectVersion = 77;\n\tobjects = {\n\n/* Begin PBXBuildFile section *"
},
{
"path": "ios/Igatha.xcodeproj/project.xcworkspace/contents.xcworkspacedata",
"chars": 135,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace\n version = \"1.0\">\n <FileRef\n location = \"self:\">\n </FileRef"
},
{
"path": "ios/Igatha.xcodeproj/xcuserdata/nizarmah.xcuserdatad/xcschemes/xcschememanagement.plist",
"chars": 341,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
}
]
// ... and 1 more files (download for full content)
About this extraction
This page contains the full source code of the nizarmah/igatha GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 128 files (294.1 KB), approximately 70.1k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.