├── .github └── workflows │ └── android.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── net │ │ └── rec0de │ │ └── android │ │ └── watchwitch │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── net │ │ │ └── rec0de │ │ │ └── android │ │ │ └── watchwitch │ │ │ ├── IdsLogger.kt │ │ │ ├── KeyReceiver.kt │ │ │ ├── KeystoreHelper.kt │ │ │ ├── Logger.kt │ │ │ ├── LongTermStorage.kt │ │ │ ├── NotificationListenerService.kt │ │ │ ├── RoutingManager.kt │ │ │ ├── TcpServerService.kt │ │ │ ├── UdpHandler.kt │ │ │ ├── Utils.kt │ │ │ ├── WatchState.kt │ │ │ ├── activities │ │ │ ├── ChatActivity.kt │ │ │ ├── FilesActivity.kt │ │ │ ├── HealthLogActivity.kt │ │ │ ├── MainActivity.kt │ │ │ ├── MapActivity.kt │ │ │ ├── NetworkLogActivity.kt │ │ │ ├── NotificationConsentDialog.kt │ │ │ ├── ProcessFirewallActivity.kt │ │ │ └── WatchStateActivity.kt │ │ │ ├── adapter │ │ │ ├── AlarmsAdapter.kt │ │ │ ├── ChatbubbleAdapter.kt │ │ │ ├── FilesAdapter.kt │ │ │ ├── FirewallProcessAdapter.kt │ │ │ ├── HealthLogAdapter.kt │ │ │ ├── NetworkStatsAdapter.kt │ │ │ └── OpenAppsAdapter.kt │ │ │ ├── alloy │ │ │ ├── AlloyController.kt │ │ │ ├── AlloyHandler.kt │ │ │ ├── AlloyMessage.kt │ │ │ ├── AlloyService.kt │ │ │ ├── ConcreteMessages.kt │ │ │ └── UTunControlMessage.kt │ │ │ ├── bitmage │ │ │ └── bitmage.kt │ │ │ ├── decoders │ │ │ ├── aoverc │ │ │ │ ├── Decryptor.kt │ │ │ │ ├── KeystoreBackedDecryptor.kt │ │ │ │ └── MPKeys.kt │ │ │ ├── bplist │ │ │ │ ├── ActivityStatisticsDecoder.kt │ │ │ │ ├── BPListParser.kt │ │ │ │ ├── KeyedArchiveDecoder.kt │ │ │ │ ├── KeyedArchiveEncoder.kt │ │ │ │ └── bplistObjects.kt │ │ │ ├── compression │ │ │ │ └── GzipDecoder.kt │ │ │ └── protobuf │ │ │ │ └── ProtobufParser.kt │ │ │ ├── ike │ │ │ ├── IKEMessage.kt │ │ │ ├── IKEPayload.kt │ │ │ ├── IKETransport.kt │ │ │ └── IKEv2Session.kt │ │ │ ├── nwsc │ │ │ ├── NWSCManager.kt │ │ │ └── NWSCPacket.kt │ │ │ ├── servicehandlers │ │ │ ├── CoreDuet.kt │ │ │ ├── FindMyLocalDevice.kt │ │ │ ├── GenericResourceTransferReceiver.kt │ │ │ ├── PreferencesSync.kt │ │ │ ├── Screenshotter.kt │ │ │ ├── health │ │ │ │ ├── HealthObjects.kt │ │ │ │ ├── HealthSync.kt │ │ │ │ ├── NanoSyncEntity.kt │ │ │ │ ├── NanoSyncMessage.kt │ │ │ │ ├── Samples.kt │ │ │ │ ├── SyncStatusKey.kt │ │ │ │ └── db │ │ │ │ │ ├── DatabaseSecretProvider.kt │ │ │ │ │ ├── DatabaseWrangler.kt │ │ │ │ │ ├── HealthSyncContract.kt │ │ │ │ │ ├── HealthSyncHelper.kt │ │ │ │ │ ├── HealthSyncSecureContract.kt │ │ │ │ │ └── HealthSyncSecureHelper.kt │ │ │ └── messaging │ │ │ │ ├── Action.kt │ │ │ │ ├── ActionRequests.kt │ │ │ │ ├── Bulletin.kt │ │ │ │ ├── BulletinAttachment.kt │ │ │ │ ├── BulletinDistributorService.kt │ │ │ │ ├── BulletinRequest.kt │ │ │ │ ├── BulletinSummary.kt │ │ │ │ ├── DidPlayLightsAndSirens.kt │ │ │ │ ├── RemoveBulletinRequest.kt │ │ │ │ ├── SectionIcon.kt │ │ │ │ ├── SettingsSyncObjects.kt │ │ │ │ └── Trailer.kt │ │ │ ├── shoes │ │ │ ├── NetworkStats.kt │ │ │ ├── ShoesMessages.kt │ │ │ ├── ShoesProxyHandler.kt │ │ │ └── ShoesService.kt │ │ │ └── watchsim │ │ │ ├── AlloyControlClient.kt │ │ │ ├── AlloyDataClient.kt │ │ │ ├── HealthDataMockup.kt │ │ │ ├── NwscSim.kt │ │ │ ├── ShoesMockup.kt │ │ │ ├── SimulatedWatch.kt │ │ │ └── WatchStateMockup.kt │ └── res │ │ ├── drawable │ │ ├── chat_inputbar.xml │ │ ├── chatbubble_incoming.xml │ │ ├── chatbubble_outgoing.xml │ │ ├── graphic_notification_permission.png │ │ ├── ic_launcher_background.xml │ │ ├── icon_alarm_disabled.xml │ │ ├── icon_alarm_enabled.xml │ │ ├── icon_bell_mute.xml │ │ ├── icon_bell_ringing.xml │ │ ├── icon_bell_unknown.xml │ │ ├── icon_delete.xml │ │ ├── icon_download.xml │ │ ├── icon_health_audio.xml │ │ ├── icon_health_cycletracking.xml │ │ ├── icon_health_energy.xml │ │ ├── icon_health_gps.xml │ │ ├── icon_health_heart.xml │ │ ├── icon_health_unknown.xml │ │ ├── icon_health_walk.xml │ │ ├── icon_health_workout.xml │ │ ├── icon_key.xml │ │ ├── icon_network_download.xml │ │ ├── icon_network_packet.xml │ │ ├── icon_network_upload.xml │ │ ├── icon_paper_plane.xml │ │ ├── icon_refresh.xml │ │ ├── witch_monochrome.xml │ │ └── witch_monochrome_lowdpi.xml │ │ ├── layout │ │ ├── activity_chat.xml │ │ ├── activity_files.xml │ │ ├── activity_health_log.xml │ │ ├── activity_main.xml │ │ ├── activity_map.xml │ │ ├── activity_network_log.xml │ │ ├── activity_process_firewall.xml │ │ ├── activity_watch_state.xml │ │ ├── alarms_item.xml │ │ ├── apps_item.xml │ │ ├── chatbubble.xml │ │ ├── filelist_item.xml │ │ ├── firewall_process_item.xml │ │ ├── healthlog_item.xml │ │ ├── netstats_item.xml │ │ └── notification_consent.xml │ │ ├── mipmap-anydpi-v26 │ │ └── ic_launcher.xml │ │ ├── mipmap-hdpi │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-mdpi │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-xhdpi │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-xxhdpi │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-xxxhdpi │ │ └── ic_launcher_foreground.png │ │ ├── values-night │ │ └── themes.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── strings.xml │ │ ├── styles.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── net │ └── rec0de │ └── android │ └── watchwitch │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── img ├── banner.png ├── screenshots-1.png └── screenshots-2.png └── settings.gradle /.github/workflows/android.yml: -------------------------------------------------------------------------------- 1 | name: Android CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: set up JDK 17 17 | uses: actions/setup-java@v4 18 | with: 19 | java-version: '17' 20 | distribution: 'temurin' 21 | cache: gradle 22 | 23 | - name: Grant execute permission for gradlew 24 | run: chmod +x gradlew 25 | - name: Build with Gradle 26 | run: ./gradlew build 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.21' 5 | } 6 | 7 | android { 8 | namespace 'net.rec0de.android.watchwitch' 9 | compileSdk 33 10 | 11 | defaultConfig { 12 | applicationId "net.rec0de.android.watchwitch" 13 | minSdk 29 14 | targetSdk 33 15 | versionCode 1 16 | versionName "1.0" 17 | 18 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 19 | } 20 | 21 | buildTypes { 22 | release { 23 | minifyEnabled false 24 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 25 | } 26 | } 27 | compileOptions { 28 | sourceCompatibility JavaVersion.VERSION_11 29 | targetCompatibility JavaVersion.VERSION_11 30 | } 31 | kotlinOptions { 32 | jvmTarget = "11" 33 | } 34 | buildFeatures { 35 | viewBinding true 36 | } 37 | } 38 | 39 | dependencies { 40 | implementation 'androidx.appcompat:appcompat:1.4.1' 41 | implementation 'com.google.android.material:material:1.5.0' 42 | implementation 'androidx.constraintlayout:constraintlayout:2.1.3' 43 | implementation 'org.bouncycastle:bcpkix-jdk18on:1.73' 44 | implementation 'org.bouncycastle:bcprov-jdk18on:1.73' 45 | implementation 'androidx.datastore:datastore-preferences:1.0.0' 46 | implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0' 47 | implementation 'androidx.core:core-ktx:1.10.0' 48 | implementation 'androidx.navigation:navigation-fragment-ktx:2.5.2' 49 | implementation 'androidx.navigation:navigation-ui-ktx:2.5.2' 50 | implementation 'androidx.legacy:legacy-support-v4:1.0.0' 51 | implementation 'androidx.recyclerview:recyclerview:1.3.1' 52 | testImplementation 'junit:junit:4.13.2' 53 | androidTestImplementation 'androidx.test.ext:junit:1.1.5' 54 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' 55 | implementation 'com.google.android.flexbox:flexbox:3.0.0' 56 | implementation 'org.osmdroid:osmdroid-android:6.1.14' // gps track map view 57 | implementation("com.patrykandpatrick.vico:core:1.10.0") // ECG visualization 58 | implementation("com.patrykandpatrick.vico:views:1.10.0") // ECG visualization 59 | implementation("net.zetetic:sqlcipher-android:4.5.6@aar") // health database encryption 60 | implementation("androidx.sqlite:sqlite-ktx:2.2.0") 61 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/net/rec0de/android/watchwitch/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package net.rec0de.android.watchwitch 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("net.rec0de.android.watchwitch", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 19 | 22 | 25 | 28 | 31 | 34 | 37 | 42 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 60 | 69 | 75 | 76 | 77 | 78 | 79 | 82 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /app/src/main/java/net/rec0de/android/watchwitch/IdsLogger.kt: -------------------------------------------------------------------------------- 1 | package net.rec0de.android.watchwitch 2 | 3 | import android.content.Context 4 | import net.rec0de.android.watchwitch.bitmage.fromIndex 5 | import net.rec0de.android.watchwitch.bitmage.hex 6 | import java.io.BufferedOutputStream 7 | import java.text.SimpleDateFormat 8 | import java.util.Date 9 | import java.util.Locale 10 | 11 | object IdsLogger { 12 | 13 | private var output: BufferedOutputStream? = null 14 | 15 | fun init(context: Context) { 16 | val time = SimpleDateFormat("yyyy-MM-dd-HHmm", Locale.US).format(Date()) 17 | val name = "session-$time.idslog" 18 | val file = context.openFileOutput(name, Context.MODE_PRIVATE) 19 | output = BufferedOutputStream(file) 20 | } 21 | 22 | fun logAlloy(send: Boolean, bytes: ByteArray) { 23 | val dir = if(send) "snd" else "rcv" 24 | output?.write("$dir utun ${bytes[0].toInt()} ${bytes.fromIndex(5).hex()}\n".toByteArray(Charsets.US_ASCII)) 25 | } 26 | 27 | fun logControl(send: Boolean, bytes: ByteArray) { 28 | val dir = if(send) "snd" else "rcv" 29 | output?.write("$dir utunctrl ${bytes.hex()}\n".toByteArray(Charsets.US_ASCII)) 30 | } 31 | 32 | fun flush() { 33 | output?.flush() 34 | output?.close() 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/main/java/net/rec0de/android/watchwitch/KeyReceiver.kt: -------------------------------------------------------------------------------- 1 | package net.rec0de.android.watchwitch 2 | 3 | import android.util.Base64 4 | import kotlinx.serialization.decodeFromString 5 | import kotlinx.serialization.json.Json 6 | import net.rec0de.android.watchwitch.bitmage.fromIndex 7 | import net.rec0de.android.watchwitch.decoders.aoverc.MPKeys 8 | import net.rec0de.android.watchwitch.servicehandlers.health.HealthSync 9 | import java.net.DatagramPacket 10 | import java.net.DatagramSocket 11 | import java.security.MessageDigest 12 | 13 | 14 | class KeyReceiver : Thread() { 15 | private val serverPort = 0x7777 16 | private val maxDatagramSize = 10000 17 | 18 | var socket: DatagramSocket? = null 19 | 20 | override fun run() { 21 | val lmessage = ByteArray(maxDatagramSize) 22 | val packet = DatagramPacket(lmessage, lmessage.size) 23 | try { 24 | socket = DatagramSocket(serverPort) 25 | Logger.log("listening for keys on :$serverPort", 1) 26 | while (true) { 27 | socket!!.receive(packet) 28 | 29 | Logger.log("received key message", 1) 30 | 31 | if (currentThread().isInterrupted) 32 | break 33 | 34 | val trimmed = packet.data.sliceArray(0 until packet.length) 35 | 36 | val plainKey = LongTermStorage.keyTransitSecret 37 | val md = MessageDigest.getInstance("SHA-256"); 38 | md.update(plainKey) 39 | val keyBytes = md.digest() 40 | 41 | val nonce = trimmed.sliceArray(0 until 12) 42 | val ciphertext = trimmed.fromIndex(12) 43 | 44 | val json = try { 45 | Utils.chachaPolyDecrypt(keyBytes, nonce, byteArrayOf(), ciphertext).decodeToString() 46 | } catch (e: Exception) { 47 | Logger.log("failed to decrypt keys, do you have the right key?", 0) 48 | continue 49 | } 50 | 51 | val map = Json.decodeFromString>(json) 52 | 53 | // store local private keys 54 | // (we cannot properly import them into the keystore since the keymaster does not support ed25519 signatures, but we'll at least seal them to be protected at rest) 55 | if(map.containsKey("al")) 56 | LongTermStorage.setKey(LongTermStorage.PRIVATE_CLASS_A, KeyStoreHelper.seal(Base64.decode(map["al"]!!, Base64.DEFAULT)).toBytes()) 57 | if(map.containsKey("cl")) 58 | LongTermStorage.setKey(LongTermStorage.PRIVATE_CLASS_C, KeyStoreHelper.seal(Base64.decode(map["cl"]!!, Base64.DEFAULT)).toBytes()) 59 | if(map.containsKey("dl")) 60 | LongTermStorage.setKey(LongTermStorage.PRIVATE_CLASS_D, KeyStoreHelper.seal(Base64.decode(map["dl"]!!, Base64.DEFAULT)).toBytes()) 61 | 62 | // store remote public keys 63 | if(map.containsKey("ar")) 64 | LongTermStorage.setKey(LongTermStorage.PUBLIC_CLASS_A, Base64.decode(map["ar"]!!, Base64.DEFAULT)) 65 | if(map.containsKey("cr")) 66 | LongTermStorage.setKey(LongTermStorage.PUBLIC_CLASS_C, Base64.decode(map["cr"]!!, Base64.DEFAULT)) 67 | if(map.containsKey("dr")) 68 | LongTermStorage.setKey(LongTermStorage.PUBLIC_CLASS_D, Base64.decode(map["dr"]!!, Base64.DEFAULT)) 69 | 70 | // store inner IPv6 addresses 71 | if(map.containsKey("lac")) 72 | LongTermStorage.setAddress(LongTermStorage.LOCAL_ADDRESS_CLASS_C, map["lac"]!!) 73 | if(map.containsKey("lad")) 74 | LongTermStorage.setAddress(LongTermStorage.LOCAL_ADDRESS_CLASS_D, map["lad"]!!) 75 | if(map.containsKey("rac")) 76 | LongTermStorage.setAddress(LongTermStorage.REMOTE_ADDRESS_CLASS_C, map["rac"]!!) 77 | if(map.containsKey("rad")) 78 | LongTermStorage.setAddress(LongTermStorage.REMOTE_ADDRESS_CLASS_D, map["rad"]!!) 79 | 80 | // store IDS keys and UUID 81 | if(map.containsKey("idsLocalUUID")) 82 | LongTermStorage.setUTUNDeviceID(map["idsLocalUUID"]!!) 83 | if(map.containsKey("idsLocalClassARsa") && map.containsKey("idsLocalClassAEcdsa") && map.containsKey("idsRemoteClassA")) { 84 | val privateEcdsaBytes = Base64.decode(map["idsLocalClassAEcdsa"]!!, Base64.DEFAULT) 85 | val privateRsaBytes = Base64.decode(map["idsLocalClassARsa"]!!, Base64.DEFAULT) 86 | val publicDerBytes = Base64.decode(map["idsRemoteClassA"]!!, Base64.DEFAULT) 87 | 88 | val keys = MPKeys.fromSentKeys(publicDerBytes, privateEcdsaBytes, privateRsaBytes) 89 | LongTermStorage.storeMPKeysForClass("A", keys) 90 | KeyStoreHelper.enrollAovercEcdsaPrivateKey(keys.friendlyEcdsaPrivateKey()) 91 | KeyStoreHelper.enrollAovercRsaPrivateKey(keys.friendlyRsaPrivateKey()) 92 | HealthSync.reloadKeys() 93 | } 94 | 95 | RoutingManager.registerAddresses() 96 | Logger.log("got keys!", 0) 97 | //Logger.log("remote public (C/D):", 1) 98 | //Logger.log(Base64.decode(map["cr"]!!, Base64.DEFAULT).hex(), 1) 99 | //Logger.log(Base64.decode(map["dr"]!!, Base64.DEFAULT).hex(), 1) 100 | } 101 | } 102 | catch (e: Throwable) { 103 | e.printStackTrace() 104 | } 105 | finally { 106 | socket?.close() 107 | Logger.log("KeyReceiver exited", 0) 108 | } 109 | } 110 | } -------------------------------------------------------------------------------- /app/src/main/java/net/rec0de/android/watchwitch/Logger.kt: -------------------------------------------------------------------------------- 1 | package net.rec0de.android.watchwitch 2 | 3 | import android.annotation.SuppressLint 4 | import android.util.Log 5 | import net.rec0de.android.watchwitch.activities.MainActivity 6 | 7 | object Logger { 8 | 9 | private const val GEN_SCREEN_LVL = 0 10 | private const val GEN_LOG_LVL = 3 11 | 12 | private const val IKE_SCREEN_LVL = 0 13 | private const val IKE_LOG_LVL = 5 14 | 15 | private const val IDS_SCREEN_LVL = 0 16 | private const val IDS_LOG_LVL = 2 17 | 18 | private const val UTUN_SCREEN_LVL = 0 19 | private const val UTUN_LOG_LVL = 3 20 | 21 | private const val SHOES_SCREEN_LVL = 5 22 | private const val SHOES_LOG_LVL = 5 23 | 24 | private const val CMD_SCREEN_LVL = -1 25 | private const val CMD_LOG_LVL = 5 26 | 27 | private const val SQL_SCREEN_LVL = 0 28 | private const val SQL_LOG_LVL = 0 29 | 30 | @SuppressLint("StaticFieldLeak") 31 | private var activity: MainActivity? = null 32 | private var cachedError: String? = null 33 | 34 | fun setMainActivity(activity: MainActivity) { 35 | this.activity = activity 36 | if(cachedError != null) 37 | activity.runOnUiThread { activity.setError(cachedError!!) } 38 | } 39 | 40 | private fun logToScreen(msg: String) { 41 | if(activity == null) 42 | return 43 | activity!!.runOnUiThread { activity!!.logData(msg) } 44 | } 45 | 46 | fun setError(msg: String) { 47 | cachedError = msg 48 | if(activity == null) 49 | return 50 | activity!!.runOnUiThread { activity!!.setError(msg) } 51 | } 52 | 53 | fun log(msg: String, level: Int) { 54 | if(level <= GEN_SCREEN_LVL) 55 | logToScreen(msg) 56 | if(level <= GEN_LOG_LVL) 57 | Log.d("WatchWitch", msg) 58 | } 59 | 60 | fun logIDS(msg: String, level: Int) { 61 | if(level <= IDS_SCREEN_LVL) 62 | logToScreen(msg) 63 | if(level <= IDS_LOG_LVL) 64 | Log.d("WatchWitchIDS", msg) 65 | } 66 | 67 | fun logUTUN(msg: String, level: Int) { 68 | if(level <= UTUN_SCREEN_LVL) 69 | logToScreen(msg) 70 | if(level <= UTUN_LOG_LVL) 71 | Log.d("WatchWitchAlloy", msg) 72 | } 73 | 74 | fun logShoes(msg: String, level: Int) { 75 | if(level <= SHOES_SCREEN_LVL) 76 | logToScreen(msg) 77 | if(level <= SHOES_LOG_LVL) 78 | Log.d("WatchWitchShoes", msg) 79 | } 80 | 81 | fun logIKE(msg: String, level: Int) { 82 | if(level <= IKE_SCREEN_LVL) 83 | logToScreen(msg) 84 | if(level <= IKE_LOG_LVL) 85 | Log.d("WatchWitchIKE", msg) 86 | } 87 | 88 | fun logSQL(msg: String, level: Int) { 89 | if(level <= SQL_SCREEN_LVL) 90 | logToScreen(msg) 91 | if(level <= SQL_LOG_LVL) 92 | Log.d("WatchWitchSQL", msg) 93 | } 94 | 95 | fun logCmd(msg: String, level: Int) { 96 | if(level <= CMD_SCREEN_LVL) 97 | logToScreen(msg) 98 | if(level <= CMD_LOG_LVL) 99 | Log.d("WatchWitchCmd", msg) 100 | } 101 | } -------------------------------------------------------------------------------- /app/src/main/java/net/rec0de/android/watchwitch/NotificationListenerService.kt: -------------------------------------------------------------------------------- 1 | package net.rec0de.android.watchwitch 2 | 3 | import android.app.Notification 4 | import android.service.notification.NotificationListenerService 5 | import android.service.notification.StatusBarNotification 6 | import net.rec0de.android.watchwitch.servicehandlers.messaging.BulletinDistributorService 7 | import net.rec0de.android.watchwitch.servicehandlers.messaging.NotificationWaitingForReply 8 | import java.util.UUID 9 | 10 | 11 | class WatchNotificationForwarder : NotificationListenerService() { 12 | 13 | private val receivedTimestamps = mutableSetOf() 14 | private val receivedNotifications = mutableSetOf() 15 | 16 | override fun onNotificationPosted(sbn: StatusBarNotification) { 17 | Logger.log("Notification received: $sbn", 2) 18 | 19 | when(sbn.packageName) { 20 | "org.thoughtcrime.securesms" -> handleSignal(sbn.notification) 21 | "com.whatsapp" -> handleWhatsapp(sbn.notification) 22 | } 23 | 24 | } 25 | 26 | override fun onNotificationRemoved(sbn: StatusBarNotification) { 27 | //Logger.log("Notification removed: $sbn", 0) 28 | } 29 | 30 | private fun handleSignal(msg: Notification) { 31 | // filter out notifications we see when signal is checking for messages 32 | if(msg.category != "msg") 33 | return 34 | 35 | val sender = msg.extras.getString(Notification.EXTRA_TITLE) 36 | val message = msg.extras.getCharSequence(Notification.EXTRA_TEXT).toString() 37 | 38 | // when multiple messages are present, we get some annoying extra messages titled "Signal" 39 | if(sender == "Signal") 40 | return 41 | 42 | if (System.currentTimeMillis() - msg.`when` > 3000 || receivedTimestamps.contains(msg.`when`) || receivedNotifications.contains("$sender|$message")) { 43 | // discard re-posted old notifications (>3s) or notifications with timestamps we already received 44 | return; 45 | } 46 | else { 47 | receivedTimestamps.add(msg.`when`) 48 | receivedNotifications.add("$sender|$message") 49 | // keep only one minute worth of notification timestamps (or up to 20) 50 | if(receivedTimestamps.size > 20) { 51 | receivedTimestamps.removeIf { it < msg.`when` - 60000 } 52 | } 53 | if(receivedNotifications.size > 20) { 54 | receivedNotifications.clear() // imperfect but should be good enough 55 | } 56 | } 57 | 58 | Logger.log("Signal: $sender - $message", 0) 59 | 60 | val bulletinUUID = UUID.randomUUID() 61 | BulletinDistributorService.replyable.add(NotificationWaitingForReply(msg, bulletinUUID)) 62 | 63 | forwardAsSpoofedIMessage(sender?: "unknown", message) 64 | //forwardAsSignalMessage(sender?: "unknown", message, bulletinUUID) 65 | } 66 | 67 | private fun handleWhatsapp(msg: Notification) { 68 | val sender = msg.extras.getString(Notification.EXTRA_TITLE) 69 | val message = msg.extras.getCharSequence(Notification.EXTRA_TEXT).toString() 70 | 71 | Logger.log("WhatsApp: $sender - $message", 0) 72 | forwardAsSpoofedIMessage(sender?: "unknown", message) 73 | } 74 | 75 | private fun forwardAsSpoofedIMessage(title: String, body: String) { 76 | val thread = Thread { 77 | val success = BulletinDistributorService.sendBulletin(title, body) 78 | Logger.log("Forwarding message \"$title: $body\", success: $success", 0) 79 | } 80 | thread.start() 81 | } 82 | 83 | private fun forwardAsSignalMessage(title: String, body: String, uuid: UUID) { 84 | val thread = Thread { 85 | val success = BulletinDistributorService.sendSignalReplyable(title, body, uuid) 86 | Logger.log("Forwarding message \"$title: $body\" as Signal message with UUID $uuid, success: $success", 0) 87 | } 88 | thread.start() 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /app/src/main/java/net/rec0de/android/watchwitch/UdpHandler.kt: -------------------------------------------------------------------------------- 1 | package net.rec0de.android.watchwitch 2 | 3 | import android.util.Log 4 | import net.rec0de.android.watchwitch.activities.MainActivity 5 | import net.rec0de.android.watchwitch.bitmage.ByteOrder 6 | import net.rec0de.android.watchwitch.bitmage.fromBytes 7 | import net.rec0de.android.watchwitch.bitmage.hex 8 | import net.rec0de.android.watchwitch.ike.DeletePayload 9 | import net.rec0de.android.watchwitch.ike.IKEMessage 10 | import net.rec0de.android.watchwitch.ike.IKETransport 11 | import net.rec0de.android.watchwitch.ike.IKEv2Session 12 | import net.rec0de.android.watchwitch.ike.NrlpOverUdpTransport 13 | import net.rec0de.android.watchwitch.ike.UDPTransport 14 | import java.net.BindException 15 | import java.net.DatagramPacket 16 | import java.net.DatagramSocket 17 | import java.net.InetAddress 18 | 19 | object IKEDispatcher { 20 | private val ikeSessions = mutableMapOf() 21 | 22 | fun dispatch(payload: ByteArray, transport: IKETransport, main: MainActivity) { 23 | val initiatorSPI = payload.sliceArray(0 until 8) 24 | val responderSPI = payload.sliceArray(8 until 16) 25 | val hexspi = initiatorSPI.hex() 26 | 27 | // existing session 28 | if(ikeSessions.containsKey(hexspi)) 29 | ikeSessions[hexspi]!!.ingestPacket(payload) 30 | // fresh session 31 | else if(Long.fromBytes(responderSPI, ByteOrder.BIG) == 0L) { 32 | main.hideWatchSimButton() 33 | val session = IKEv2Session(transport, initiatorSPI) 34 | session.ingestPacket(payload) 35 | ikeSessions[hexspi] = session 36 | } 37 | // existing session that we have no memory of 38 | else { 39 | Logger.logIKE("Got orphaned IKE, sending delete", 0) // this does not seem to actually do anything 40 | // informational exchange 41 | val msg = IKEMessage(37, 0, initiatorSPI, responderSPI, false) 42 | msg.addPayload(DeletePayload()) 43 | val delete = msg.assemble() 44 | transport.send(delete) 45 | } 46 | } 47 | } 48 | 49 | class UDPHandler(private val main: MainActivity, private val serverPort: Int) : Thread() { 50 | private val maxDatagramSize = 10000 51 | var socket: DatagramSocket? = null 52 | override fun run() { 53 | val lmessage = ByteArray(maxDatagramSize) 54 | val packet = DatagramPacket(lmessage, lmessage.size) 55 | try { 56 | socket = DatagramSocket(serverPort) 57 | main.runOnUiThread { main.statusListening(serverPort) } 58 | while (true) { 59 | socket!!.receive(packet) 60 | val payload = packet.data.sliceArray(0 until packet.length) 61 | IKEDispatcher.dispatch(payload, UDPTransport(socket!!, packet.address, packet.port), main) 62 | } 63 | } catch (e: Throwable) { 64 | if(e is BindException) { 65 | Logger.setError("socket already in use") 66 | } 67 | e.printStackTrace() 68 | } 69 | finally { 70 | socket?.close() 71 | main.runOnUiThread { main.statusIdle() } 72 | } 73 | } 74 | 75 | fun kill() { 76 | socket?.close() 77 | } 78 | } 79 | 80 | class NRLPoverUDPhandler(private val main: MainActivity, private val serverPort: Int) : Thread() { 81 | private val maxDatagramSize = 10000 82 | 83 | override fun run() { 84 | val socket = DatagramSocket() 85 | val sendData = "nrlp-hello".toByteArray() 86 | val sendPacket = DatagramPacket(sendData, sendData.size, InetAddress.getByName("10.0.2.2"), 0x5757) 87 | socket.send(sendPacket) 88 | Log.d("NRLPoverUDP", "sent nrlp hello") 89 | 90 | val lmessage = ByteArray(maxDatagramSize) 91 | val packet = DatagramPacket(lmessage, lmessage.size) 92 | val transport = NrlpOverUdpTransport(socket, sendPacket.address, sendPacket.port) 93 | 94 | while (true) { 95 | socket.receive(packet) 96 | val data = packet.data.sliceArray(0 until packet.length) 97 | IKEDispatcher.dispatch(data, transport, main) 98 | Log.d("NRLPoverUDP", packet.data.hex()) 99 | } 100 | 101 | } 102 | } -------------------------------------------------------------------------------- /app/src/main/java/net/rec0de/android/watchwitch/WatchState.kt: -------------------------------------------------------------------------------- 1 | package net.rec0de.android.watchwitch 2 | 3 | import androidx.lifecycle.MutableLiveData 4 | import java.util.concurrent.atomic.AtomicBoolean 5 | import kotlin.concurrent.Volatile 6 | 7 | /* 8 | * A place to gather information about the current state of the connected watch, mostly sourced from NanoPreferencesSync 9 | */ 10 | object WatchState { 11 | 12 | @Volatile 13 | var networkPlumbingDone = AtomicBoolean(false) 14 | @Volatile 15 | var alloyConnected = AtomicBoolean(false) 16 | 17 | fun resetConnectionState() { 18 | alloyConnected.set(false) 19 | networkPlumbingDone.set(false) 20 | } 21 | 22 | val alarms: MutableLiveData> by lazy { 23 | MutableLiveData>(mapOf()) 24 | } 25 | 26 | val openApps: MutableLiveData> by lazy { 27 | MutableLiveData>() 28 | } 29 | 30 | val ringerMuted: MutableLiveData by lazy { 31 | MutableLiveData(TriState.UNKNOWN) 32 | } 33 | 34 | fun setAlarm(id: String, alarm: Alarm) { 35 | val map = alarms.value!!.toMutableMap() 36 | map[id] = alarm 37 | alarms.postValue(map) 38 | } 39 | 40 | enum class TriState { TRUE, FALSE, UNKNOWN} 41 | 42 | data class Alarm(val hour: Int, val minute: Int, val enabled: Boolean, val title: String?) 43 | } -------------------------------------------------------------------------------- /app/src/main/java/net/rec0de/android/watchwitch/activities/FilesActivity.kt: -------------------------------------------------------------------------------- 1 | package net.rec0de.android.watchwitch.activities 2 | 3 | import android.graphics.Bitmap 4 | import android.graphics.BitmapFactory 5 | import android.net.Uri 6 | import android.os.Bundle 7 | import android.view.View.GONE 8 | import android.view.View.VISIBLE 9 | import android.widget.TextView 10 | import android.widget.Toast 11 | import androidx.activity.result.contract.ActivityResultContracts 12 | import androidx.appcompat.app.AppCompatActivity 13 | import androidx.recyclerview.widget.RecyclerView 14 | import net.rec0de.android.watchwitch.R 15 | import net.rec0de.android.watchwitch.adapter.FilesAdapter 16 | import java.io.FileInputStream 17 | import java.io.FileOutputStream 18 | import java.util.Date 19 | 20 | class FilesActivity : AppCompatActivity() { 21 | private val imgExtensions = setOf("png", "jpg", "JPG", "bmp") 22 | 23 | var sourceFilePath = "" 24 | val savePrompt = registerForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri: Uri? -> 25 | if(uri != null) { 26 | val thread = Thread { 27 | try { 28 | val sourceFile = FileInputStream(sourceFilePath) 29 | applicationContext.contentResolver.openFileDescriptor(uri, "w")?.use { 30 | FileOutputStream(it.fileDescriptor).use { stream -> 31 | sourceFile.copyTo(stream) 32 | } 33 | } 34 | sourceFile.close() 35 | } catch (e: Exception) { 36 | runOnUiThread { 37 | val duration = Toast.LENGTH_LONG 38 | val toast = Toast.makeText(this, R.string.files_store_failed, duration) 39 | toast.show() 40 | e.printStackTrace() 41 | } 42 | } 43 | } 44 | thread.start() 45 | } 46 | } 47 | 48 | override fun onCreate(savedInstanceState: Bundle?) { 49 | super.onCreate(savedInstanceState) 50 | setContentView(R.layout.activity_files) 51 | 52 | val msgList = findViewById(R.id.listFiles) 53 | val emptyLabel = findViewById(R.id.labelFilesEmpty) 54 | 55 | val storedFiles = filesDir.listFiles()!!.toList().filter { 56 | if(it.name == "osmdroid" || it.name.startsWith("ww-internal")) 57 | false 58 | // delete empty session logs before display 59 | else if(it.length() == 0L && it.name.startsWith("session-")) { 60 | it.delete() 61 | false 62 | } 63 | else 64 | true 65 | } 66 | 67 | val items = storedFiles.map {file -> 68 | val bitmap = if(file.extension in imgExtensions) BitmapFactory.decodeFile(file.absolutePath) else null 69 | FileItem(file.name, Date(file.lastModified()), bitmap, file.absolutePath, file.length()) 70 | }.sortedByDescending { it.timestamp } 71 | 72 | if(storedFiles.isEmpty()) { 73 | emptyLabel.visibility = VISIBLE 74 | msgList.visibility = GONE 75 | } 76 | else { 77 | emptyLabel.visibility = GONE 78 | msgList.visibility = VISIBLE 79 | } 80 | 81 | println(storedFiles) 82 | 83 | msgList.adapter = FilesAdapter(this, items.toMutableList()) 84 | msgList.setHasFixedSize(false) 85 | } 86 | 87 | class FileItem(val filename: String, val timestamp: Date, val bitmap: Bitmap?, val fullPath: String, val size: Long) 88 | } -------------------------------------------------------------------------------- /app/src/main/java/net/rec0de/android/watchwitch/activities/HealthLogActivity.kt: -------------------------------------------------------------------------------- 1 | package net.rec0de.android.watchwitch.activities 2 | 3 | import android.os.Bundle 4 | import android.widget.Button 5 | import androidx.appcompat.app.AppCompatActivity 6 | import androidx.recyclerview.widget.RecyclerView 7 | import net.rec0de.android.watchwitch.R 8 | import net.rec0de.android.watchwitch.adapter.HealthLogAdapter 9 | import net.rec0de.android.watchwitch.servicehandlers.health.HealthSync 10 | import net.rec0de.android.watchwitch.servicehandlers.health.db.DatabaseWrangler 11 | 12 | 13 | class HealthLogActivity : AppCompatActivity() { 14 | 15 | private val ignoreBoring = true 16 | private val boringTypes = listOf("BasalEnergyBurned", "ActiveEnergyBurned", "WristEvent") 17 | 18 | override fun onCreate(savedInstanceState: Bundle?) { 19 | super.onCreate(savedInstanceState) 20 | setContentView(R.layout.activity_health_log) 21 | 22 | val categoryData = DatabaseWrangler.getCategorySamples() 23 | val quantityData = DatabaseWrangler.getQuantitySamples() 24 | val locationSeries = DatabaseWrangler.getLocationSeries() 25 | val workouts = DatabaseWrangler.getWorkouts() 26 | val ecg = DatabaseWrangler.getEcgSamples() 27 | 28 | var data = (categoryData + quantityData + workouts + ecg + locationSeries).sortedBy { it.startDate } 29 | 30 | if(ignoreBoring) 31 | data = data.filterNot { it is DatabaseWrangler.DisplaySample && it.dataType in boringTypes } 32 | 33 | val recyclerView = findViewById(R.id.hostList) 34 | recyclerView.adapter = HealthLogAdapter(data) 35 | recyclerView.setHasFixedSize(false) 36 | 37 | val resetButton = findViewById