├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── assets │ └── license.txt │ ├── kotlin │ └── app │ │ └── trigger │ │ ├── AboutActivity.kt │ │ ├── AbstractCertificateActivity.kt │ │ ├── AbstractClientKeyPairActivity.kt │ │ ├── BackupActivity.kt │ │ ├── BluetoothDoor.kt │ │ ├── BluetoothTools.kt │ │ ├── Door.kt │ │ ├── DoorReply.kt │ │ ├── DoorStatus.kt │ │ ├── HttpsDoor.kt │ │ ├── ImageActivity.kt │ │ ├── LicenseActivity.kt │ │ ├── Log.kt │ │ ├── MainActivity.kt │ │ ├── MqttDoor.kt │ │ ├── NukiDoor.kt │ │ ├── OnTaskCompleted.kt │ │ ├── QRScanActivity.kt │ │ ├── QRShowActivity.kt │ │ ├── Settings.kt │ │ ├── SetupActivity.kt │ │ ├── SshDoor.kt │ │ ├── Utils.kt │ │ ├── WifiTools.kt │ │ ├── bluetooth │ │ └── BluetoothRequestHandler.kt │ │ ├── https │ │ ├── CertificateFetchTask.kt │ │ ├── HttpsClientCertificateActivity.kt │ │ ├── HttpsClientKeyPairActivity.kt │ │ ├── HttpsRequestHandler.kt │ │ ├── HttpsServerCertificateActivity.kt │ │ ├── HttpsTools.kt │ │ └── IgnoreExpirationTrustManager.kt │ │ ├── mqtt │ │ ├── MqttClientCertificateActivity.kt │ │ ├── MqttClientKeyPairActivity.kt │ │ ├── MqttRequestHandler.kt │ │ └── MqttServerCertificateActivity.kt │ │ ├── nuki │ │ ├── NukiCallback.kt │ │ ├── NukiCommand.kt │ │ ├── NukiLockActionCallback.kt │ │ ├── NukiPairingCallback.kt │ │ ├── NukiReadLockStateCallback.kt │ │ ├── NukiRequestHandler.kt │ │ └── NukiTools.kt │ │ └── ssh │ │ ├── EcCore.kt │ │ ├── Encryptor.kt │ │ ├── GenerateIdentityTask.kt │ │ ├── KeyPairBean.kt │ │ ├── PubkeyUtils.kt │ │ ├── RegisterIdentityTask.kt │ │ ├── SshKeyPairActivity.kt │ │ ├── SshRequestHandler.kt │ │ └── SshTools.kt │ └── res │ ├── anim │ └── pressed.xml │ ├── drawable-hdpi │ ├── ic_action_about.png │ ├── ic_action_edit.png │ └── ic_action_new.png │ ├── drawable-mdpi │ ├── ic_action_about.png │ ├── ic_action_edit.png │ └── ic_action_new.png │ ├── drawable-xhdpi │ ├── ic_action_about.png │ ├── ic_action_edit.png │ ├── ic_action_new.png │ ├── ic_action_refresh.png │ └── ic_launcher.png │ ├── drawable-xxhdpi │ ├── ic_action_about.png │ ├── ic_action_edit.png │ ├── ic_action_new.png │ ├── ic_action_refresh.png │ └── ic_launcher.png │ ├── drawable │ ├── ic_action_scan_qr.xml │ ├── ic_action_show_qr.xml │ ├── ic_launcher_foreground.xml │ ├── ic_lock.xml │ ├── ic_ring.xml │ ├── ic_unlock.xml │ ├── rounded_corners.xml │ ├── state_closed.png │ ├── state_disabled.png │ ├── state_open.png │ ├── state_unknown.png │ └── trigger_logo.xml │ ├── layout │ ├── activity_about.xml │ ├── activity_abstract_certificate.xml │ ├── activity_abstract_client_keypair.xml │ ├── activity_backup.xml │ ├── activity_image.xml │ ├── activity_license.xml │ ├── activity_main.xml │ ├── activity_qrscan.xml │ ├── activity_qrshow.xml │ ├── activity_setup_bluetooth.xml │ ├── activity_setup_https.xml │ ├── activity_setup_mqtt.xml │ ├── activity_setup_nuki.xml │ ├── activity_setup_ssh.xml │ ├── activity_ssh_keypair.xml │ ├── dialog_change_string.xml │ ├── dialog_delete_door.xml │ ├── dialog_ssh_passphrase.xml │ ├── main_spinner.xml │ ├── spinner_dropdown_item_settings.xml │ └── spinner_item_settings.xml │ ├── menu │ └── main.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ └── ic_launcher.png │ ├── mipmap-mdpi │ └── ic_launcher.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── values-de │ ├── array.xml │ └── strings.xml │ ├── values-in │ └── strings.xml │ ├── values-ru │ └── strings.xml │ ├── values-w820dp │ └── dimens.xml │ └── values │ ├── array.xml │ ├── dimens.xml │ ├── ic_launcher_background.xml │ ├── strings.xml │ └── styles.xml ├── build.gradle ├── default_images.svg ├── docs ├── apk.png ├── documentation.md ├── fdroid.png ├── gplay.png ├── screenshot_bluetooth_settings_part1.png ├── screenshot_door_types.png ├── screenshot_https_manage_tls_certificate.png ├── screenshot_https_settings_part1.png ├── screenshot_https_settings_part2.png ├── screenshot_main_menu.png ├── screenshot_mqtt_settings_part1.png ├── screenshot_nuki_settings_part1.png ├── screenshot_ssh_key_pair.png ├── screenshot_ssh_settings_part1.png ├── screenshot_ssh_settings_part2.png ├── screenshot_states.png └── screenshots.md ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── metadata ├── en-US │ ├── changelogs │ │ ├── 170.txt │ │ ├── 171.txt │ │ ├── 180.txt │ │ ├── 190.txt │ │ ├── 191.txt │ │ ├── 192.txt │ │ ├── 200.txt │ │ ├── 201.txt │ │ ├── 202.txt │ │ ├── 203.txt │ │ ├── 204.txt │ │ ├── 205.txt │ │ ├── 206.txt │ │ ├── 210.txt │ │ ├── 211.txt │ │ ├── 220.txt │ │ ├── 221.txt │ │ ├── 222.txt │ │ ├── 223.txt │ │ ├── 224.txt │ │ ├── 225.txt │ │ ├── 300.txt │ │ ├── 301.txt │ │ ├── 310.txt │ │ ├── 311.txt │ │ ├── 312.txt │ │ ├── 313.txt │ │ ├── 320.txt │ │ ├── 321.txt │ │ ├── 322.txt │ │ ├── 330.txt │ │ ├── 331.txt │ │ ├── 332.txt │ │ ├── 333.txt │ │ ├── 334.txt │ │ ├── 335.txt │ │ ├── 336.txt │ │ ├── 340.txt │ │ ├── 341.txt │ │ ├── 342.txt │ │ ├── 343.txt │ │ ├── 344.txt │ │ ├── 400.txt │ │ ├── 401.txt │ │ ├── 402.txt │ │ ├── 403.txt │ │ ├── 404.txt │ │ ├── 405.txt │ │ ├── 406.txt │ │ └── 407.txt │ ├── full_description.txt │ ├── images │ │ ├── icon.png │ │ └── phoneScreenshots │ │ │ ├── 01_setup.png │ │ │ ├── 02_settings_https_part1.png │ │ │ └── 03_settings_https_part2.png │ └── short_description.txt └── ru-RU │ ├── full_description.txt │ └── short_description.txt └── settings.gradle /.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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Trigger 2 | ======= 3 | 4 | Trigger is an Android App to unlock/lock doors, show the door status and ring a bell. 5 | 6 | Features: 7 | - Support of HTTPS, SSH, Bluetooth/BLE and MQTT. 8 | - Support for Nuki SmartLock. 9 | - Multiple door profiles. 10 | - Auto select profiles by connected WiFi. 11 | - HTTPS server/client certificate support. 12 | - SSH key generation support (ED25519, RSA). 13 | - Custom status images. 14 | - QR code support. 15 | - Support of backup. 16 | 17 | ![image](docs/screenshot_states.png) 18 | 19 | (more [Screenshots](docs/screenshots.md)) 20 | 21 | 22 | ## Download 23 | 24 | [Get it on F-Droid](https://f-droid.org/packages/com.example.trigger/) 25 | [Get it on GitHub](https://github.com/mwarning/trigger/releases) 26 | 27 | The minimum supported Android version is 5.0. 28 | 29 | ## Documentation 30 | 31 | For further feature explanations and How-Tos, see the [Documentation](docs/documentation.md) page. 32 | 33 | ## Contribution 34 | 35 | Any help, bugfixes, new features, translations are much appreciated. 36 | 37 | For translations use https://toolate.othing.xyz/projects/trigger/ 38 | 39 | ## Similar/Related Projects 40 | 41 | * [Sphincter-Remote](https://github.com/openlab-aux/Sphincter-Remote) / [Sphincter](https://github.com/openlab-aux/sphincter) / [Sphincterd](https://github.com/openlab-aux/sphincterd) 42 | * [D00r-app](https://github.com/h42i/d00r-app) / [D00r-key-server](https://github.com/h42i/d00r-key-server) 43 | * [labadoor](https://github.com/ToLABaki/labadoor) / [DoorLock](https://wiki.tolabaki.gr/w/DoorLock_v3) 44 | * [Krautschlüssel](https://gitlab.com/fiveop/krautschluessel) 45 | * [MetalabDoorWidget](https://github.com/zoff99/MetalabDoorWidget) 46 | * [HACKS](https://github.com/ktt-ol/hacs) 47 | * [Stratum0Widget](https://github.com/Valodim/Stratum0Widget) 48 | 49 | ## License 50 | 51 | This work is licenced under the GNU General Public License version 2 or later (GPLv2). 52 | 53 | Icons: [Googles Material Design](https://material.io/tools/icons/) 54 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'org.jetbrains.kotlin.android' 4 | } 5 | 6 | android { 7 | defaultConfig { 8 | minSdk 21 9 | targetSdk 35 10 | compileSdk 35 11 | versionCode 407 12 | versionName "4.0.7" 13 | 14 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 15 | } 16 | 17 | buildTypes { 18 | release { 19 | minifyEnabled false 20 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 21 | } 22 | } 23 | 24 | flavorDimensions += "store" 25 | 26 | productFlavors { 27 | google { 28 | applicationId 'app.door_trigger' 29 | } 30 | fdroid { 31 | applicationId 'com.example.trigger' 32 | } 33 | } 34 | buildFeatures { 35 | buildConfig = true 36 | } 37 | compileOptions { 38 | sourceCompatibility = JavaVersion.VERSION_17 39 | targetCompatibility = JavaVersion.VERSION_17 40 | } 41 | kotlinOptions { 42 | jvmTarget = "17" 43 | } 44 | namespace 'app.trigger' 45 | } 46 | 47 | dependencies { 48 | implementation 'org.conscrypt:conscrypt-android:2.5.2' 49 | implementation 'org.connectbot:sshlib:2.2.19' 50 | implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5' 51 | implementation('com.journeyapps:zxing-android-embedded:4.3.0') { transitive = false } 52 | implementation 'androidx.documentfile:documentfile:1.0.1' 53 | implementation 'com.github.joshjdevl.libsodiumjni:libsodium-jni-aar:2.0.2' 54 | implementation 'com.google.zxing:core:3.4.1' 55 | implementation 'androidx.core:core-ktx:1.15.0' 56 | implementation 'androidx.appcompat:appcompat:1.7.0' 57 | implementation 'com.google.android.material:material:1.12.0' 58 | implementation 'androidx.constraintlayout:constraintlayout:2.2.0' 59 | implementation 'androidx.navigation:navigation-fragment-ktx:2.8.5' 60 | implementation 'androidx.navigation:navigation-ui-ktx:2.8.5' 61 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0' 62 | implementation 'androidx.preference:preference-ktx:1.2.1' 63 | testImplementation 'junit:junit:4.13.2' 64 | androidTestImplementation 'androidx.test.ext:junit:1.2.1' 65 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' 66 | } -------------------------------------------------------------------------------- /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/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 38 | 41 | 42 | 43 | 44 | 45 | 46 | 50 | 54 | 58 | 62 | 67 | 71 | 72 | 76 | 77 | 81 | 82 | 86 | 87 | 91 | 92 | 96 | 97 | 101 | 102 | 105 | 106 | 110 | 111 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/AboutActivity.kt: -------------------------------------------------------------------------------- 1 | package app.trigger 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.widget.TextView 6 | import androidx.appcompat.app.AppCompatActivity 7 | import androidx.appcompat.widget.Toolbar 8 | 9 | class AboutActivity : AppCompatActivity() { 10 | override fun onCreate(savedInstanceState: Bundle?) { 11 | super.onCreate(savedInstanceState) 12 | setContentView(R.layout.activity_about) 13 | title = getString(R.string.menu_about) 14 | 15 | val toolbar = findViewById(R.id.toolbar) 16 | setSupportActionBar(toolbar) 17 | 18 | findViewById(R.id.versionTv).text = if (BuildConfig.DEBUG) { 19 | BuildConfig.VERSION_NAME + " (debug)" 20 | } else { 21 | BuildConfig.VERSION_NAME 22 | } 23 | 24 | findViewById(R.id.licenseTV).setOnClickListener { 25 | val intent = Intent(this, LicenseActivity::class.java) 26 | startActivity(intent) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/BackupActivity.kt: -------------------------------------------------------------------------------- 1 | package app.trigger 2 | 3 | import android.app.Activity 4 | import android.app.AlertDialog 5 | import android.content.Intent 6 | import android.net.Uri 7 | import android.os.Bundle 8 | import android.view.View 9 | import android.widget.Button 10 | import android.widget.Toast 11 | import androidx.activity.result.contract.ActivityResultContracts 12 | import androidx.appcompat.app.AppCompatActivity 13 | import androidx.appcompat.widget.Toolbar 14 | import org.json.JSONObject 15 | 16 | class BackupActivity : AppCompatActivity() { 17 | private lateinit var builder: AlertDialog.Builder 18 | private lateinit var exportButton: Button 19 | private lateinit var importButton: Button 20 | 21 | private fun showErrorMessage(title: String, message: String) { 22 | builder.setTitle(title) 23 | builder.setMessage(message) 24 | builder.setPositiveButton(android.R.string.ok, null) 25 | builder.show() 26 | } 27 | 28 | public override fun onCreate(savedInstanceState: Bundle?) { 29 | super.onCreate(savedInstanceState) 30 | setContentView(R.layout.activity_backup) 31 | 32 | val toolbar = findViewById(R.id.toolbar) 33 | setSupportActionBar(toolbar) 34 | 35 | builder = AlertDialog.Builder(this) 36 | 37 | importButton = findViewById(R.id.ImportButton) 38 | exportButton = findViewById(R.id.ExportButton) 39 | 40 | importButton.setOnClickListener { v: View? -> 41 | val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) 42 | intent.addCategory(Intent.CATEGORY_OPENABLE) 43 | intent.type = "application/json" 44 | importFileLauncher.launch(intent) 45 | } 46 | 47 | exportButton.setOnClickListener { 48 | val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) 49 | intent.addCategory(Intent.CATEGORY_OPENABLE) 50 | intent.putExtra(Intent.EXTRA_TITLE, "trigger-backup.json") 51 | intent.type = "application/json" 52 | exportFileLauncher.launch(intent) 53 | } 54 | } 55 | 56 | private var importFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> 57 | if (result.resultCode == Activity.RESULT_OK) { 58 | val intent = result.data ?: return@registerForActivityResult 59 | val uri = intent.data ?: return@registerForActivityResult 60 | importBackup(uri) 61 | } 62 | } 63 | 64 | private var exportFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> 65 | if (result.resultCode == Activity.RESULT_OK) { 66 | val intent = result.data ?: return@registerForActivityResult 67 | val uri: Uri = intent.data ?: return@registerForActivityResult 68 | exportBackup(uri) 69 | } 70 | } 71 | 72 | private fun exportBackup(uri: Uri) { 73 | try { 74 | val obj = JSONObject() 75 | var count = 0 76 | for (door in Settings.getDoors()) { 77 | val json_obj = Settings.toJsonObject(door) 78 | json_obj!!.remove("id") 79 | obj.put(door.name, json_obj) 80 | count += 1 81 | } 82 | Utils.writeFile(this, uri, obj.toString().toByteArray()) 83 | Toast.makeText(this, "Exported $count entries.", Toast.LENGTH_LONG).show() 84 | } catch (e: Exception) { 85 | showErrorMessage("Error", e.toString()) 86 | } 87 | } 88 | 89 | private fun importBackup(uri: Uri) { 90 | try { 91 | val data = Utils.readFile(this, uri) 92 | val json_data = JSONObject( 93 | String(data, 0, data.size) 94 | ) 95 | var count = 0 96 | val keys = json_data.keys() 97 | while (keys.hasNext()) { 98 | val key = keys.next() 99 | val obj = json_data.getJSONObject(key) 100 | obj.put("id", Settings.getNewDoorIdentifier()) 101 | val door = Settings.fromJsonObject(obj) 102 | if (door != null) { 103 | Settings.storeDoorSetup(door) 104 | } 105 | count += 1 106 | } 107 | Toast.makeText(this, "Imported $count doors", Toast.LENGTH_LONG).show() 108 | } catch (e: Exception) { 109 | showErrorMessage("Error", e.toString()) 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/BluetoothDoor.kt: -------------------------------------------------------------------------------- 1 | package app.trigger 2 | 3 | import org.json.JSONObject 4 | 5 | 6 | class BluetoothDoor(override var id: Int, override var name: String) : Door() { 7 | override val type = Companion.TYPE 8 | var device_name = "" 9 | var service_uuid = "" 10 | var open_query = "" 11 | var close_query = "" 12 | var ring_query = "" 13 | var status_query = "" 14 | var locked_pattern = "" 15 | var unlocked_pattern = "" 16 | 17 | override fun getWiFiSSIDs(): String = "" 18 | override fun getWiFiRequired(): Boolean = false 19 | 20 | override fun parseReply(reply: DoorReply): DoorStatus { 21 | return Utils.genericDoorReplyParser(reply, unlocked_pattern, locked_pattern) 22 | } 23 | 24 | override fun isActionSupported(action: MainActivity.Action): Boolean { 25 | return when (action) { 26 | MainActivity.Action.OPEN_DOOR -> open_query.isNotEmpty() 27 | MainActivity.Action.CLOSE_DOOR -> close_query.isNotEmpty() 28 | MainActivity.Action.RING_DOOR -> ring_query.isNotEmpty() 29 | MainActivity.Action.FETCH_STATE -> status_query.isNotEmpty() 30 | } 31 | } 32 | 33 | fun toJSONObject(): JSONObject { 34 | val obj = JSONObject() 35 | obj.put("id", id) 36 | obj.put("name", name) 37 | obj.put("type", type) 38 | 39 | obj.put("device_name", device_name) 40 | obj.put("service_uuid", service_uuid) 41 | obj.put("open_query", open_query) 42 | obj.put("close_query", close_query) 43 | obj.put("ring_query", ring_query) 44 | obj.put("locked_pattern", locked_pattern) 45 | obj.put("unlocked_pattern", unlocked_pattern) 46 | obj.put("status_query", status_query) 47 | 48 | obj.put("open_image", Utils.serializeBitmap(open_image)) 49 | obj.put("closed_image", Utils.serializeBitmap(closed_image)) 50 | obj.put("unknown_image", Utils.serializeBitmap(unknown_image)) 51 | obj.put("disabled_image", Utils.serializeBitmap(disabled_image)) 52 | 53 | return obj 54 | } 55 | 56 | companion object { 57 | const val TYPE = "BluetoothDoorSetup" 58 | 59 | fun fromJSONObject(obj: JSONObject): BluetoothDoor { 60 | val id = obj.getInt("id") 61 | val name = obj.getString("name") 62 | val setup = BluetoothDoor(id, name) 63 | 64 | setup.device_name = obj.optString("device_name", "") 65 | setup.service_uuid = obj.optString("service_uuid", "") 66 | setup.open_query = obj.optString("open_query", "") 67 | setup.close_query = obj.optString("close_query", "") 68 | setup.ring_query = obj.optString("ring_query", "") 69 | setup.locked_pattern = obj.optString("locked_pattern", "") 70 | setup.unlocked_pattern = obj.optString("unlocked_pattern", "") 71 | setup.status_query = obj.optString("status_query", "") 72 | 73 | setup.open_image = Utils.deserializeBitmap(obj.optString("open_image", "")) 74 | setup.closed_image = Utils.deserializeBitmap(obj.optString("closed_image", "")) 75 | setup.unknown_image = Utils.deserializeBitmap(obj.optString("unknown_image", "")) 76 | setup.disabled_image = Utils.deserializeBitmap(obj.optString("disabled_image", "")) 77 | 78 | return setup 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/BluetoothTools.kt: -------------------------------------------------------------------------------- 1 | package app.trigger 2 | 3 | import android.bluetooth.BluetoothAdapter 4 | import android.bluetooth.BluetoothDevice 5 | import android.bluetooth.BluetoothSocket 6 | import android.content.Context 7 | import java.lang.reflect.InvocationTargetException 8 | 9 | object BluetoothTools { 10 | private const val TAG = "BluetoothTools" 11 | private var adapter: BluetoothAdapter? = null 12 | 13 | fun init(context: Context?) { 14 | adapter = BluetoothAdapter.getDefaultAdapter() 15 | } 16 | 17 | fun isEnabled(): Boolean { 18 | return adapter != null && adapter!!.isEnabled 19 | } 20 | 21 | fun isSupported(): Boolean { 22 | return adapter != null 23 | } 24 | 25 | fun createRfcommSocket(device: BluetoothDevice): BluetoothSocket? { 26 | var tmp: BluetoothSocket? = null 27 | try { 28 | val class1: Class<*> = device.javaClass 29 | val aclass: Array> = arrayOf(Integer.TYPE as Class<*>) 30 | val method = class1.getMethod("createRfcommSocket", *aclass) 31 | val aobj = arrayOfNulls(1) 32 | aobj[0] = Integer.valueOf(1) 33 | tmp = method.invoke(device, *aobj) as BluetoothSocket 34 | } catch (e: NoSuchMethodException) { 35 | e.printStackTrace() 36 | Log.e(TAG, "createRfcommSocket() failed $e") 37 | } catch (e: InvocationTargetException) { 38 | e.printStackTrace() 39 | Log.e(TAG, "createRfcommSocket() failed $e") 40 | } catch (e: IllegalAccessException) { 41 | e.printStackTrace() 42 | Log.e(TAG, "createRfcommSocket() failed $e") 43 | } 44 | return tmp 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/Door.kt: -------------------------------------------------------------------------------- 1 | package app.trigger 2 | 3 | import android.graphics.Bitmap 4 | import app.trigger.DoorStatus.StateCode 5 | 6 | abstract class Door { 7 | // internal id 8 | abstract var id: Int 9 | 10 | // Name of this setup for dropdown menu 11 | abstract var name: String 12 | 13 | // Door mechanism type name 14 | abstract val type: String 15 | 16 | var open_image: Bitmap? = null 17 | var closed_image: Bitmap? = null 18 | var unknown_image: Bitmap? = null 19 | var disabled_image: Bitmap? = null 20 | 21 | // Select setup entry from dropdown if it 22 | // matches any of these SSIDs (comma separated) 23 | abstract fun getWiFiSSIDs(): String 24 | 25 | // only applies for HTTPS, SSH and MQTT so far 26 | abstract fun getWiFiRequired(): Boolean 27 | 28 | // Get image dependent of the door state 29 | //fun getStateImage(state: StateCode?): Bitmap? 30 | fun getStateImage(state: StateCode?): Bitmap? { 31 | return when (state) { 32 | StateCode.OPEN -> open_image 33 | StateCode.CLOSED -> closed_image 34 | StateCode.DISABLED -> disabled_image 35 | StateCode.UNKNOWN -> unknown_image 36 | else -> null 37 | } 38 | } 39 | 40 | fun setStateImage(state: StateCode, bitmap: Bitmap?) { 41 | when (state) { 42 | StateCode.OPEN -> open_image = bitmap 43 | StateCode.CLOSED -> closed_image = bitmap 44 | StateCode.DISABLED -> disabled_image = bitmap 45 | StateCode.UNKNOWN -> unknown_image = bitmap 46 | } 47 | } 48 | 49 | // URL to fetch a https certificate from 50 | // or to send a ssh public key for registration 51 | open fun getRegisterUrl(): String = "" 52 | 53 | // Parse the text reply from 54 | // the door and determine state 55 | abstract fun parseReply(reply: DoorReply): DoorStatus 56 | 57 | // To show/hide respective buttons 58 | abstract fun isActionSupported(action: MainActivity.Action): Boolean 59 | } 60 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/DoorReply.kt: -------------------------------------------------------------------------------- 1 | package app.trigger 2 | 3 | // reply from door 4 | class DoorReply(val action: MainActivity.Action, val code: ReplyCode, val message: String) { 5 | enum class ReplyCode { 6 | LOCAL_ERROR, // could establish a connection for some reason 7 | REMOTE_ERROR, // the door send some error 8 | SUCCESS, // the door send some message that has yet to be parsed 9 | DISABLED // Internet, WiFi or Bluetooth disabled or not supported 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/DoorStatus.kt: -------------------------------------------------------------------------------- 1 | package app.trigger 2 | 3 | // parsed door reply 4 | class DoorStatus(val code: StateCode, val message: String) { 5 | enum class StateCode { 6 | OPEN, CLOSED, UNKNOWN, DISABLED 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/HttpsDoor.kt: -------------------------------------------------------------------------------- 1 | package app.trigger 2 | 3 | import app.trigger.https.HttpsTools 4 | import app.trigger.ssh.KeyPairBean 5 | import app.trigger.ssh.SshTools 6 | import org.json.JSONObject 7 | import java.security.cert.Certificate 8 | 9 | class HttpsDoor(override var id: Int, override var name: String) : Door() { 10 | override val type = Companion.TYPE 11 | 12 | var require_wifi = false 13 | var open_query = "" 14 | var open_method = "GET" 15 | var close_query = "" 16 | var close_method = "GET" 17 | var ring_query = "" 18 | var ring_method = "GET" 19 | var status_query = "" 20 | var status_method = "GET" 21 | var ssids = "" 22 | 23 | // regex to evaluate the door return message 24 | var unlocked_pattern = "" 25 | var locked_pattern = "" 26 | 27 | var server_certificate: Certificate? = null 28 | var client_certificate: Certificate? = null 29 | var client_keypair: KeyPairBean? = null 30 | var ignore_certificate = false 31 | var ignore_hostname_mismatch = false 32 | var ignore_expiration = false 33 | 34 | override fun getWiFiRequired(): Boolean = require_wifi 35 | override fun getWiFiSSIDs(): String = ssids 36 | 37 | // extract from known urls 38 | override fun getRegisterUrl(): String { 39 | return stripUrls(open_query, ring_query, close_query, status_query) 40 | } 41 | 42 | override fun parseReply(reply: DoorReply): DoorStatus { 43 | return Utils.genericDoorReplyParser(reply, unlocked_pattern, locked_pattern) 44 | } 45 | 46 | override fun isActionSupported(action: MainActivity.Action): Boolean { 47 | return when (action) { 48 | MainActivity.Action.OPEN_DOOR -> open_query.isNotEmpty() 49 | MainActivity.Action.CLOSE_DOOR -> close_query.isNotEmpty() 50 | MainActivity.Action.RING_DOOR -> ring_query.isNotEmpty() 51 | MainActivity.Action.FETCH_STATE -> status_query.isNotEmpty() 52 | } 53 | } 54 | 55 | fun toJSONObject(): JSONObject { 56 | val obj = JSONObject() 57 | obj.put("id", id) 58 | obj.put("name", name) 59 | obj.put("type", type) 60 | obj.put("require_wifi", require_wifi) 61 | obj.put("open_query", open_query) 62 | obj.put("open_method", open_method) 63 | obj.put("close_query", close_query) 64 | obj.put("close_method", close_method) 65 | obj.put("ring_query", ring_query) 66 | obj.put("ring_method", ring_method) 67 | obj.put("status_query", status_query) 68 | obj.put("status_method", status_method) 69 | obj.put("ssids", ssids) 70 | obj.put("unlocked_pattern", unlocked_pattern) 71 | obj.put("locked_pattern", locked_pattern) 72 | 73 | obj.put("open_image", Utils.serializeBitmap(open_image)) 74 | obj.put("closed_image", Utils.serializeBitmap(closed_image)) 75 | obj.put("unknown_image", Utils.serializeBitmap(unknown_image)) 76 | obj.put("disabled_image", Utils.serializeBitmap(disabled_image)) 77 | 78 | obj.put("server_certificate", HttpsTools.serializeCertificate(server_certificate)) 79 | obj.put("client_certificate", HttpsTools.serializeCertificate(client_certificate)) 80 | obj.put("client_keypair", SshTools.serializeKeyPair(client_keypair)) 81 | 82 | obj.put("ignore_certificate", ignore_certificate) 83 | obj.put("ignore_hostname_mismatch", ignore_hostname_mismatch) 84 | obj.put("ignore_expiration", ignore_expiration) 85 | 86 | return obj 87 | } 88 | 89 | companion object { 90 | const val TYPE = "HttpsDoorSetup" 91 | 92 | fun fromJSONObject(obj: JSONObject): HttpsDoor { 93 | val id = obj.getInt("id") 94 | val name = obj.getString("name") 95 | val setup = HttpsDoor(id, name) 96 | 97 | val defaultMethod = obj.optString("method", "GET") 98 | 99 | setup.require_wifi = obj.optBoolean("require_wifi", false) 100 | setup.open_query = obj.optString("open_query", "") 101 | setup.open_method = obj.optString("open_method", defaultMethod) 102 | setup.close_query = obj.optString("close_query", "") 103 | setup.close_method = obj.optString("close_method", defaultMethod) 104 | setup.ring_query = obj.optString("ring_query", "") 105 | setup.ring_method = obj.optString("ring_method", defaultMethod) 106 | setup.status_query = obj.optString("status_query", "") 107 | setup.status_method = obj.optString("status_method", defaultMethod) 108 | setup.ssids = obj.optString("ssids", "") 109 | setup.unlocked_pattern = obj.optString("unlocked_pattern", "") 110 | setup.locked_pattern = obj.optString("locked_pattern", "") 111 | setup.open_image = Utils.deserializeBitmap(obj.optString("open_image", "")) 112 | setup.closed_image = Utils.deserializeBitmap(obj.optString("closed_image", "")) 113 | setup.unknown_image = Utils.deserializeBitmap(obj.optString("unknown_image", "")) 114 | setup.disabled_image = Utils.deserializeBitmap(obj.optString("disabled_image", "")) 115 | setup.server_certificate = HttpsTools.deserializeCertificate(obj.optString("server_certificate", "")) 116 | setup.client_certificate = HttpsTools.deserializeCertificate(obj.optString("client_certificate", "")) 117 | setup.client_keypair = SshTools.deserializeKeyPair(obj.optString("client_keypair", "")) 118 | 119 | setup.ignore_certificate = obj.optBoolean("ignore_certificate", false) 120 | setup.ignore_hostname_mismatch = obj.optBoolean("ignore_hostname_mismatch", false) 121 | setup.ignore_expiration = obj.optBoolean("ignore_expiration", false) 122 | 123 | return setup 124 | } 125 | 126 | private fun stripUrls(vararg urls: String): String { 127 | // remove path 128 | val prefix = "https://" 129 | for (url in urls) { 130 | if (url.startsWith(prefix)) { 131 | val i = url.indexOf('/', prefix.length) 132 | if (i > 0) { 133 | return url.substring(0, i) 134 | } 135 | } 136 | return url 137 | } 138 | return "" 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/ImageActivity.kt: -------------------------------------------------------------------------------- 1 | package app.trigger 2 | 3 | import android.app.Activity 4 | import android.content.DialogInterface 5 | import android.content.Intent 6 | import android.graphics.Bitmap 7 | import android.graphics.BitmapFactory 8 | import android.net.Uri 9 | import android.os.Bundle 10 | import android.view.View 11 | import android.widget.Button 12 | import android.widget.ImageView 13 | import androidx.activity.result.contract.ActivityResultContracts 14 | import androidx.appcompat.app.AlertDialog 15 | import androidx.appcompat.app.AppCompatActivity 16 | import androidx.appcompat.widget.Toolbar 17 | import app.trigger.DoorStatus.StateCode 18 | import java.io.ByteArrayOutputStream 19 | 20 | class ImageActivity : AppCompatActivity() { 21 | private lateinit var door: Door 22 | private lateinit var stateCode: StateCode 23 | 24 | private lateinit var builder: AlertDialog.Builder 25 | private lateinit var setButton: Button 26 | private lateinit var selectButton: Button 27 | private lateinit var deleteButton: Button 28 | private lateinit var imageView: ImageView 29 | private var currentImage: Bitmap? = null 30 | 31 | private fun showErrorMessage(message: String) { 32 | builder.setTitle(R.string.error) 33 | builder.setMessage(message) 34 | builder.setPositiveButton(android.R.string.ok, null) 35 | builder.show() 36 | } 37 | 38 | public override fun onCreate(savedInstanceState: Bundle?) { 39 | super.onCreate(savedInstanceState) 40 | setContentView(R.layout.activity_image) 41 | 42 | val toolbar = findViewById(R.id.toolbar) 43 | setSupportActionBar(toolbar) 44 | 45 | // currentDoor might still not be stored if it is a new one 46 | val currentDoor = SetupActivity.currentDoor 47 | val codeString = intent.getStringExtra("state_code") ?: "" 48 | if (currentDoor == null || codeString.isEmpty()) { 49 | // not expected to happen 50 | finish() 51 | return 52 | } 53 | 54 | door = currentDoor 55 | stateCode = StateCode.valueOf(codeString) 56 | currentImage = door.getStateImage(stateCode) 57 | 58 | imageView = findViewById(R.id.selectedImage) 59 | builder = AlertDialog.Builder(this) 60 | setButton = findViewById(R.id.SetButton) 61 | selectButton = findViewById(R.id.SelectButton) 62 | deleteButton = findViewById(R.id.DeleteButton) 63 | 64 | selectButton.setOnClickListener { _: View? -> 65 | val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) 66 | intent.addCategory(Intent.CATEGORY_OPENABLE) 67 | intent.type = "image/*" 68 | importImageLauncher.launch(intent) 69 | } 70 | 71 | setButton.setOnClickListener { _: View? -> 72 | // persist your value here 73 | door.setStateImage(stateCode, currentImage) 74 | finish() 75 | } 76 | 77 | deleteButton.setOnClickListener { _: View? -> 78 | builder.setTitle(R.string.confirm) 79 | builder.setMessage(R.string.dialog_really_remove_image) 80 | builder.setCancelable(false) // not necessary 81 | builder.setPositiveButton(R.string.yes) { dialog: DialogInterface, _: Int -> 82 | currentImage = null 83 | door.setStateImage(stateCode, null) 84 | updateImageView() 85 | dialog.cancel() 86 | } 87 | builder.setNegativeButton(R.string.no) { dialog: DialogInterface, _: Int -> dialog.cancel() } 88 | 89 | // create dialog box 90 | val alert = builder.create() 91 | alert.show() 92 | } 93 | 94 | updateImageView() 95 | } 96 | 97 | private fun updateImageView() { 98 | if (currentImage == null) { 99 | // show default image 100 | val defaultImageResource = when (stateCode) { 101 | StateCode.OPEN -> R.drawable.state_open 102 | StateCode.CLOSED -> R.drawable.state_closed 103 | StateCode.DISABLED -> R.drawable.state_disabled 104 | StateCode.UNKNOWN -> R.drawable.state_unknown 105 | } 106 | imageView.setImageBitmap(BitmapFactory.decodeResource(resources, defaultImageResource)) 107 | deleteButton.isEnabled = false 108 | } else { 109 | imageView.setImageBitmap(currentImage) 110 | deleteButton.isEnabled = true 111 | } 112 | } 113 | 114 | private var importImageLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> 115 | if (result.resultCode == Activity.RESULT_OK) { 116 | val intent = result.data ?: return@registerForActivityResult 117 | val uri = intent.data ?: return@registerForActivityResult 118 | updateImage(uri) 119 | } 120 | } 121 | 122 | private fun updateImage(uri: Uri) { 123 | val maxSize = 800 124 | try { 125 | val data = Utils.readFile(this, uri) 126 | var image = BitmapFactory.decodeByteArray(data, 0, data.size) 127 | if (image == null) { 128 | showErrorMessage("Not a supported image format: " + uri.lastPathSegment) 129 | return 130 | } 131 | val inWidth = image.width 132 | val inHeight = image.height 133 | var outWidth = 0 134 | var outHeight = 0 135 | if (inWidth > inHeight) { 136 | outWidth = maxSize 137 | outHeight = (inHeight * maxSize / inWidth.toFloat()).toInt() 138 | } else { 139 | outHeight = maxSize 140 | outWidth = (inWidth * maxSize / inHeight.toFloat()).toInt() 141 | } 142 | image = Bitmap.createScaledBitmap(image, outWidth, outHeight, false) 143 | val byteStream = ByteArrayOutputStream() 144 | val success = image.compress(Bitmap.CompressFormat.PNG, 0, byteStream) 145 | if (success) { 146 | //Log.d("ImageActivity", "image: " + inWidth + "/" + inHeight + ", compress.length: " + byteStream.toByteArray().length + ", base64: " + Base64.encodeToString(byteStream.toByteArray(), 0).length()); 147 | currentImage = image 148 | } else { 149 | throw Exception("Cannot compress image") 150 | } 151 | updateImageView() 152 | } catch (e: Exception) { 153 | showErrorMessage(e.toString()) 154 | } 155 | } 156 | 157 | companion object { 158 | private const val TAG = "ImageActivity" 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/LicenseActivity.kt: -------------------------------------------------------------------------------- 1 | package app.trigger 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import android.widget.ProgressBar 6 | import android.widget.TextView 7 | import androidx.appcompat.app.AppCompatActivity 8 | import androidx.appcompat.widget.Toolbar 9 | import java.io.BufferedReader 10 | import java.io.IOException 11 | import java.io.InputStreamReader 12 | 13 | class LicenseActivity : AppCompatActivity() { 14 | override fun onCreate(savedInstanceState: Bundle?) { 15 | super.onCreate(savedInstanceState) 16 | setContentView(R.layout.activity_license) 17 | title = getString(R.string.title_license) 18 | 19 | val toolbar = findViewById(R.id.toolbar) 20 | setSupportActionBar(toolbar) 21 | 22 | // reading the license file can be slow => use a thread 23 | Thread { 24 | try { 25 | val buffer = StringBuffer() 26 | val reader = BufferedReader(InputStreamReader(assets.open("license.txt"))) 27 | while (reader.ready()) { 28 | val line = reader.readLine() 29 | if (line != null) { 30 | if (line.trim().isEmpty()){ 31 | buffer.append("\n") 32 | } else { 33 | buffer.append(line + "\n") 34 | } 35 | } else { 36 | break 37 | } 38 | } 39 | reader.close() 40 | runOnUiThread { 41 | findViewById(R.id.licenseLoadingBar).visibility = View.GONE 42 | findViewById(R.id.licenceText).text = buffer.toString() 43 | } 44 | } catch (e: IOException) { 45 | e.printStackTrace() 46 | } 47 | }.start() 48 | } 49 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/Log.kt: -------------------------------------------------------------------------------- 1 | package app.trigger 2 | 3 | import android.util.Log 4 | 5 | /* 6 | * Wrapper for android.util.Log to disable logging 7 | */ 8 | object Log { 9 | private fun contextString(context: Any): String { 10 | return if (context is String) { 11 | context 12 | } else { 13 | context.javaClass.simpleName 14 | } 15 | } 16 | 17 | fun d(context: Any, message: String) { 18 | if (BuildConfig.DEBUG) { 19 | val tag = contextString(context) 20 | Log.d(tag, message) 21 | } 22 | } 23 | 24 | fun w(context: Any, message: String) { 25 | if (BuildConfig.DEBUG) { 26 | val tag = contextString(context) 27 | Log.w(tag, message) 28 | } 29 | } 30 | 31 | fun i(context: Any, message: String) { 32 | if (BuildConfig.DEBUG) { 33 | val tag = contextString(context) 34 | Log.i(tag, message) 35 | } 36 | } 37 | 38 | fun e(context: Any, message: String) { 39 | if (BuildConfig.DEBUG) { 40 | val tag = contextString(context) 41 | Log.e(tag, message) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/MqttDoor.kt: -------------------------------------------------------------------------------- 1 | package app.trigger 2 | 3 | import app.trigger.https.HttpsTools 4 | import app.trigger.ssh.KeyPairBean 5 | import app.trigger.ssh.SshTools 6 | import org.json.JSONObject 7 | import java.security.cert.Certificate 8 | 9 | 10 | class MqttDoor(override var id: Int, override var name: String) : Door() { 11 | override val type = Companion.TYPE 12 | var require_wifi = false 13 | var username = "" 14 | var password = "" 15 | var server = "" 16 | var status_topic = "" 17 | var command_topic = "" 18 | var retained = false 19 | var qos = 0 20 | var open_command = "" 21 | var close_command = "" 22 | var ring_command = "" 23 | var ssids = "" 24 | var locked_pattern = "" 25 | var unlocked_pattern = "" 26 | 27 | var server_certificate: Certificate? = null 28 | var client_certificate: Certificate? = null 29 | var client_keypair: KeyPairBean? = null 30 | var ignore_certificate = false 31 | var ignore_hostname_mismatch = false 32 | var ignore_expiration = false 33 | 34 | override fun getWiFiRequired(): Boolean = require_wifi 35 | override fun getWiFiSSIDs(): String = ssids 36 | 37 | override fun parseReply(reply: DoorReply): DoorStatus { 38 | return Utils.genericDoorReplyParser(reply, unlocked_pattern, locked_pattern) 39 | } 40 | 41 | override fun isActionSupported(action: MainActivity.Action): Boolean { 42 | return when (action) { 43 | MainActivity.Action.OPEN_DOOR -> open_command.isNotEmpty() 44 | MainActivity.Action.CLOSE_DOOR -> close_command.isNotEmpty() 45 | MainActivity.Action.RING_DOOR -> ring_command.isNotEmpty() 46 | MainActivity.Action.FETCH_STATE -> status_topic.isNotEmpty() 47 | } 48 | } 49 | 50 | fun toJSONObject(): JSONObject { 51 | val obj = JSONObject() 52 | obj.put("id", id) 53 | obj.put("name", name) 54 | obj.put("type", type) 55 | 56 | obj.put("require_wifi", require_wifi) 57 | obj.put("username", username) 58 | obj.put("password", password) 59 | obj.put("server", server) 60 | obj.put("status_topic", status_topic) 61 | obj.put("command_topic", command_topic) 62 | obj.put("retained", retained) 63 | obj.put("qos", qos) 64 | 65 | obj.put("open_command", open_command) 66 | obj.put("close_command", close_command) 67 | obj.put("ring_command", ring_command) 68 | obj.put("ssids", ssids) 69 | 70 | obj.put("unlocked_pattern", unlocked_pattern) 71 | obj.put("locked_pattern", locked_pattern) 72 | obj.put("open_image", Utils.serializeBitmap(open_image)) 73 | obj.put("closed_image", Utils.serializeBitmap(closed_image)) 74 | obj.put("unknown_image", Utils.serializeBitmap(unknown_image)) 75 | obj.put("disabled_image", Utils.serializeBitmap(disabled_image)) 76 | 77 | obj.put("server_certificate", HttpsTools.serializeCertificate(server_certificate)) 78 | obj.put("client_certificate", HttpsTools.serializeCertificate(client_certificate)) 79 | obj.put("client_keypair", SshTools.serializeKeyPair(client_keypair)) 80 | 81 | obj.put("ignore_certificate", ignore_certificate) 82 | obj.put("ignore_hostname_mismatch", ignore_hostname_mismatch) 83 | obj.put("ignore_expiration", ignore_expiration) 84 | 85 | return obj 86 | } 87 | 88 | companion object { 89 | const val TYPE = "MqttDoorSetup" 90 | 91 | fun fromJSONObject(obj: JSONObject): MqttDoor { 92 | val id = obj.getInt("id") 93 | val name = obj.getString("name") 94 | val setup = MqttDoor(id, name) 95 | 96 | setup.require_wifi = obj.optBoolean("require_wifi", false) 97 | setup.username = obj.optString("username", "") 98 | setup.password = obj.optString("password", "") 99 | setup.server = obj.optString("server", "") 100 | setup.status_topic = obj.optString("status_topic", "") 101 | setup.command_topic = obj.optString("command_topic", "") 102 | setup.retained = obj.optBoolean("retained", false) 103 | setup.qos = obj.optInt("qos", 0) 104 | setup.open_command = obj.optString("open_command", "") 105 | setup.close_command = obj.optString("close_command", "") 106 | setup.ring_command = obj.optString("ring_command", "") 107 | setup.ssids = obj.optString("ssids", "") 108 | 109 | setup.unlocked_pattern = obj.optString("unlocked_pattern", "") 110 | setup.locked_pattern = obj.optString("locked_pattern", "") 111 | 112 | setup.open_image = Utils.deserializeBitmap(obj.optString("open_image", "")) 113 | setup.closed_image = Utils.deserializeBitmap(obj.optString("closed_image", "")) 114 | setup.unknown_image = Utils.deserializeBitmap(obj.optString("unknown_image", "")) 115 | setup.disabled_image = Utils.deserializeBitmap(obj.optString("disabled_image", "")) 116 | 117 | setup.server_certificate = HttpsTools.deserializeCertificate(obj.optString("server_certificate", "")) 118 | setup.client_certificate = HttpsTools.deserializeCertificate(obj.optString("client_certificate", "")) 119 | setup.client_keypair = SshTools.deserializeKeyPair(obj.optString("client_keypair", "")) 120 | 121 | setup.ignore_certificate = obj.optBoolean("ignore_certificate", false) 122 | setup.ignore_hostname_mismatch = obj.optBoolean("ignore_hostname_mismatch", false) 123 | setup.ignore_expiration = obj.optBoolean("ignore_expiration", false) 124 | 125 | return setup 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/NukiDoor.kt: -------------------------------------------------------------------------------- 1 | package app.trigger 2 | 3 | import android.text.Html 4 | import app.trigger.DoorReply.ReplyCode 5 | import app.trigger.DoorStatus.StateCode 6 | import org.json.JSONObject 7 | 8 | 9 | class NukiDoor(override var id: Int, override var name: String) : Door() { 10 | override val type = Companion.TYPE 11 | var device_name = "" 12 | var user_name = "user" 13 | var shared_key = "" 14 | var auth_id: Long = 0 15 | var app_id: Long = 2342 16 | 17 | override fun getWiFiSSIDs(): String = "" 18 | override fun getWiFiRequired(): Boolean = false 19 | 20 | override fun parseReply(reply: DoorReply): DoorStatus { 21 | val msg = Html.fromHtml(reply.message).toString().trim { it <= ' ' } 22 | return when (reply.code) { 23 | ReplyCode.LOCAL_ERROR, ReplyCode.REMOTE_ERROR -> DoorStatus(StateCode.UNKNOWN, msg) 24 | ReplyCode.SUCCESS -> if (reply.message.contains("unlocked")) { 25 | // door unlocked 26 | DoorStatus(StateCode.OPEN, msg) 27 | } else if (reply.message.contains("locked")) { 28 | // door locked 29 | DoorStatus(StateCode.CLOSED, msg) 30 | } else { 31 | DoorStatus(StateCode.UNKNOWN, msg) 32 | } 33 | ReplyCode.DISABLED -> DoorStatus(StateCode.DISABLED, msg) 34 | } 35 | } 36 | 37 | override fun isActionSupported(action: MainActivity.Action): Boolean { 38 | return when (action) { 39 | MainActivity.Action.OPEN_DOOR -> true 40 | MainActivity.Action.CLOSE_DOOR -> true 41 | MainActivity.Action.RING_DOOR -> false 42 | MainActivity.Action.FETCH_STATE -> true 43 | } 44 | } 45 | 46 | fun toJSONObject(): JSONObject { 47 | val obj = JSONObject() 48 | obj.put("id", id) 49 | obj.put("name", name) 50 | obj.put("type", type) 51 | obj.put("device_name", device_name) 52 | obj.put("user_name", user_name) 53 | obj.put("shared_key", shared_key) 54 | obj.put("auth_id", auth_id) 55 | obj.put("app_id", app_id) 56 | 57 | obj.put("open_image", Utils.serializeBitmap(open_image)) 58 | obj.put("closed_image", Utils.serializeBitmap(closed_image)) 59 | obj.put("unknown_image", Utils.serializeBitmap(unknown_image)) 60 | obj.put("disabled_image", Utils.serializeBitmap(disabled_image)) 61 | 62 | return obj 63 | } 64 | 65 | companion object { 66 | const val TYPE = "NukiDoorSetup" 67 | 68 | fun fromJSONObject(obj: JSONObject): NukiDoor { 69 | val id = obj.getInt("id") 70 | val name = obj.getString("name") 71 | val setup = NukiDoor(id, name) 72 | 73 | setup.device_name = obj.optString("device_name", "") 74 | setup.user_name = obj.optString("user_name", "") 75 | setup.shared_key = obj.optString("shared_key", "") 76 | setup.auth_id = obj.optLong("auth_id", 0) 77 | setup.app_id = obj.optLong("app_id", 2342) 78 | 79 | setup.open_image = Utils.deserializeBitmap(obj.optString("open_image", "")) 80 | setup.closed_image = Utils.deserializeBitmap(obj.optString("closed_image", "")) 81 | setup.unknown_image = Utils.deserializeBitmap(obj.optString("unknown_image", "")) 82 | setup.disabled_image = Utils.deserializeBitmap(obj.optString("disabled_image", "")) 83 | 84 | return setup 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/OnTaskCompleted.kt: -------------------------------------------------------------------------------- 1 | package app.trigger 2 | 3 | import app.trigger.DoorReply.ReplyCode 4 | 5 | interface OnTaskCompleted { 6 | fun onTaskResult(setupId: Int, action: MainActivity.Action, code: ReplyCode, message: String) 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/QRScanActivity.kt: -------------------------------------------------------------------------------- 1 | package app.trigger 2 | 3 | import android.Manifest 4 | import android.os.Build 5 | import android.os.Bundle 6 | import android.widget.Toast 7 | import androidx.activity.result.contract.ActivityResultContracts 8 | import androidx.appcompat.app.AppCompatActivity 9 | import androidx.appcompat.widget.Toolbar 10 | import app.trigger.ssh.SshTools 11 | import com.google.zxing.BarcodeFormat 12 | import com.google.zxing.ResultPoint 13 | import com.journeyapps.barcodescanner.BarcodeCallback 14 | import com.journeyapps.barcodescanner.BarcodeResult 15 | import com.journeyapps.barcodescanner.DecoratedBarcodeView 16 | import com.journeyapps.barcodescanner.DefaultDecoderFactory 17 | import org.json.JSONException 18 | import org.json.JSONObject 19 | import java.net.URI 20 | 21 | class QRScanActivity : AppCompatActivity(), BarcodeCallback { 22 | private lateinit var barcodeView: DecoratedBarcodeView 23 | 24 | override fun onCreate(savedInstanceState: Bundle?) { 25 | super.onCreate(savedInstanceState) 26 | setContentView(R.layout.activity_qrscan) 27 | 28 | val toolbar = findViewById(R.id.toolbar) 29 | setSupportActionBar(toolbar) 30 | 31 | barcodeView = findViewById(R.id.barcodeScannerView) 32 | if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M) { 33 | if (Utils.hasCameraPermission(this)) { 34 | startScan() 35 | } else { 36 | enabledCameraForResult.launch(Manifest.permission.CAMERA) 37 | } 38 | } else { 39 | startScan() 40 | } 41 | } 42 | 43 | private fun startScan() { 44 | val formats: Collection = listOf(BarcodeFormat.QR_CODE) 45 | barcodeView.barcodeView.decoderFactory = DefaultDecoderFactory(formats) 46 | barcodeView.initializeFromIntent(intent) 47 | barcodeView.decodeContinuous(this) 48 | } 49 | 50 | override fun onResume() { 51 | super.onResume() 52 | if (Utils.hasCameraPermission(this)) { 53 | barcodeView.resume() 54 | } 55 | } 56 | 57 | override fun onPause() { 58 | super.onPause() 59 | if (Utils.hasCameraPermission(this)) { 60 | barcodeView.pause() 61 | } 62 | } 63 | 64 | private val enabledCameraForResult = registerForActivityResult(ActivityResultContracts.RequestPermission()) { 65 | isGranted -> if (isGranted) { 66 | startScan() 67 | } else { 68 | Toast.makeText(this, R.string.missing_camera_permission, Toast.LENGTH_LONG).show() 69 | finish() 70 | } 71 | } 72 | 73 | private fun decodeSetup(data: String): JSONObject { 74 | try { 75 | val kp = SshTools.parsePrivateKeyPEM(data) 76 | if (kp != null) { 77 | val obj = JSONObject() 78 | obj.put("type", SshDoor.TYPE) 79 | obj.put("name", Settings.getNewDoorName("SSH Door")) 80 | obj.put("keypair", SshTools.serializeKeyPair(kp)) 81 | return obj 82 | } else { 83 | // assume raw link 84 | val uri = URI(data.trim { it <= ' ' }) 85 | val scheme = uri.scheme 86 | val domain = uri.host 87 | val path = uri.path 88 | val query = uri.query 89 | val port = uri.port 90 | when (scheme) { 91 | "http", "https" -> { 92 | val http_server = domain + if (port > 0) ":$port" else "" 93 | val obj = JSONObject() 94 | obj.put("type", HttpsDoor.TYPE) 95 | obj.put("name", Settings.getNewDoorName(domain)) 96 | obj.put("open_query", data) 97 | return obj 98 | } 99 | "mqtt", "mqtts" -> { 100 | val mqtt_server = scheme + "://" + domain + if (port > 0) ":$port" else "" 101 | val obj = JSONObject() 102 | obj.put("type", MqttDoor.TYPE) 103 | obj.put("name", Settings.getNewDoorName(domain)) 104 | obj.put("server", mqtt_server) 105 | obj.put("command_topic", path) 106 | obj.put("open_command", query) 107 | return obj 108 | } 109 | "ssh" -> { 110 | val obj = JSONObject() 111 | obj.put("type", SshDoor.TYPE) 112 | obj.put("name", Settings.getNewDoorName(domain)) 113 | obj.put("host", domain) 114 | obj.put("port", port) 115 | obj.put("open_command", query) 116 | return obj 117 | } 118 | else -> {} 119 | } 120 | } 121 | } catch (e: Exception) { 122 | // continue 123 | } 124 | 125 | // assume json data, throws exception otherwise 126 | return JSONObject(data) 127 | } 128 | 129 | override fun barcodeResult(result: BarcodeResult) { 130 | try { 131 | val obj = decodeSetup(result.text) 132 | // give entry a new id 133 | obj.put("id", Settings.getNewDoorIdentifier()) 134 | val setup = Settings.fromJsonObject(obj) 135 | if (setup != null) { 136 | Settings.storeDoorSetup(setup) 137 | Toast.makeText(this, "Added ${setup.name}", Toast.LENGTH_LONG).show() 138 | } else { 139 | Toast.makeText(this, "Invalid QR Code", Toast.LENGTH_LONG).show() 140 | } 141 | } catch (e: JSONException) { 142 | Toast.makeText(this, "Invalid QR Code", Toast.LENGTH_LONG).show() 143 | } catch (e: IllegalAccessException) { 144 | Toast.makeText(this, "Incompatible QR Code", Toast.LENGTH_LONG).show() 145 | } catch (e: Exception) { 146 | Toast.makeText(this, e.message, Toast.LENGTH_LONG).show() 147 | } 148 | finish() 149 | } 150 | 151 | override fun possibleResultPoints(resultPoints: List) {} 152 | 153 | companion object { 154 | private const val TAG = "QRScanActivity" 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/QRShowActivity.kt: -------------------------------------------------------------------------------- 1 | package app.trigger 2 | 3 | import android.os.Bundle 4 | import android.widget.ImageView 5 | import android.widget.Toast 6 | import androidx.appcompat.app.AppCompatActivity 7 | import androidx.appcompat.widget.Toolbar 8 | import com.google.zxing.BarcodeFormat 9 | import com.google.zxing.EncodeHintType 10 | import com.google.zxing.MultiFormatWriter 11 | import com.google.zxing.WriterException 12 | import com.journeyapps.barcodescanner.BarcodeEncoder 13 | import org.json.JSONObject 14 | 15 | class QRShowActivity : AppCompatActivity() { 16 | override fun onCreate(savedInstanceState: Bundle?) { 17 | super.onCreate(savedInstanceState) 18 | setContentView(R.layout.activity_qrshow) 19 | 20 | val toolbar = findViewById(R.id.toolbar) 21 | setSupportActionBar(toolbar) 22 | 23 | val door_id = intent.getIntExtra("door_id", -1) 24 | val door = Settings.getDoor(door_id) 25 | if (door != null) { 26 | title = "$title: ${door.name}" 27 | try { 28 | generateQR(door) 29 | } catch (e: Exception) { 30 | e.printStackTrace() 31 | Toast.makeText(this, e.message, Toast.LENGTH_LONG).show() 32 | } 33 | } else { 34 | Toast.makeText(this, "Setup not found.", Toast.LENGTH_LONG).show() 35 | } 36 | } 37 | 38 | private fun getJsonKeys(obj: JSONObject): ArrayList { 39 | val keys = ArrayList() 40 | val it = obj.keys() 41 | while (it.hasNext()) { 42 | keys.add(it.next()) 43 | } 44 | return keys 45 | } 46 | 47 | private fun encodeSetup(obj: JSONObject): String { 48 | // do not export internal id 49 | obj.remove("id") 50 | 51 | // remove empty strings, images and null values 52 | val keys = getJsonKeys(obj) 53 | for (key in keys) { 54 | val value = obj.opt(key) 55 | if (value == null) { 56 | obj.remove(key) 57 | } else if (key.endsWith("_image")) { 58 | obj.remove(key) 59 | } else if (value is String) { 60 | if (value.length == 0) { 61 | obj.remove(key) 62 | } 63 | } 64 | } 65 | return obj.toString() 66 | } 67 | 68 | private fun generateQR(door: Door) { 69 | val multiFormatWriter = MultiFormatWriter() 70 | var data_length = 0 71 | try { 72 | val obj = Settings.toJsonObject(door) ?: throw Exception("Failed to convert setup to JSON") 73 | val data = encodeSetup(obj) 74 | data_length = data.length 75 | 76 | // data has to be a string 77 | val hints = mapOf(EncodeHintType.CHARACTER_SET to "utf-8") 78 | val bitMatrix = multiFormatWriter.encode(data, BarcodeFormat.QR_CODE, 1080, 1080, hints) 79 | val barcodeEncoder = BarcodeEncoder() 80 | val bitmap = barcodeEncoder.createBitmap(bitMatrix) 81 | findViewById(R.id.QRView).setImageBitmap(bitmap) 82 | } catch (e: WriterException) { 83 | Toast.makeText(this, "${e.message} ($data_length Bytes)", Toast.LENGTH_LONG).show() 84 | finish() 85 | } 86 | } 87 | 88 | companion object { 89 | private const val TAG = "QRShowActivity" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/SshDoor.kt: -------------------------------------------------------------------------------- 1 | package app.trigger 2 | 3 | import app.trigger.ssh.KeyPairBean 4 | import app.trigger.ssh.SshTools 5 | import org.json.JSONObject 6 | 7 | 8 | class SshDoor(override var id: Int, override var name: String) : Door() { 9 | override val type = Companion.TYPE 10 | 11 | var require_wifi = false 12 | var keypair: KeyPairBean? = null 13 | var user = "" 14 | var password = "" 15 | var host = "" 16 | var port = 22 17 | var open_command = "" 18 | var close_command = "" 19 | var ring_command = "" 20 | var state_command = "" 21 | 22 | // regex to evaluate the door return message 23 | var unlocked_pattern = "" 24 | var locked_pattern = "" 25 | 26 | var register_url = "" 27 | var ssids = "" 28 | var timeout = 5000 // milliseconds 29 | var passphrase_tmp = "" 30 | 31 | override fun getWiFiRequired(): Boolean = require_wifi 32 | override fun getWiFiSSIDs(): String = ssids 33 | 34 | override fun getRegisterUrl(): String { 35 | return register_url.ifEmpty { host } 36 | } 37 | 38 | override fun parseReply(reply: DoorReply): DoorStatus { 39 | return Utils.genericDoorReplyParser(reply, unlocked_pattern, locked_pattern) 40 | } 41 | 42 | override fun isActionSupported(action: MainActivity.Action): Boolean { 43 | return when (action) { 44 | MainActivity.Action.OPEN_DOOR -> open_command.isNotEmpty() 45 | MainActivity.Action.CLOSE_DOOR -> close_command.isNotEmpty() 46 | MainActivity.Action.RING_DOOR -> ring_command.isNotEmpty() 47 | MainActivity.Action.FETCH_STATE -> state_command.isNotEmpty() 48 | } 49 | } 50 | 51 | fun needsPassphrase(): Boolean { 52 | return keypair != null && keypair!!.encrypted 53 | } 54 | 55 | fun toJSONObject(): JSONObject { 56 | val obj = JSONObject() 57 | obj.put("id", id) 58 | obj.put("name", name) 59 | obj.put("type", type) 60 | 61 | obj.put("require_wifi", require_wifi) 62 | obj.put("keypair", SshTools.serializeKeyPair(keypair)) 63 | obj.put("user", user) 64 | obj.put("password", password) 65 | obj.put("host", host) 66 | obj.put("port", port) 67 | obj.put("open_command", open_command) 68 | obj.put("close_command", close_command) 69 | obj.put("ring_command", ring_command) 70 | obj.put("state_command", state_command) 71 | 72 | obj.put("unlocked_pattern", unlocked_pattern) 73 | obj.put("locked_pattern", locked_pattern) 74 | obj.put("open_image", Utils.serializeBitmap(open_image)) 75 | obj.put("closed_image", Utils.serializeBitmap(closed_image)) 76 | obj.put("unknown_image", Utils.serializeBitmap(unknown_image)) 77 | obj.put("disabled_image", Utils.serializeBitmap(disabled_image)) 78 | 79 | obj.put("register_url", register_url) 80 | obj.put("ssids", ssids) 81 | obj.put("timeout", timeout) 82 | 83 | return obj 84 | } 85 | 86 | companion object { 87 | const val TYPE = "SshDoorSetup" 88 | 89 | fun fromJSONObject(obj: JSONObject): SshDoor { 90 | val id = obj.getInt("id") 91 | val name = obj.getString("name") 92 | val setup = SshDoor(id, name) 93 | 94 | setup.require_wifi = obj.optBoolean("require_wifi", false) 95 | setup.keypair = SshTools.deserializeKeyPair(obj.optString("keypair", "")) 96 | setup.user = obj.optString("user", "") 97 | setup.password = obj.optString("password", "") 98 | setup.host = obj.optString("host", "") 99 | setup.port = obj.optInt("port", 22) 100 | setup.open_command = obj.optString("open_command", "") 101 | setup.close_command = obj.optString("close_command", "") 102 | setup.ring_command = obj.optString("ring_command", "") 103 | setup.state_command = obj.optString("state_command", "") 104 | 105 | setup.unlocked_pattern = obj.optString("unlocked_pattern", "") 106 | setup.locked_pattern = obj.optString("locked_pattern", "") 107 | 108 | setup.open_image = Utils.deserializeBitmap(obj.optString("open_image", "")) 109 | setup.closed_image = Utils.deserializeBitmap(obj.optString("closed_image", "")) 110 | setup.unknown_image = Utils.deserializeBitmap(obj.optString("unknown_image", "")) 111 | setup.disabled_image = Utils.deserializeBitmap(obj.optString("disabled_image", "")) 112 | 113 | setup.register_url = obj.optString("register_url", "") 114 | setup.ssids = obj.optString("ssids", "") 115 | setup.timeout = obj.optInt("timeout", 5000) 116 | 117 | return setup 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/WifiTools.kt: -------------------------------------------------------------------------------- 1 | package app.trigger 2 | 3 | import android.content.Context 4 | import android.net.ConnectivityManager 5 | import android.net.wifi.WifiManager 6 | 7 | object WifiTools { 8 | private const val TAG = "WifiTools" 9 | private var wifiManager: WifiManager? = null 10 | private var connectivityManager: ConnectivityManager? = null 11 | 12 | fun init(context: Context) { 13 | wifiManager = context.getSystemService(Context.WIFI_SERVICE) as WifiManager 14 | connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager 15 | } 16 | 17 | fun matchSSID(ssids: String?, ssid: String): Boolean { 18 | if (ssids != null) { 19 | for (element in ssids.split(",").toTypedArray()) { 20 | val e = element.trim { it <= ' ' } 21 | if (e.isNotEmpty() && e == ssid) { 22 | return true 23 | } 24 | } 25 | } 26 | return false 27 | } 28 | 29 | // Note: needs coarse location permission 30 | fun getCurrentSSID(): String { 31 | // Note: needs coarse location permission 32 | return if (wifiManager != null) { 33 | val info = wifiManager!!.connectionInfo 34 | val ssid = info.ssid 35 | if (ssid.length >= 2 && ssid.startsWith("\"") && ssid.endsWith("\"")) { 36 | // quoted string 37 | ssid.substring(1, ssid.length - 1) 38 | } else { 39 | // hexadecimal string... 40 | ssid 41 | } 42 | } else { 43 | "" 44 | } 45 | } 46 | 47 | /* 48 | public static ArrayList getScannedSSIDs() { 49 | ArrayList ssids; 50 | List results; 51 | 52 | ssids = new ArrayList<>(); 53 | if (wifiManager != null) { 54 | results = wifiManager.getScanResults(); 55 | if (results != null) { 56 | for (ScanResult result : results) { 57 | ssids.add(result.SSID); 58 | } 59 | } 60 | } 61 | 62 | return ssids; 63 | } 64 | 65 | public static ArrayList getConfiguredSSIDs() { 66 | // Note: needs coarse location permission 67 | List configs; 68 | ArrayList ssids; 69 | 70 | ssids = new ArrayList<>(); 71 | if (wifiManager != null) { 72 | configs = wifiManager.getConfiguredNetworks(); 73 | if (configs != null) { 74 | for (WifiConfiguration config : configs) { 75 | ssids.add(config.SSID); 76 | } 77 | } 78 | } 79 | 80 | return ssids; 81 | } 82 | 83 | public static WifiConfiguration findConfig(List configs, String ssid) { 84 | for (WifiConfiguration config : configs) { 85 | if (config.SSID.equals(ssid)) { 86 | return config; 87 | } 88 | } 89 | return null; 90 | } 91 | 92 | // connect to the best wifi that is configured by this app and system 93 | void connectBestOf(ArrayList ssids) { 94 | String current_ssid = this.getCurrentSSID(); 95 | List configs; 96 | WifiConfiguration config; 97 | List scanned; 98 | 99 | if (wifiManager == null) { 100 | return; 101 | } 102 | 103 | configs = wifiManager.getConfiguredNetworks(); 104 | scanned = wifiManager.getScanResults(); 105 | 106 | if (scanned == null && configs == null) { 107 | Log.e("Wifi", "Insufficient data for connect."); 108 | return; 109 | } 110 | 111 | // TODO: sort by signal 112 | for (ScanResult scan : scanned) { 113 | config = findConfig(configs, scan.SSID); 114 | if (config != null) { 115 | if (!current_ssid.equals(scan.SSID)) { 116 | wifiManager.disconnect(); 117 | wifiManager.enableNetwork(config.networkId, true); 118 | wifiManager.reconnect(); 119 | } 120 | break; 121 | } 122 | } 123 | } 124 | */ 125 | fun isConnected(): Boolean { 126 | val networks = connectivityManager!!.allNetworks 127 | for (network in networks) { 128 | val networkInfo = connectivityManager!!.getNetworkInfo(network) 129 | if (networkInfo!!.type == ConnectivityManager.TYPE_WIFI) { 130 | return true 131 | } 132 | } 133 | return false 134 | } 135 | 136 | fun isConnectedWithInternet(): Boolean { 137 | if (connectivityManager == null) { 138 | return false 139 | } 140 | val mWifi = connectivityManager!!.getNetworkInfo(ConnectivityManager.TYPE_WIFI) 141 | return mWifi!!.isConnected 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/bluetooth/BluetoothRequestHandler.kt: -------------------------------------------------------------------------------- 1 | package app.trigger.bluetooth 2 | 3 | import app.trigger.BluetoothTools.createRfcommSocket 4 | import android.bluetooth.BluetoothSocket 5 | import app.trigger.DoorReply.ReplyCode 6 | import android.bluetooth.BluetoothAdapter 7 | import app.trigger.* 8 | import java.io.IOException 9 | import java.lang.Exception 10 | import java.util.* 11 | 12 | 13 | class BluetoothRequestHandler(private val listener: OnTaskCompleted, private val setup: BluetoothDoor, private val action: MainActivity.Action) : Thread() { 14 | private var socket: BluetoothSocket? = null 15 | override fun run() { 16 | if (setup.id < 0) { 17 | listener.onTaskResult(setup.id, action, ReplyCode.LOCAL_ERROR, "Internal Error") 18 | return 19 | } 20 | 21 | val adapter = BluetoothAdapter.getDefaultAdapter() 22 | if (adapter == null) { 23 | listener.onTaskResult(setup.id, action, ReplyCode.DISABLED, "Device does not support Bluetooth") 24 | return 25 | } 26 | 27 | if (!adapter.isEnabled) { 28 | // request to enable 29 | listener.onTaskResult(setup.id, action, ReplyCode.DISABLED, "Bluetooth is disabled.") 30 | return 31 | } 32 | 33 | val request = when (action) { 34 | MainActivity.Action.OPEN_DOOR -> setup.open_query 35 | MainActivity.Action.RING_DOOR -> setup.ring_query 36 | MainActivity.Action.CLOSE_DOOR -> setup.close_query 37 | MainActivity.Action.FETCH_STATE -> setup.status_query 38 | } 39 | 40 | if (request.isEmpty()) { 41 | listener.onTaskResult(setup.id, action, ReplyCode.LOCAL_ERROR, "") 42 | return 43 | } 44 | try { 45 | val pairedDevices = adapter.bondedDevices 46 | var address = "" 47 | for (device in pairedDevices) { 48 | if (device.name != null && device.name == setup.device_name 49 | || device.address == setup.device_name.uppercase(Locale.ROOT) 50 | ) { 51 | address = device.address 52 | } 53 | } 54 | if (address.isEmpty()) { 55 | listener.onTaskResult(setup.id, action, ReplyCode.LOCAL_ERROR, "Device not paired yet.") 56 | return 57 | } 58 | val device = adapter.getRemoteDevice(address) 59 | socket = if (setup.service_uuid.isEmpty()) { 60 | createRfcommSocket(device) 61 | } else { 62 | val uuid = UUID.fromString(setup.service_uuid) 63 | device.createRfcommSocketToServiceRecord(uuid) 64 | } 65 | socket!!.connect() 66 | 67 | // Get the BluetoothSocket input and output streams 68 | val tmpIn = socket!!.inputStream 69 | val tmpOut = socket!!.outputStream 70 | tmpOut.write(request.toByteArray()) 71 | tmpOut.flush() 72 | val response = try { 73 | val buffer = ByteArray(512) 74 | val bytes = tmpIn.read(buffer) 75 | String(buffer, 0, bytes) 76 | } catch (ioe: IOException) { 77 | listener.onTaskResult(setup.id, action, ReplyCode.REMOTE_ERROR, "Cannot reach remote device.") 78 | return 79 | } 80 | socket!!.close() 81 | listener.onTaskResult(setup.id, action, ReplyCode.SUCCESS, response) 82 | } catch (e: Exception) { 83 | listener.onTaskResult(setup.id, action, ReplyCode.LOCAL_ERROR, e.toString()) 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/https/CertificateFetchTask.kt: -------------------------------------------------------------------------------- 1 | package app.trigger.https 2 | 3 | import android.os.AsyncTask 4 | import app.trigger.Log 5 | import java.lang.Exception 6 | import java.net.URL 7 | import java.security.cert.Certificate 8 | import javax.net.ssl.HttpsURLConnection 9 | 10 | class CertificateFetchTask(private val listener: OnTaskCompleted) : AsyncTask() { 11 | interface OnTaskCompleted { 12 | fun onCertificateFetchTaskCompleted(result: Result) 13 | } 14 | 15 | class Result internal constructor(var certificate: Certificate?, var error: String) 16 | 17 | override fun doInBackground(vararg params: Any?): Result { 18 | if (params.size != 1) { 19 | Log.e(TAG, "Unexpected number of params.") 20 | return Result(null, "Internal Error") 21 | } 22 | return try { 23 | var url = URL(params[0] as String) 24 | 25 | // try to establish TLS session only 26 | val port = if (url.port > 0) url.port else url.defaultPort 27 | url = URL("https", url.host, port, "") 28 | 29 | // disable all certification checks 30 | HttpsTools.disableDefaultHostnameVerifier() 31 | HttpsTools.disableDefaultCertificateValidation() 32 | val con = url.openConnection() as HttpsURLConnection 33 | con.connectTimeout = 2000 34 | con.connect() 35 | val certificates = con.serverCertificates 36 | con.disconnect() 37 | if (certificates.size == 0) { 38 | Result(null, "No certificate found.") 39 | } else { 40 | Result(certificates[0], "") 41 | } 42 | } catch (e: Exception) { 43 | Result(null, e.toString()) 44 | } 45 | } 46 | 47 | override fun onPostExecute(result: Result) { 48 | listener.onCertificateFetchTaskCompleted(result) 49 | } 50 | 51 | companion object { 52 | const val TAG = "CertificateFetchTask" 53 | } 54 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/https/HttpsClientCertificateActivity.kt: -------------------------------------------------------------------------------- 1 | package app.trigger.https 2 | 3 | import android.os.Bundle 4 | import app.trigger.AbstractCertificateActivity 5 | import app.trigger.Door 6 | import app.trigger.HttpsDoor 7 | import app.trigger.SetupActivity 8 | import java.security.cert.Certificate 9 | 10 | class HttpsClientCertificateActivity : AbstractCertificateActivity() { 11 | private lateinit var httpsDoor: HttpsDoor 12 | 13 | override fun getDoor(): Door { 14 | return httpsDoor 15 | } 16 | 17 | override fun getCertificate(): Certificate? { 18 | return httpsDoor.client_certificate 19 | } 20 | 21 | override fun setCertificate(certificate: Certificate?) { 22 | httpsDoor.client_certificate = certificate 23 | } 24 | 25 | override fun onCreate(savedInstanceState: Bundle?) { 26 | if (SetupActivity.currentDoor is HttpsDoor) { 27 | httpsDoor = SetupActivity.currentDoor as HttpsDoor 28 | } else { 29 | // not expected to happen 30 | finish() 31 | return 32 | } 33 | super.onCreate(savedInstanceState) 34 | } 35 | 36 | companion object { 37 | private const val TAG = "HttpsClientCertificateActivity" 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/https/HttpsClientKeyPairActivity.kt: -------------------------------------------------------------------------------- 1 | package app.trigger.https 2 | 3 | import android.os.Bundle 4 | import app.trigger.ssh.KeyPairBean 5 | import app.trigger.AbstractClientKeyPairActivity 6 | import app.trigger.HttpsDoor 7 | import app.trigger.SetupActivity 8 | 9 | 10 | class HttpsClientKeyPairActivity : AbstractClientKeyPairActivity() { 11 | private lateinit var httpsDoor: HttpsDoor 12 | 13 | override fun getKeyPair(): KeyPairBean? { 14 | return httpsDoor.client_keypair 15 | } 16 | 17 | override fun setKeyPair(keyPair: KeyPairBean?) { 18 | httpsDoor.client_keypair = keyPair 19 | } 20 | 21 | override fun onCreate(savedInstanceState: Bundle?) { 22 | if (SetupActivity.currentDoor is HttpsDoor) { 23 | httpsDoor = SetupActivity.currentDoor as HttpsDoor 24 | } else { 25 | // not expected to happen 26 | finish() 27 | return 28 | } 29 | super.onCreate(savedInstanceState) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/https/HttpsServerCertificateActivity.kt: -------------------------------------------------------------------------------- 1 | package app.trigger.https 2 | 3 | import android.os.Bundle 4 | import app.trigger.AbstractCertificateActivity 5 | import app.trigger.Door 6 | import app.trigger.HttpsDoor 7 | import app.trigger.SetupActivity 8 | import java.security.cert.Certificate 9 | 10 | class HttpsServerCertificateActivity : AbstractCertificateActivity() { 11 | private lateinit var httpsDoor: HttpsDoor 12 | 13 | override fun getDoor(): Door { 14 | return httpsDoor 15 | } 16 | 17 | override fun getCertificate(): Certificate? { 18 | return httpsDoor.server_certificate 19 | } 20 | 21 | override fun setCertificate(certificate: Certificate?) { 22 | httpsDoor.server_certificate = certificate 23 | } 24 | 25 | override fun onCreate(savedInstanceState: Bundle?) { 26 | if (SetupActivity.currentDoor is HttpsDoor) { 27 | httpsDoor = SetupActivity.currentDoor as HttpsDoor 28 | } else { 29 | // not expected to happen 30 | finish() 31 | return 32 | } 33 | super.onCreate(savedInstanceState) 34 | } 35 | 36 | companion object { 37 | private const val TAG = "HttpsServerCertificateActivity" 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/https/HttpsTools.kt: -------------------------------------------------------------------------------- 1 | package app.trigger.https 2 | 3 | import kotlin.Throws 4 | import android.util.Base64 5 | import app.trigger.Log 6 | import java.io.ByteArrayInputStream 7 | import java.lang.Exception 8 | import java.security.* 9 | import java.security.cert.Certificate 10 | import java.security.cert.CertificateException 11 | import java.security.cert.CertificateFactory 12 | import java.security.cert.X509Certificate 13 | import javax.net.ssl.* 14 | 15 | object HttpsTools { 16 | private const val TAG = "HttpsTools" 17 | fun isValid(cert: X509Certificate): Boolean { 18 | return try { 19 | cert.checkValidity() 20 | true 21 | } catch (e: Exception) { 22 | false 23 | } 24 | } 25 | 26 | fun isSelfSigned(cert: X509Certificate): Boolean { 27 | return cert.issuerX500Principal.name == 28 | cert.subjectX500Principal.name 29 | } 30 | 31 | fun serializeCertificate(certificate: Certificate?): String { 32 | if (certificate == null) { 33 | return "" 34 | } 35 | try { 36 | val prefix = "-----BEGIN CERTIFICATE-----" 37 | val mid = Base64.encodeToString(certificate.encoded, Base64.DEFAULT) 38 | val suffix = "-----END CERTIFICATE-----" 39 | return "${prefix}\n${mid}\n${suffix}" 40 | } catch (e: Exception) { 41 | Log.e(TAG, e.toString()) 42 | } 43 | return "" 44 | } 45 | 46 | fun deserializeCertificate(certificate: String?): Certificate? { 47 | if (certificate.isNullOrEmpty()) { 48 | return null 49 | } 50 | try { 51 | val derInputStream = ByteArrayInputStream(certificate.toByteArray()) 52 | val certificateFactory = CertificateFactory.getInstance("X.509") //KeyStore.getDefaultType() => "BKS" 53 | return certificateFactory.generateCertificate(derInputStream) 54 | } catch (e: Exception) { 55 | Log.e(this, e.toString()) 56 | } 57 | return null 58 | } 59 | 60 | // disable any certificate validation 61 | fun disableDefaultHostnameVerifier() { 62 | HttpsURLConnection.setDefaultHostnameVerifier { arg0, arg1 -> true } 63 | } 64 | 65 | // disable any certificate validation 66 | @Throws(NoSuchAlgorithmException::class, KeyManagementException::class) 67 | fun disableDefaultCertificateValidation() { 68 | val trustManager: TrustManager = object : X509TrustManager { 69 | @Throws(CertificateException::class) 70 | override fun checkClientTrusted(cert: Array, authType: String) { 71 | } 72 | 73 | @Throws(CertificateException::class) 74 | override fun checkServerTrusted(cert: Array, authType: String) { 75 | } 76 | 77 | override fun getAcceptedIssuers(): Array { 78 | return arrayOf() 79 | } 80 | } 81 | val trustManagers = arrayOf(trustManager) 82 | val context = SSLContext.getInstance("TLS") 83 | context.init(null, trustManagers, SecureRandom()) 84 | HttpsURLConnection.setDefaultSSLSocketFactory(context.socketFactory) 85 | } 86 | 87 | @Throws(NoSuchAlgorithmException::class, KeyStoreException::class, KeyManagementException::class) 88 | fun getSocketFactoryIgnoreCertificateExpiredException(): SSLSocketFactory { 89 | val factory = TrustManagerFactory.getInstance("X509") 90 | factory.init(null as KeyStore?) 91 | val trustManagers = factory.trustManagers 92 | for (i in trustManagers.indices) { 93 | if (trustManagers[i] is X509TrustManager) { 94 | trustManagers[i] = IgnoreExpirationTrustManager(trustManagers[i] as X509TrustManager) 95 | } 96 | } 97 | val sslContext = SSLContext.getInstance("TLS") 98 | sslContext.init(null, trustManagers, null) 99 | return sslContext.socketFactory 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/https/IgnoreExpirationTrustManager.kt: -------------------------------------------------------------------------------- 1 | package app.trigger.https 2 | 3 | import kotlin.Throws 4 | import java.math.BigInteger 5 | import java.security.* 6 | import java.security.cert.CertificateEncodingException 7 | import java.security.cert.CertificateException 8 | import java.security.cert.X509Certificate 9 | import java.util.* 10 | import javax.net.ssl.X509TrustManager 11 | 12 | internal class IgnoreExpirationTrustManager(private val innerTrustManager: X509TrustManager) : X509TrustManager { 13 | @Throws(CertificateException::class) 14 | override fun checkClientTrusted(chain: Array, authType: String) { 15 | innerTrustManager.checkClientTrusted(chain, authType) 16 | } 17 | 18 | @Throws(CertificateException::class) 19 | override fun checkServerTrusted(chain: Array, authType: String) { 20 | val newChain = arrayOf(EternalCertificate(chain[0])) 21 | /* 22 | var chain = chain 23 | chain = Arrays.copyOf(chain, chain.size) 24 | val newChain = arrayOfNulls(chain.size) 25 | newChain[0] = EternalCertificate(chain[0]) 26 | System.arraycopy(chain, 1, newChain, 1, chain.size - 1) 27 | chain = newChain 28 | innerTrustManager.checkServerTrusted(chain, authType) 29 | */ 30 | innerTrustManager.checkServerTrusted(newChain, authType) 31 | } 32 | 33 | override fun getAcceptedIssuers(): Array { 34 | return innerTrustManager.acceptedIssuers 35 | } 36 | 37 | private inner class EternalCertificate(private val originalCertificate: X509Certificate) : X509Certificate() { 38 | override fun checkValidity() { 39 | // ignore notBefore/notAfter 40 | } 41 | 42 | override fun checkValidity(date: Date) { 43 | // ignore notBefore/notAfter 44 | } 45 | 46 | override fun getVersion(): Int { 47 | return originalCertificate.version 48 | } 49 | 50 | override fun getSerialNumber(): BigInteger { 51 | return originalCertificate.serialNumber 52 | } 53 | 54 | override fun getIssuerDN(): Principal { 55 | return originalCertificate.issuerDN 56 | } 57 | 58 | override fun getSubjectDN(): Principal { 59 | return originalCertificate.subjectDN 60 | } 61 | 62 | override fun getNotBefore(): Date { 63 | return originalCertificate.notBefore 64 | } 65 | 66 | override fun getNotAfter(): Date { 67 | return originalCertificate.notAfter 68 | } 69 | 70 | @Throws(CertificateEncodingException::class) 71 | override fun getTBSCertificate(): ByteArray { 72 | return originalCertificate.tbsCertificate 73 | } 74 | 75 | override fun getSignature(): ByteArray { 76 | return originalCertificate.signature 77 | } 78 | 79 | override fun getSigAlgName(): String { 80 | return originalCertificate.sigAlgName 81 | } 82 | 83 | override fun getSigAlgOID(): String { 84 | return originalCertificate.sigAlgOID 85 | } 86 | 87 | override fun getSigAlgParams(): ByteArray { 88 | return originalCertificate.sigAlgParams 89 | } 90 | 91 | override fun getIssuerUniqueID(): BooleanArray { 92 | return originalCertificate.issuerUniqueID 93 | } 94 | 95 | override fun getSubjectUniqueID(): BooleanArray { 96 | return originalCertificate.subjectUniqueID 97 | } 98 | 99 | override fun getKeyUsage(): BooleanArray { 100 | return originalCertificate.keyUsage 101 | } 102 | 103 | override fun getBasicConstraints(): Int { 104 | return originalCertificate.basicConstraints 105 | } 106 | 107 | @Throws(CertificateEncodingException::class) 108 | override fun getEncoded(): ByteArray { 109 | return originalCertificate.encoded 110 | } 111 | 112 | @Throws(CertificateException::class, NoSuchAlgorithmException::class, InvalidKeyException::class, NoSuchProviderException::class, SignatureException::class) 113 | override fun verify(key: PublicKey) { 114 | originalCertificate.verify(key) 115 | } 116 | 117 | @Throws(CertificateException::class, NoSuchAlgorithmException::class, InvalidKeyException::class, NoSuchProviderException::class, SignatureException::class) 118 | override fun verify(key: PublicKey, sigProvider: String) { 119 | originalCertificate.verify(key, sigProvider) 120 | } 121 | 122 | override fun toString(): String { 123 | return originalCertificate.toString() 124 | } 125 | 126 | override fun getPublicKey(): PublicKey { 127 | return originalCertificate.publicKey 128 | } 129 | 130 | override fun getCriticalExtensionOIDs(): Set { 131 | return originalCertificate.criticalExtensionOIDs 132 | } 133 | 134 | override fun getExtensionValue(oid: String): ByteArray { 135 | return originalCertificate.getExtensionValue(oid) 136 | } 137 | 138 | override fun getNonCriticalExtensionOIDs(): Set { 139 | return originalCertificate.nonCriticalExtensionOIDs 140 | } 141 | 142 | override fun hasUnsupportedCriticalExtension(): Boolean { 143 | return originalCertificate.hasUnsupportedCriticalExtension() 144 | } 145 | } 146 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/mqtt/MqttClientCertificateActivity.kt: -------------------------------------------------------------------------------- 1 | package app.trigger.mqtt 2 | 3 | import android.os.Bundle 4 | import app.trigger.AbstractCertificateActivity 5 | import app.trigger.Door 6 | import app.trigger.MqttDoor 7 | import app.trigger.SetupActivity 8 | import java.security.cert.Certificate 9 | 10 | class MqttClientCertificateActivity : AbstractCertificateActivity() { 11 | private lateinit var mqttDoor: MqttDoor 12 | 13 | override fun getDoor(): Door { 14 | return mqttDoor 15 | } 16 | 17 | override fun getCertificate(): Certificate? { 18 | return mqttDoor.client_certificate 19 | } 20 | 21 | override fun setCertificate(certificate: Certificate?) { 22 | mqttDoor.client_certificate = certificate 23 | } 24 | 25 | override fun onCreate(savedInstanceState: Bundle?) { 26 | if (SetupActivity.currentDoor is MqttDoor) { 27 | mqttDoor = SetupActivity.currentDoor as MqttDoor 28 | } else { 29 | // not expected to happen 30 | finish() 31 | return 32 | } 33 | super.onCreate(savedInstanceState) 34 | } 35 | 36 | companion object { 37 | private const val TAG = "MqttClientCertificateActivity" 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/mqtt/MqttClientKeyPairActivity.kt: -------------------------------------------------------------------------------- 1 | package app.trigger.mqtt 2 | 3 | import android.os.Bundle 4 | import app.trigger.ssh.KeyPairBean 5 | import app.trigger.AbstractClientKeyPairActivity 6 | import app.trigger.MqttDoor 7 | import app.trigger.SetupActivity 8 | 9 | 10 | class MqttClientKeyPairActivity : AbstractClientKeyPairActivity() { 11 | private lateinit var mqttDoor: MqttDoor 12 | 13 | override fun getKeyPair(): KeyPairBean? { 14 | return mqttDoor.client_keypair 15 | } 16 | 17 | override fun setKeyPair(keyPair: KeyPairBean?) { 18 | mqttDoor.client_keypair = keyPair 19 | } 20 | 21 | override fun onCreate(savedInstanceState: Bundle?) { 22 | if (SetupActivity.currentDoor is MqttDoor) { 23 | mqttDoor = SetupActivity.currentDoor as MqttDoor 24 | } else { 25 | // not expected to happen 26 | finish() 27 | return 28 | } 29 | super.onCreate(savedInstanceState) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/mqtt/MqttServerCertificateActivity.kt: -------------------------------------------------------------------------------- 1 | package app.trigger.mqtt 2 | 3 | import android.os.Bundle 4 | import app.trigger.AbstractCertificateActivity 5 | import app.trigger.Door 6 | import app.trigger.MqttDoor 7 | import app.trigger.SetupActivity 8 | import java.security.cert.Certificate 9 | 10 | class MqttServerCertificateActivity : AbstractCertificateActivity() { 11 | private lateinit var mqttDoor: MqttDoor 12 | 13 | override fun getDoor(): Door { 14 | return mqttDoor 15 | } 16 | 17 | override fun getCertificate(): Certificate? { 18 | return mqttDoor.server_certificate 19 | } 20 | 21 | override fun setCertificate(certificate: Certificate?) { 22 | mqttDoor.server_certificate = certificate 23 | } 24 | 25 | override fun onCreate(savedInstanceState: Bundle?) { 26 | if (SetupActivity.currentDoor is MqttDoor) { 27 | mqttDoor = SetupActivity.currentDoor as MqttDoor 28 | } else { 29 | // not expected to happen 30 | finish() 31 | return 32 | } 33 | super.onCreate(savedInstanceState) 34 | } 35 | 36 | companion object { 37 | private const val TAG = "MqttServerCertificateActivity" 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/nuki/NukiCallback.kt: -------------------------------------------------------------------------------- 1 | package app.trigger.nuki 2 | 3 | import app.trigger.Utils.byteArrayToHexString 4 | import app.trigger.DoorReply.ReplyCode 5 | import android.bluetooth.BluetoothGattCharacteristic 6 | import android.bluetooth.BluetoothGattCallback 7 | import android.bluetooth.BluetoothGatt 8 | import android.bluetooth.BluetoothGattDescriptor 9 | import app.trigger.* 10 | import java.util.* 11 | 12 | internal abstract class NukiCallback(protected val door_id: Int, val action: MainActivity.Action, protected val listener: OnTaskCompleted, private val service_uuid: UUID, private val characteristic_uuid: UUID) 13 | : BluetoothGattCallback() { 14 | /* 15 | * This is mostly called to end the connection quickly instead 16 | * of waiting for the other side to close the connection 17 | */ 18 | protected fun closeConnection(gatt: BluetoothGatt) { 19 | Log.d(TAG, "closeConnection") 20 | gatt.close() 21 | NukiRequestHandler.Companion.bluetooth_in_use.set(false) 22 | } 23 | 24 | override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { 25 | Log.d(TAG, "onConnectionStateChange, status: " 26 | + NukiRequestHandler.getGattStatus(status) 27 | + ", newState: " + NukiRequestHandler.getGattState(newState)) 28 | if (status == BluetoothGatt.GATT_SUCCESS) { 29 | when (newState) { 30 | BluetoothGatt.STATE_CONNECTED -> gatt.discoverServices() 31 | BluetoothGatt.STATE_CONNECTING -> {} 32 | BluetoothGatt.STATE_DISCONNECTED -> closeConnection(gatt) 33 | BluetoothGatt.STATE_DISCONNECTING -> closeConnection(gatt) 34 | else -> closeConnection(gatt) 35 | } 36 | } else { 37 | closeConnection(gatt) 38 | listener.onTaskResult( 39 | door_id, action, ReplyCode.REMOTE_ERROR, "Connection error: ${NukiRequestHandler.getGattStatus(status)}" 40 | ) 41 | } 42 | } 43 | 44 | override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { 45 | Log.d(TAG, "onServicesDiscovered") 46 | 47 | if (status == BluetoothGatt.GATT_SUCCESS) { 48 | val service = gatt.getService(service_uuid) 49 | if (service == null) { 50 | Log.d(TAG, "Service not found: $service_uuid") 51 | closeConnection(gatt) 52 | listener.onTaskResult( 53 | door_id, action, ReplyCode.REMOTE_ERROR, "Service not found: $service_uuid" 54 | ) 55 | return 56 | } 57 | val characteristic = service.getCharacteristic(characteristic_uuid) 58 | if (characteristic == null) { 59 | Log.d(TAG, "Characteristic not found: $characteristic_uuid") 60 | closeConnection(gatt) 61 | listener.onTaskResult( 62 | door_id, action, ReplyCode.REMOTE_ERROR, "Characteristic not found: $characteristic_uuid" 63 | ) 64 | return 65 | } 66 | gatt.setCharacteristicNotification(characteristic, true) 67 | val descriptor = characteristic.getDescriptor(CCC_DESCRIPTOR_UUID) 68 | if (descriptor == null) { 69 | Log.d(TAG, "Descriptor not found: $CCC_DESCRIPTOR_UUID") 70 | closeConnection(gatt) 71 | listener.onTaskResult( 72 | door_id, action, ReplyCode.REMOTE_ERROR, "Descriptor not found: $CCC_DESCRIPTOR_UUID" 73 | ) 74 | return 75 | } 76 | 77 | //Log.i(TAG, "characteristic properties: " + NukiTools.getProperties(characteristic)); 78 | descriptor.value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE 79 | val ok = gatt.writeDescriptor(descriptor) 80 | if (!ok) { 81 | Log.e(TAG, "descriptor write failed") 82 | closeConnection(gatt) 83 | } 84 | } else { 85 | Log.d(TAG, "Client not found: ${NukiRequestHandler.getGattStatus(status)}") 86 | closeConnection(gatt) 87 | listener.onTaskResult( 88 | door_id, action, ReplyCode.LOCAL_ERROR, "Client not found: ${NukiRequestHandler.getGattStatus(status)}" 89 | ) 90 | } 91 | } 92 | 93 | override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { 94 | Log.d(TAG, "onDescriptorWrite, uiid: ${descriptor.uuid}: ${byteArrayToHexString(descriptor.value)}") 95 | 96 | if (status == BluetoothGatt.GATT_SUCCESS) { 97 | onConnected(gatt, descriptor.characteristic) 98 | } else { 99 | Log.e(TAG, "failed to write to client: ${NukiRequestHandler.getGattStatus(status)}") 100 | closeConnection(gatt) 101 | } 102 | } 103 | 104 | abstract fun onConnected(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) 105 | 106 | companion object { 107 | private const val TAG = "NukiCallback" 108 | 109 | // Client Characteristic Configuration Descriptor 110 | val CCC_DESCRIPTOR_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") 111 | 112 | // Pairing UUIDs 113 | val PAIRING_SERVICE_UUID = UUID.fromString("a92ee100-5501-11e4-916c-0800200c9a66") 114 | val PAIRING_GDIO_XTERISTIC_UUID = UUID.fromString("a92ee101-5501-11e4-916c-0800200c9a66") 115 | 116 | // Keyturner UUIDs 117 | val KEYTURNER_SERVICE_UUID = UUID.fromString("a92ee200-5501-11e4-916c-0800200c9a66") 118 | val KEYTURNER_GDIO_XTERISTIC_UUID = UUID.fromString("a92ee201-5501-11e4-916c-0800200c9a66") 119 | val KEYTURNER_USDIO_XTERISTIC_UUID = UUID.fromString("a92ee202-5501-11e4-916c-0800200c9a66") 120 | } 121 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/nuki/NukiCommand.kt: -------------------------------------------------------------------------------- 1 | package app.trigger.nuki 2 | 3 | import org.libsodium.jni.Sodium 4 | import android.util.Log 5 | import java.util.* 6 | 7 | open class NukiCommand(var command: Int) { 8 | internal class NukiRequest(var command_id: Int) : NukiCommand(0x0001) { 9 | fun generate(): ByteArray { 10 | return NukiTools.concat(NukiTools.from16(command), NukiTools.from16(command_id)) 11 | } 12 | } 13 | 14 | internal class NukiAuthIdConfirm(var authenticator: ByteArray, var auth_id: Long) : NukiCommand(0x001E) { 15 | fun generate(): ByteArray { 16 | return NukiTools.concat(NukiTools.from16(command), authenticator, NukiTools.from32_auth_id(auth_id)) 17 | } 18 | } 19 | 20 | internal class NukiAuthData(var authenticator: ByteArray, // 0x00: App, 0x01: Bridge, 0x02 Fob, 0x03 Keypad 21 | var id_type: Int, // The ID of the Nuki App, Nuki Bridge or Nuki Fob to be authorized. same as auth_id???? 22 | var app_id: Long, var name: String, var nonce: ByteArray) : NukiCommand(0x0006) { 23 | fun generate(): ByteArray { 24 | return NukiTools.concat(NukiTools.from16(command), authenticator, NukiTools.from8(id_type), NukiTools.from32_app_id(app_id), NukiTools.nameToBytes(name, 32), nonce) 25 | } 26 | } 27 | 28 | internal class NukiError(var error_code: Int, var command_id: Int) : NukiCommand(0x0012) { 29 | fun asString(): String { 30 | return "Nuki Error: " + NukiTools.getError(error_code) 31 | } 32 | } 33 | 34 | internal class NukiPublicKey(var public_key: ByteArray) : NukiCommand(0x0003) { 35 | fun generate(): ByteArray { 36 | return NukiTools.concat(NukiTools.from16(command), public_key) 37 | } 38 | } 39 | 40 | internal class NukiChallenge(var nonce: ByteArray) : NukiCommand(0x0004) { 41 | fun generate(): ByteArray { 42 | return NukiTools.concat(NukiTools.from16(command), nonce) 43 | } 44 | 45 | init { 46 | if (nonce.size != 32) { 47 | Log.e("NukiChallenge", "invalid nonce length: " + nonce.size + " (expected " + 32 + ")") 48 | } 49 | } 50 | } 51 | 52 | internal class NukiAuthID(var authenticator: ByteArray, var auth_id: Long, var uuid: ByteArray, var nonce: ByteArray) : NukiCommand(0x0007) { 53 | fun verify(shared_key: ByteArray?, nonce: ByteArray?): Boolean { 54 | val valueR = NukiTools.concat(NukiTools.from32_auth_id(auth_id), uuid, this.nonce, nonce!!) 55 | val authenticator = ByteArray(Sodium.crypto_auth_hmacsha256_bytes()) 56 | if (Sodium.crypto_auth_hmacsha256(authenticator, valueR, valueR.size, shared_key) != 0) { 57 | Log.e("NukiAuthID", "crypto_auth_hmacsha256 failed") 58 | return false 59 | } 60 | return Arrays.equals(this.authenticator, authenticator) 61 | } 62 | } 63 | 64 | internal class NukiStatus(var status: Int) : NukiCommand(0x000E) { 65 | fun generate(): ByteArray { 66 | return NukiTools.concat(NukiTools.from16(command), NukiTools.from8(status)) 67 | } 68 | 69 | companion object { 70 | const val STATUS_COMPLETE = 0x00 // Returned to signal the successful completion of a command 71 | const val STATUS_ACCEPTED = 0x01 // Returned to signal that a command has been accepted but the completion status will be signaled later. 72 | } 73 | } 74 | 75 | internal class NukiStates(var nuki_state: Int, var lock_state: Int, var lock_trigger: Int, var current_time: String, var time_offset: Int, var battery_critical: Int) : NukiCommand(0x000C) 76 | internal class NukiLockAction(var lock_action: Int, var app_id: Long, var flags: Int, // optional 77 | var name_suffix: String?, var nonce: ByteArray?) : NukiCommand(0x000D) { 78 | constructor(lock_action: Int, app_id: Long, flags: Int, nonce: ByteArray?) : this(lock_action, app_id, flags, null, nonce) 79 | 80 | fun generate(): ByteArray { 81 | val name_suffix_padded = if (name_suffix == null) { 82 | ByteArray(0) 83 | } else { 84 | NukiTools.nameToBytes(name_suffix, 20) 85 | } 86 | return NukiTools.concat(NukiTools.from16(command), NukiTools.from8(lock_action), NukiTools.from32_app_id(app_id), NukiTools.from8(flags), name_suffix_padded, nonce!!) 87 | } 88 | 89 | init { 90 | if (nonce!!.size != 32) { 91 | Log.e("NukiLockAction", "nonce has wrong length: " + nonce!!.size) 92 | } 93 | } 94 | } 95 | 96 | internal class NukiAuthAuthentication(private var authenticator: ByteArray) : NukiCommand(0x0005) { 97 | fun generate(): ByteArray { 98 | return NukiTools.concat(NukiTools.from16(command), authenticator) 99 | } 100 | } 101 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/nuki/NukiLockActionCallback.kt: -------------------------------------------------------------------------------- 1 | package app.trigger.nuki 2 | 3 | import app.trigger.Utils.byteArrayToHexString 4 | import app.trigger.Utils.hexStringToByteArray 5 | import app.trigger.DoorReply.ReplyCode 6 | import android.bluetooth.BluetoothGattCharacteristic 7 | import android.bluetooth.BluetoothGatt 8 | import app.trigger.nuki.NukiCommand.NukiRequest 9 | import app.trigger.nuki.NukiCommand.NukiChallenge 10 | import app.trigger.nuki.NukiCommand.NukiStates 11 | import app.trigger.nuki.NukiCommand.NukiStatus 12 | import app.trigger.nuki.NukiCommand.NukiError 13 | import app.trigger.nuki.NukiCommand.NukiLockAction 14 | import app.trigger.* 15 | 16 | internal class NukiLockActionCallback(door_id: Int, action: MainActivity.Action, listener: OnTaskCompleted, setup: NukiDoor, lock_action: Int) 17 | : NukiCallback(door_id, action, listener, KEYTURNER_SERVICE_UUID, KEYTURNER_USDIO_XTERISTIC_UUID) { 18 | var auth_id: Long 19 | var app_id: Long 20 | var lock_action: Int 21 | var shared_key: ByteArray 22 | var data = ByteArray(0) 23 | 24 | override fun onConnected(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { 25 | Log.d(TAG, "onConnected") 26 | val nr = NukiRequest(0x04) 27 | val request: ByteArray? = NukiRequestHandler.encrypt_message(shared_key, auth_id, nr.generate(), null) 28 | characteristic.value = request 29 | val ok = gatt.writeCharacteristic(characteristic) 30 | if (!ok) { 31 | Log.e(TAG, "initial writeCharacteristic failed") 32 | closeConnection(gatt) 33 | } 34 | } 35 | 36 | override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { 37 | Log.d(TAG, "onCharacteristicChanged, uiid: ${characteristic.uuid}: ${byteArrayToHexString(characteristic.value)}") 38 | data = if (data.isEmpty()) { 39 | characteristic.value 40 | } else { 41 | NukiTools.concat(data, characteristic.value) 42 | } 43 | val message: ByteArray? = NukiRequestHandler.decrypt_message(shared_key, data) 44 | val command: NukiCommand? = NukiRequestHandler.parse(message) 45 | if (command == null) { 46 | Log.d(TAG, "NukiCommand is null") 47 | return 48 | } else { 49 | data = ByteArray(0) 50 | } 51 | if (command is NukiChallenge) { 52 | Log.d(TAG, "NukiCommand.NukiChallenge") 53 | val nla = NukiLockAction(lock_action, app_id, 0x00, command.nonce) 54 | val response: ByteArray? = NukiRequestHandler.encrypt_message(shared_key, auth_id, nla.generate(), null) 55 | characteristic.value = response 56 | val ok = gatt.writeCharacteristic(characteristic) 57 | if (!ok) { 58 | Log.e(TAG, "writeCharacteristic failed for NukiLockAction") 59 | closeConnection(gatt) 60 | } 61 | } else if (command is NukiStatus) { 62 | Log.d(TAG, "NukiCommand.NukiStatus") 63 | if (command.status == NukiStatus.STATUS_COMPLETE) { 64 | // do not wait until the Nuki closes the connection 65 | closeConnection(gatt) 66 | } 67 | } else if (command is NukiStates) { 68 | Log.d(TAG, "NukiCommand.NukiStates") 69 | val ns = command 70 | var extra = "" 71 | if (ns.battery_critical == 0x01) { 72 | extra = " (Battery Critical!)" 73 | } 74 | listener.onTaskResult(door_id, action, ReplyCode.SUCCESS, NukiTools.getLockState(ns.lock_state) + extra) 75 | } else if (command is NukiError) { 76 | Log.d(TAG, "NukiCommand.NukiError") 77 | listener.onTaskResult(door_id, action, ReplyCode.REMOTE_ERROR, command.asString()) 78 | closeConnection(gatt) 79 | } else { 80 | Log.e(TAG, "Unhandled command") 81 | closeConnection(gatt) 82 | } 83 | } 84 | 85 | companion object { 86 | private const val TAG = "LockActionCallback" 87 | } 88 | 89 | init { 90 | shared_key = hexStringToByteArray(setup.shared_key) 91 | auth_id = setup.auth_id 92 | app_id = setup.app_id 93 | this.lock_action = lock_action 94 | } 95 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/nuki/NukiReadLockStateCallback.kt: -------------------------------------------------------------------------------- 1 | package app.trigger.nuki 2 | 3 | import app.trigger.Utils.byteArrayToHexString 4 | import app.trigger.Utils.hexStringToByteArray 5 | import app.trigger.DoorReply.ReplyCode 6 | import android.bluetooth.BluetoothGattCharacteristic 7 | import android.bluetooth.BluetoothGatt 8 | import app.trigger.* 9 | 10 | internal class NukiReadLockStateCallback(door_id: Int, action: MainActivity.Action, listener: OnTaskCompleted, setup: NukiDoor) 11 | : NukiCallback(door_id, action, listener, KEYTURNER_SERVICE_UUID, KEYTURNER_USDIO_XTERISTIC_UUID) { 12 | var auth_id: Long 13 | var shared_key: ByteArray 14 | var data: ByteArray = ByteArray(0) 15 | 16 | override fun onConnected(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { 17 | Log.d(TAG, "onConnected") 18 | val nr = NukiCommand.NukiRequest(0x0c) 19 | val request: ByteArray? = NukiRequestHandler.encrypt_message(shared_key, auth_id, nr.generate(), null) 20 | characteristic.value = request 21 | val ok = gatt.writeCharacteristic(characteristic) 22 | if (!ok) { 23 | Log.e(TAG, "initial writeCharacteristic failed") 24 | closeConnection(gatt) 25 | } 26 | } 27 | 28 | override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { 29 | Log.d(TAG, "onCharacteristicChanged, uiid: ${characteristic.uuid}: ${byteArrayToHexString(characteristic.value)}") 30 | data = if (data == null) { 31 | characteristic.value 32 | } else { 33 | NukiTools.concat(data, characteristic.value) 34 | } 35 | val message: ByteArray? = NukiRequestHandler.decrypt_message(shared_key, data) 36 | val m: NukiCommand? = NukiRequestHandler.parse(message) 37 | if (m == null) { 38 | Log.d(TAG, "NukiCommand is null") 39 | return 40 | } else { 41 | data = ByteArray(0) 42 | } 43 | 44 | if (m is NukiCommand.NukiStates) { 45 | val ns = m 46 | var extra = "" 47 | if (ns.battery_critical == 0x01) { 48 | extra = " (Battery Critical!)" 49 | } 50 | listener.onTaskResult( 51 | door_id, action, ReplyCode.SUCCESS, NukiTools.getLockState(ns.lock_state) + extra 52 | ) 53 | 54 | // do not wait until the Nuki closes the connection 55 | closeConnection(gatt) 56 | } else if (m is NukiCommand.NukiError) { 57 | listener.onTaskResult(door_id, action, ReplyCode.REMOTE_ERROR, m.asString()) 58 | closeConnection(gatt) 59 | } else { 60 | Log.e(TAG, "Unhandled command.") 61 | closeConnection(gatt) 62 | } 63 | } 64 | 65 | companion object { 66 | private const val TAG = "ReadLockStateCallback" 67 | } 68 | 69 | init { 70 | shared_key = hexStringToByteArray(setup.shared_key) 71 | auth_id = setup.auth_id 72 | } 73 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/ssh/GenerateIdentityTask.kt: -------------------------------------------------------------------------------- 1 | package app.trigger.ssh 2 | 3 | import android.os.AsyncTask 4 | import app.trigger.Log 5 | import com.trilead.ssh2.crypto.keys.Ed25519Provider 6 | import java.lang.Exception 7 | import java.security.KeyPairGenerator 8 | import java.security.SecureRandom 9 | 10 | internal class GenerateIdentityTask(var listener: OnTaskCompleted) : AsyncTask() { 11 | var keypair: KeyPairBean? = null 12 | 13 | companion object { 14 | private const val TAG = "GenerateIdentityTask" 15 | 16 | init { 17 | Log.d(TAG, "Ed25519Provider.insertIfNeeded2") 18 | // Since this class deals with Ed25519 keys, we need to make sure this is available. 19 | Ed25519Provider.insertIfNeeded() 20 | } 21 | } 22 | 23 | interface OnTaskCompleted { 24 | fun onGenerateIdentityTaskCompleted(message: String?, keypair: KeyPairBean?) 25 | } 26 | 27 | private fun convertAlgorithmName(algorithm: String): String { 28 | return if ("EdDSA" == algorithm) { 29 | KeyPairBean.KEY_TYPE_ED25519 30 | } else { 31 | algorithm 32 | } 33 | } 34 | 35 | override fun doInBackground(vararg params: Any?): String? { 36 | if (params.size != 1) { 37 | Log.e(TAG, "Unexpected number of params.") 38 | return "Internal Error" 39 | } 40 | keypair = try { 41 | val type = params[0] as String 42 | if (type == "ED25519") { 43 | createKeyPair(KeyPairBean.KEY_TYPE_ED25519, 256) 44 | } else if (type == "ECDSA-384") { 45 | createKeyPair(KeyPairBean.KEY_TYPE_EC, 384) 46 | } else if (type == "ECDSA-521") { 47 | createKeyPair(KeyPairBean.KEY_TYPE_EC, 521) 48 | } else if (type == "RSA-2048") { 49 | createKeyPair(KeyPairBean.KEY_TYPE_RSA, 2048) 50 | } else if (type == "RSA-4096") { 51 | createKeyPair(KeyPairBean.KEY_TYPE_RSA, 4096) 52 | } else if (type == "DSA-1024") { 53 | createKeyPair(KeyPairBean.KEY_TYPE_DSA, 1024) 54 | } else { 55 | return "Unknown key type: $type" 56 | } 57 | } catch (e: Exception) { 58 | return e.message 59 | } 60 | return "Done" 61 | } 62 | 63 | override fun onPostExecute(message: String?) { 64 | listener.onGenerateIdentityTaskCompleted(message, keypair) 65 | } 66 | 67 | fun createKeyPair(type: String, bits: Int): KeyPairBean? { 68 | val random = SecureRandom() 69 | 70 | // Work around JVM bug 71 | //random.nextInt(); 72 | //random.setSeed(entropy); //TODO! 73 | try { 74 | val keyPairGen = KeyPairGenerator.getInstance(type) 75 | keyPairGen.initialize(bits, random) 76 | val pair = keyPairGen.generateKeyPair() 77 | val priv = pair.private 78 | val pub = pair.public 79 | 80 | //Log.d(TAG, "PrivateKey: " + priv.getAlgorithm() + " " + priv.getFormat() + " " + priv.getEncoded().length); 81 | //Log.d(TAG, "PublicKey: " + pub.getAlgorithm() + " " + pub.getFormat() + " " + pub.getEncoded().length); 82 | val secret = "" // password for encrypted key 83 | 84 | //Log.d(TAG, "private: " + PubkeyUtils.formatKey(priv)); 85 | Log.d(TAG, "public: ${PubkeyUtils.formatKey(pub)}") // public: Key[algorithm=EdDSA, format=X.509, bytes=44] 86 | val privateKey = PubkeyUtils.getEncodedPrivate(priv, secret).clone() 87 | val publicKey = pub.encoded.clone() 88 | return KeyPairBean(type, privateKey, publicKey, false) 89 | } catch (e: Exception) { 90 | Log.e(TAG, e.toString()) 91 | } 92 | return null 93 | } 94 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/ssh/KeyPairBean.kt: -------------------------------------------------------------------------------- 1 | package app.trigger.ssh 2 | 3 | import app.trigger.Log 4 | import com.trilead.ssh2.crypto.PEMDecoder 5 | import java.io.IOException 6 | import java.io.Serializable 7 | import java.lang.Exception 8 | import java.lang.RuntimeException 9 | import java.lang.StringBuilder 10 | import java.security.NoSuchAlgorithmException 11 | import java.security.spec.InvalidKeySpecException 12 | import java.util.* 13 | 14 | /* 15 | * A wrapper for a private key in PEM (text) format. 16 | * This is necessary for type inference mechanism. 17 | * The public key is derived/extracted if needed. 18 | */ 19 | class KeyPairBean(val type: String, val privateKey: ByteArray, val publicKey: ByteArray, val encrypted: Boolean) : Serializable { 20 | var nickname = "" 21 | 22 | val openSSHPublicKey: String? 23 | get() { 24 | try { 25 | val pk = PubkeyUtils.decodePublic(publicKey, type) 26 | return PubkeyUtils.convertToOpenSSHFormat(pk, nickname) 27 | } catch (e: Exception) { 28 | Log.e(TAG, "getOpenSSHPublicKey: " + e.message) 29 | } 30 | return null 31 | } 32 | val openSSHPrivateKey: String? 33 | get() { 34 | try { 35 | return if (type == KEY_TYPE_IMPORTED) { 36 | String(privateKey) 37 | } else { 38 | val pk = PubkeyUtils.decodePrivate(privateKey, type) 39 | PubkeyUtils.exportPEM(pk, null) 40 | } 41 | } catch (e: Exception) { 42 | Log.e(TAG, "getOpenSSHPrivateKey: " + e.message) 43 | } 44 | return null 45 | } 46 | 47 | // 256 bit, but this might give the wrong impression regarding security 48 | val description: String 49 | get() = if (KEY_TYPE_IMPORTED == type) { 50 | var type = "" 51 | try { 52 | val struct = PEMDecoder.parsePEM(String(privateKey).toCharArray()) 53 | type = when (struct.pemType) { 54 | PEMDecoder.PEM_RSA_PRIVATE_KEY -> "RSA" 55 | PEMDecoder.PEM_DSA_PRIVATE_KEY -> "DSA" 56 | PEMDecoder.PEM_EC_PRIVATE_KEY -> "EC" 57 | PEMDecoder.PEM_OPENSSH_PRIVATE_KEY -> "OpenSSH" 58 | else -> throw RuntimeException("Unexpected key type: ${struct.pemType}") 59 | } 60 | } catch (e: IOException) { 61 | Log.e(TAG, "Error decoding IMPORTED public key: $e") 62 | } 63 | String.format("%s unknown-bit", type) 64 | } else { 65 | var bits: Int? = null 66 | try { 67 | bits = PubkeyUtils.getBitStrength(publicKey, type) 68 | } catch (ignored: NoSuchAlgorithmException) { 69 | } catch (ignored: InvalidKeySpecException) { 70 | } 71 | val sb = StringBuilder() 72 | if (KEY_TYPE_RSA == type) { 73 | sb.append(String.format(Locale.getDefault(), "RSA %d-bit", bits)) 74 | } else if (KEY_TYPE_DSA == type) { 75 | sb.append(String.format(Locale.getDefault(), "DSA %d-bit", 1024)) 76 | } else if (KEY_TYPE_EC == type) { 77 | sb.append(String.format(Locale.getDefault(), "EC %d-bit", bits)) 78 | } else if (KEY_TYPE_ED25519 == type) { 79 | sb.append("ED25519") // 256 bit, but this might give the wrong impression regarding security 80 | } else { 81 | sb.append("Unknown key type") 82 | } 83 | if (encrypted) { 84 | sb.append(" (encrypted)") 85 | } 86 | sb.toString() 87 | } 88 | 89 | companion object { 90 | private const val TAG = "KeyPairBean" 91 | const val KEY_TYPE_RSA = "RSA" 92 | const val KEY_TYPE_DSA = "DSA" 93 | const val KEY_TYPE_IMPORTED = "IMPORTED" // imported PEM key 94 | const val KEY_TYPE_EC = "EC" 95 | const val KEY_TYPE_ED25519 = "ED25519" 96 | } 97 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/ssh/RegisterIdentityTask.kt: -------------------------------------------------------------------------------- 1 | package app.trigger.ssh 2 | 3 | import app.trigger.Utils.readInputStreamWithTimeout 4 | import app.trigger.Utils.rebuildAddress 5 | import app.trigger.Utils.createSocketAddress 6 | import java.io.DataOutputStream 7 | import java.lang.Exception 8 | import java.net.Socket 9 | 10 | internal class RegisterIdentityTask(private val listener: OnTaskCompleted, private val address: String, private val keypair: KeyPairBean) : Thread() { 11 | interface OnTaskCompleted { 12 | fun onRegisterIdentityTaskCompleted(message: String?) 13 | } 14 | 15 | override fun run() { 16 | try { 17 | val addr = createSocketAddress( 18 | rebuildAddress(address, 0) 19 | ) 20 | if (addr.port == 0) { 21 | listener.onRegisterIdentityTaskCompleted("Missing port, use
:") 22 | return 23 | } 24 | val client = Socket(addr.address, addr.port) 25 | val os = client.getOutputStream() 26 | val `is` = client.getInputStream() 27 | val writer = DataOutputStream(os) 28 | 29 | // send public key in PEM format 30 | os.write(keypair.openSSHPublicKey!!.toByteArray()) 31 | os.flush() 32 | val reply = readInputStreamWithTimeout(`is`, 1024, 1000) 33 | client.close() 34 | if (reply.isNotEmpty()) { 35 | listener.onRegisterIdentityTaskCompleted(reply) 36 | } else { 37 | listener.onRegisterIdentityTaskCompleted("Done") 38 | } 39 | } catch (e: Exception) { 40 | listener.onRegisterIdentityTaskCompleted(e.toString()) 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/app/trigger/ssh/SshTools.kt: -------------------------------------------------------------------------------- 1 | package app.trigger.ssh 2 | 3 | import app.trigger.Log 4 | import app.trigger.Utils.byteArrayToHexString 5 | import app.trigger.Utils.hexStringToByteArray 6 | import org.json.JSONObject 7 | import org.json.JSONException 8 | import com.trilead.ssh2.crypto.PEMDecoder 9 | import com.trilead.ssh2.crypto.Base64 10 | import java.io.* 11 | import java.lang.Exception 12 | import java.security.KeyPair 13 | 14 | 15 | object SshTools { 16 | private const val TAG = "SshTools" 17 | 18 | fun serializeKeyPair(keypair: KeyPairBean?): String? { 19 | if (keypair == null) { 20 | return "" 21 | } 22 | 23 | try { 24 | val obj = JSONObject() 25 | obj.put("type", keypair.type) 26 | obj.put("privateKey", byteArrayToHexString(keypair.privateKey)) 27 | obj.put("publicKey", byteArrayToHexString(keypair.publicKey)) 28 | obj.put("encrypted", keypair.encrypted) 29 | return obj.toString() 30 | } catch (e: JSONException) { 31 | Log.e(TAG, "serializeKeyPair: $e") 32 | } 33 | return null 34 | } 35 | 36 | fun deserializeKeyPair(str: String?): KeyPairBean? { 37 | return if (str == null || str.length == 0) { 38 | null 39 | } else try { 40 | val obj = JSONObject(str) 41 | KeyPairBean( 42 | obj.getString("type"), 43 | hexStringToByteArray(obj.getString("privateKey")), 44 | hexStringToByteArray(obj.getString("publicKey")), 45 | obj.getBoolean("encrypted") 46 | ) 47 | } catch (e: JSONException) { 48 | Log.e(TAG, "deserializeKeyPair: $e") 49 | 50 | // fallback for old 51 | deserializeKeyPair_3_2_3(str) 52 | } 53 | } 54 | 55 | fun deserializeKeyPair_3_2_3(str: String?): KeyPairBean? { 56 | if (str == null || str.length == 0) { 57 | return null 58 | } 59 | try { 60 | return parsePrivateKeyPEM(str) 61 | } catch (e: Exception) { 62 | Log.e(TAG, "deserialize error: $e") 63 | } 64 | return null 65 | } 66 | 67 | // for <= 1.9.1 68 | fun deserializeKeyPair_1_9_1(str: String?): KeyPairBean? { 69 | if (str == null || str.length == 0) { 70 | return null 71 | } 72 | try { 73 | // base64 string to bytes 74 | val bytes = Base64.decode(str.toCharArray()) 75 | 76 | // bytes to KeyPairData 77 | val bais = ByteArrayInputStream(bytes) 78 | val ios = ObjectInputStream(bais) 79 | val obj = ios.readObject() as KeyPairData 80 | return parsePrivateKeyPEM(String(obj.prvkey)) 81 | } catch (e: Exception) { 82 | Log.e(TAG, "deserialize error: $e") 83 | } 84 | return null 85 | } 86 | 87 | private fun readPKCS8Key(keyData: ByteArray): KeyPair? { 88 | val reader = BufferedReader(InputStreamReader(ByteArrayInputStream(keyData))) 89 | 90 | // parse the actual key once to check if its encrypted 91 | // then save original file contents into our database 92 | try { 93 | val keyBytes = ByteArrayOutputStream() 94 | var line: String? 95 | var inKey = false 96 | 97 | while (reader.readLine().also { line = it } != null) { 98 | if (line == PubkeyUtils.PKCS8_START) { 99 | inKey = true 100 | } else if (line == PubkeyUtils.PKCS8_END) { 101 | break 102 | } else if (inKey) { 103 | keyBytes.write(line!!.toByteArray(charset("US-ASCII"))) 104 | } 105 | } 106 | if (keyBytes.size() > 0) { 107 | val decoded = Base64.decode(keyBytes.toString().toCharArray()) 108 | return PubkeyUtils.recoverKeyPair(decoded) 109 | } 110 | } catch (e: Exception) { 111 | return null 112 | } 113 | return null 114 | } 115 | 116 | private fun convertAlgorithmName(algorithm: String): String { 117 | return if ("EdDSA" == algorithm) { 118 | KeyPairBean.KEY_TYPE_ED25519 119 | } else { 120 | algorithm 121 | } 122 | } 123 | 124 | fun parsePrivateKeyPEM(keyData: String): KeyPairBean? { 125 | val kp2 = readPKCS8Key(keyData.toByteArray()) 126 | if (kp2 != null) { 127 | val algorithm = convertAlgorithmName(kp2.private.algorithm) 128 | return KeyPairBean(algorithm, kp2.private.encoded, kp2.public.encoded, false) 129 | } else { 130 | try { 131 | val struct = PEMDecoder.parsePEM(keyData.toCharArray()) 132 | val encrypted = PEMDecoder.isPEMEncrypted(struct) 133 | return if (!encrypted) { 134 | val kp = PEMDecoder.decode(struct, null) 135 | val algorithm = convertAlgorithmName(kp.private.algorithm) 136 | KeyPairBean(algorithm, kp.private.encoded, kp.public.encoded, encrypted) 137 | } else { 138 | KeyPairBean(KeyPairBean.KEY_TYPE_IMPORTED, keyData.toByteArray(), ByteArray(0), encrypted) 139 | } 140 | } catch (e: IOException) { 141 | Log.e(TAG, "Problem parsing imported private key: $e") 142 | } 143 | } 144 | return null 145 | } 146 | 147 | // helper class that holds the content of the old id_rsa/id_rsa.pub file content (PEM format) 148 | private class KeyPairData(val prvkey: ByteArray, val pubkey: ByteArray) : Serializable 149 | } -------------------------------------------------------------------------------- /app/src/main/res/anim/pressed.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_action_about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwarning/trigger/c0b9a351a6f7b58341c5fb24650e383287d18533/app/src/main/res/drawable-hdpi/ic_action_about.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_action_edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwarning/trigger/c0b9a351a6f7b58341c5fb24650e383287d18533/app/src/main/res/drawable-hdpi/ic_action_edit.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_action_new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwarning/trigger/c0b9a351a6f7b58341c5fb24650e383287d18533/app/src/main/res/drawable-hdpi/ic_action_new.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_action_about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwarning/trigger/c0b9a351a6f7b58341c5fb24650e383287d18533/app/src/main/res/drawable-mdpi/ic_action_about.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_action_edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwarning/trigger/c0b9a351a6f7b58341c5fb24650e383287d18533/app/src/main/res/drawable-mdpi/ic_action_edit.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_action_new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwarning/trigger/c0b9a351a6f7b58341c5fb24650e383287d18533/app/src/main/res/drawable-mdpi/ic_action_new.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_action_about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwarning/trigger/c0b9a351a6f7b58341c5fb24650e383287d18533/app/src/main/res/drawable-xhdpi/ic_action_about.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_action_edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwarning/trigger/c0b9a351a6f7b58341c5fb24650e383287d18533/app/src/main/res/drawable-xhdpi/ic_action_edit.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_action_new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwarning/trigger/c0b9a351a6f7b58341c5fb24650e383287d18533/app/src/main/res/drawable-xhdpi/ic_action_new.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_action_refresh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwarning/trigger/c0b9a351a6f7b58341c5fb24650e383287d18533/app/src/main/res/drawable-xhdpi/ic_action_refresh.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwarning/trigger/c0b9a351a6f7b58341c5fb24650e383287d18533/app/src/main/res/drawable-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_action_about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwarning/trigger/c0b9a351a6f7b58341c5fb24650e383287d18533/app/src/main/res/drawable-xxhdpi/ic_action_about.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_action_edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwarning/trigger/c0b9a351a6f7b58341c5fb24650e383287d18533/app/src/main/res/drawable-xxhdpi/ic_action_edit.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_action_new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwarning/trigger/c0b9a351a6f7b58341c5fb24650e383287d18533/app/src/main/res/drawable-xxhdpi/ic_action_new.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_action_refresh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwarning/trigger/c0b9a351a6f7b58341c5fb24650e383287d18533/app/src/main/res/drawable-xxhdpi/ic_action_refresh.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwarning/trigger/c0b9a351a6f7b58341c5fb24650e383287d18533/app/src/main/res/drawable-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_action_scan_qr.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_action_show_qr.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 15 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_lock.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_ring.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_unlock.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/rounded_corners.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/state_closed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwarning/trigger/c0b9a351a6f7b58341c5fb24650e383287d18533/app/src/main/res/drawable/state_closed.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/state_disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwarning/trigger/c0b9a351a6f7b58341c5fb24650e383287d18533/app/src/main/res/drawable/state_disabled.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/state_open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwarning/trigger/c0b9a351a6f7b58341c5fb24650e383287d18533/app/src/main/res/drawable/state_open.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/state_unknown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwarning/trigger/c0b9a351a6f7b58341c5fb24650e383287d18533/app/src/main/res/drawable/state_unknown.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/trigger_logo.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_about.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 15 | 16 | 21 | 22 | 23 | 24 | 29 | 30 | 38 | 39 | 47 | 48 | 55 | 56 | 63 | 64 | 71 | 72 | 79 | 80 | 87 | 88 | 95 | 96 | 103 | 104 | 112 | 113 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_abstract_certificate.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 13 | 14 | 18 | 19 | 20 | 21 | 27 | 28 | 35 | 36 | 44 | 45 | 52 | 53 | 62 | 63 | 64 | 65 | 70 | 71 |