├── app
├── .gitignore
├── src
│ └── main
│ │ ├── res
│ │ ├── values-de
│ │ │ └── array.xml
│ │ ├── drawable
│ │ │ ├── state_open.png
│ │ │ ├── state_closed.png
│ │ │ ├── state_unknown.png
│ │ │ ├── state_disabled.png
│ │ │ ├── rounded_corners.xml
│ │ │ ├── ic_ring.xml
│ │ │ ├── ic_action_show_qr.xml
│ │ │ ├── ic_action_scan_qr.xml
│ │ │ ├── trigger_logo.xml
│ │ │ ├── ic_unlock.xml
│ │ │ ├── ic_lock.xml
│ │ │ └── ic_launcher_foreground.xml
│ │ ├── mipmap-hdpi
│ │ │ └── ic_launcher.png
│ │ ├── mipmap-mdpi
│ │ │ └── ic_launcher.png
│ │ ├── mipmap-xhdpi
│ │ │ ├── ic_launcher.png
│ │ │ └── ic_launcher_round.png
│ │ ├── drawable-hdpi
│ │ │ ├── ic_action_new.png
│ │ │ ├── ic_action_about.png
│ │ │ └── ic_action_edit.png
│ │ ├── drawable-mdpi
│ │ │ ├── ic_action_new.png
│ │ │ ├── ic_action_about.png
│ │ │ └── ic_action_edit.png
│ │ ├── drawable-xhdpi
│ │ │ ├── ic_launcher.png
│ │ │ ├── ic_action_edit.png
│ │ │ ├── ic_action_new.png
│ │ │ ├── ic_action_about.png
│ │ │ └── ic_action_refresh.png
│ │ ├── drawable-xxhdpi
│ │ │ ├── ic_launcher.png
│ │ │ ├── ic_action_new.png
│ │ │ ├── ic_action_about.png
│ │ │ ├── ic_action_edit.png
│ │ │ └── ic_action_refresh.png
│ │ ├── mipmap-xxhdpi
│ │ │ ├── ic_launcher.png
│ │ │ └── ic_launcher_round.png
│ │ ├── mipmap-xxxhdpi
│ │ │ ├── ic_launcher.png
│ │ │ └── ic_launcher_round.png
│ │ ├── values
│ │ │ ├── ic_launcher_background.xml
│ │ │ ├── dimens.xml
│ │ │ ├── styles.xml
│ │ │ └── array.xml
│ │ ├── layout
│ │ │ ├── main_spinner.xml
│ │ │ ├── spinner_item_settings.xml
│ │ │ ├── spinner_dropdown_item_settings.xml
│ │ │ ├── activity_qrshow.xml
│ │ │ ├── activity_qrscan.xml
│ │ │ ├── dialog_delete_door.xml
│ │ │ ├── activity_backup.xml
│ │ │ ├── dialog_ssh_passphrase.xml
│ │ │ ├── dialog_change_string.xml
│ │ │ ├── activity_license.xml
│ │ │ ├── activity_image.xml
│ │ │ ├── activity_main.xml
│ │ │ ├── activity_about.xml
│ │ │ ├── activity_abstract_certificate.xml
│ │ │ └── activity_abstract_client_keypair.xml
│ │ ├── mipmap-anydpi-v26
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ ├── anim
│ │ │ └── pressed.xml
│ │ ├── values-w820dp
│ │ │ └── dimens.xml
│ │ ├── menu
│ │ │ └── main.xml
│ │ └── values-in
│ │ │ └── strings.xml
│ │ ├── kotlin
│ │ └── app
│ │ │ └── trigger
│ │ │ ├── DoorStatus.kt
│ │ │ ├── OnTaskCompleted.kt
│ │ │ ├── DoorReply.kt
│ │ │ ├── mqtt
│ │ │ ├── MqttClientKeyPairActivity.kt
│ │ │ ├── MqttClientCertificateActivity.kt
│ │ │ └── MqttServerCertificateActivity.kt
│ │ │ ├── https
│ │ │ ├── HttpsClientKeyPairActivity.kt
│ │ │ ├── HttpsClientCertificateActivity.kt
│ │ │ ├── HttpsServerCertificateActivity.kt
│ │ │ ├── CertificateFetchTask.kt
│ │ │ ├── HttpsTools.kt
│ │ │ └── IgnoreExpirationTrustManager.kt
│ │ │ ├── AboutActivity.kt
│ │ │ ├── Log.kt
│ │ │ ├── BluetoothTools.kt
│ │ │ ├── ssh
│ │ │ ├── RegisterIdentityTask.kt
│ │ │ ├── GenerateIdentityTask.kt
│ │ │ ├── KeyPairBean.kt
│ │ │ └── SshTools.kt
│ │ │ ├── LicenseActivity.kt
│ │ │ ├── Door.kt
│ │ │ ├── nuki
│ │ │ ├── NukiReadLockStateCallback.kt
│ │ │ ├── NukiLockActionCallback.kt
│ │ │ ├── NukiCommand.kt
│ │ │ └── NukiCallback.kt
│ │ │ ├── BluetoothDoor.kt
│ │ │ ├── QRShowActivity.kt
│ │ │ ├── NukiDoor.kt
│ │ │ ├── bluetooth
│ │ │ └── BluetoothRequestHandler.kt
│ │ │ ├── BackupActivity.kt
│ │ │ ├── SshDoor.kt
│ │ │ ├── WifiTools.kt
│ │ │ ├── MqttDoor.kt
│ │ │ ├── HttpsDoor.kt
│ │ │ └── QRScanActivity.kt
│ │ └── AndroidManifest.xml
├── proguard-rules.pro
└── build.gradle
├── metadata
├── en-US
│ ├── changelogs
│ │ ├── 312.txt
│ │ ├── 211.txt
│ │ ├── 342.txt
│ │ ├── 336.txt
│ │ ├── 333.txt
│ │ ├── 344.txt
│ │ ├── 407.txt
│ │ ├── 204.txt
│ │ ├── 191.txt
│ │ ├── 205.txt
│ │ ├── 301.txt
│ │ ├── 322.txt
│ │ ├── 220.txt
│ │ ├── 222.txt
│ │ ├── 225.txt
│ │ ├── 190.txt
│ │ ├── 210.txt
│ │ ├── 313.txt
│ │ ├── 320.txt
│ │ ├── 334.txt
│ │ ├── 224.txt
│ │ ├── 310.txt
│ │ ├── 404.txt
│ │ ├── 202.txt
│ │ ├── 221.txt
│ │ ├── 403.txt
│ │ ├── 171.txt
│ │ ├── 200.txt
│ │ ├── 300.txt
│ │ ├── 321.txt
│ │ ├── 335.txt
│ │ ├── 180.txt
│ │ ├── 203.txt
│ │ ├── 331.txt
│ │ ├── 402.txt
│ │ ├── 340.txt
│ │ ├── 206.txt
│ │ ├── 311.txt
│ │ ├── 401.txt
│ │ ├── 406.txt
│ │ ├── 170.txt
│ │ ├── 192.txt
│ │ ├── 201.txt
│ │ ├── 332.txt
│ │ ├── 400.txt
│ │ ├── 343.txt
│ │ ├── 341.txt
│ │ ├── 223.txt
│ │ ├── 330.txt
│ │ └── 405.txt
│ ├── short_description.txt
│ ├── images
│ │ ├── icon.png
│ │ └── phoneScreenshots
│ │ │ ├── 01_setup.png
│ │ │ ├── 02_settings_https_part1.png
│ │ │ └── 03_settings_https_part2.png
│ └── full_description.txt
└── ru-RU
│ ├── short_description.txt
│ └── full_description.txt
├── docs
├── apk.png
├── fdroid.png
├── gplay.png
├── obtainium.png
├── screenshot_states.png
├── screenshot_door_types.png
├── screenshot_main_menu.png
├── screenshot_ssh_key_pair.png
├── screenshot_ssh_settings_part1.png
├── screenshot_ssh_settings_part2.png
├── screenshot_https_settings_part1.png
├── screenshot_https_settings_part2.png
├── screenshot_mqtt_settings_part1.png
├── screenshot_nuki_settings_part1.png
├── screenshot_bluetooth_settings_part1.png
├── screenshot_https_manage_tls_certificate.png
├── screenshots.md
└── documentation.md
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── .gitignore
├── settings.gradle
├── gradle.properties
├── gradlew.bat
├── README.md
└── gradlew
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/312.txt:
--------------------------------------------------------------------------------
1 | * add build flavors
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/211.txt:
--------------------------------------------------------------------------------
1 | * fix update of buttons
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/342.txt:
--------------------------------------------------------------------------------
1 | * fix broken WiFi SSID match
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/336.txt:
--------------------------------------------------------------------------------
1 | * fix HTTP Basic Authentication
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/333.txt:
--------------------------------------------------------------------------------
1 | * support old style SSH keypair value
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/344.txt:
--------------------------------------------------------------------------------
1 | * fix crash when there are two SSH doors
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/407.txt:
--------------------------------------------------------------------------------
1 | * really minor cleanup and fixes
2 |
--------------------------------------------------------------------------------
/docs/apk.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/docs/apk.png
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/204.txt:
--------------------------------------------------------------------------------
1 | * fix crash when sharing the screen space
--------------------------------------------------------------------------------
/metadata/en-US/short_description.txt:
--------------------------------------------------------------------------------
1 | Open doors via HTTPS/SSH/Bluetooth/MQTT.
--------------------------------------------------------------------------------
/docs/fdroid.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/docs/fdroid.png
--------------------------------------------------------------------------------
/docs/gplay.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/docs/gplay.png
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/191.txt:
--------------------------------------------------------------------------------
1 | * add camera permission
2 | * add package metadata
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/205.txt:
--------------------------------------------------------------------------------
1 | * fix "entry exists" error when saving setting
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/301.txt:
--------------------------------------------------------------------------------
1 | * update ssh library (jcraft.com/jsch - 0.1.54)
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/322.txt:
--------------------------------------------------------------------------------
1 | * fix crash on message display (Android 11 only)
--------------------------------------------------------------------------------
/docs/obtainium.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/docs/obtainium.png
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/220.txt:
--------------------------------------------------------------------------------
1 | * Add HTTP GET/PUT support
2 | * Improve Bluetooth support
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/222.txt:
--------------------------------------------------------------------------------
1 | * add support for Nuki SmartLock (pairing, lock, unlock)
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/225.txt:
--------------------------------------------------------------------------------
1 | * add option to allow https/ssh/mqtt to be used over the Internet
--------------------------------------------------------------------------------
/app/src/main/res/values-de/array.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/docs/screenshot_states.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/docs/screenshot_states.png
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/190.txt:
--------------------------------------------------------------------------------
1 | * custom status images
2 | * backup data
3 | * QR codes are smaller now
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/210.txt:
--------------------------------------------------------------------------------
1 | * add ring button for door bells
2 | * hide unused open/close/ring buttons
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/313.txt:
--------------------------------------------------------------------------------
1 | * add connected ssid to error message
2 | * make Android 10 read the SSID
--------------------------------------------------------------------------------
/metadata/ru-RU/short_description.txt:
--------------------------------------------------------------------------------
1 | Открывайте двери через Wi-Fi, используя HTTPS/SSH/Bluetooth/MQTT.
2 |
--------------------------------------------------------------------------------
/docs/screenshot_door_types.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/docs/screenshot_door_types.png
--------------------------------------------------------------------------------
/docs/screenshot_main_menu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/docs/screenshot_main_menu.png
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/320.txt:
--------------------------------------------------------------------------------
1 | * use new Android SDK version 30 and AndroidX
2 | * change file picker library
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/334.txt:
--------------------------------------------------------------------------------
1 | * fix some HTTPS hostname verification issues
2 | * make HTTP method explicit
--------------------------------------------------------------------------------
/metadata/en-US/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/metadata/en-US/images/icon.png
--------------------------------------------------------------------------------
/docs/screenshot_ssh_key_pair.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/docs/screenshot_ssh_key_pair.png
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/224.txt:
--------------------------------------------------------------------------------
1 | * drop permission access fine location
2 | * do not overwrite existing file on backup
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/310.txt:
--------------------------------------------------------------------------------
1 | * allow regex to match locked/unlocked message status of https, ssh, mqtt and bluetooth
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/404.txt:
--------------------------------------------------------------------------------
1 | * support separate HTTP method per query
2 | * replace some deprecated permission code
--------------------------------------------------------------------------------
/docs/screenshot_ssh_settings_part1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/docs/screenshot_ssh_settings_part1.png
--------------------------------------------------------------------------------
/docs/screenshot_ssh_settings_part2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/docs/screenshot_ssh_settings_part2.png
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/202.txt:
--------------------------------------------------------------------------------
1 | * allow to scan qr code with http/https/tcp/ssl/ssh link. Makes a "guess" about parameters.
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/221.txt:
--------------------------------------------------------------------------------
1 | * use username/password for MQTT
2 | * do not default to GET if http request method is invalid
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/403.txt:
--------------------------------------------------------------------------------
1 | * fix toobar and system status bar overlap (Android 15)
2 | * fix crash in the Nuki settings
--------------------------------------------------------------------------------
/app/src/main/res/drawable/state_open.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/app/src/main/res/drawable/state_open.png
--------------------------------------------------------------------------------
/docs/screenshot_https_settings_part1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/docs/screenshot_https_settings_part1.png
--------------------------------------------------------------------------------
/docs/screenshot_https_settings_part2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/docs/screenshot_https_settings_part2.png
--------------------------------------------------------------------------------
/docs/screenshot_mqtt_settings_part1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/docs/screenshot_mqtt_settings_part1.png
--------------------------------------------------------------------------------
/docs/screenshot_nuki_settings_part1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/docs/screenshot_nuki_settings_part1.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/state_closed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/app/src/main/res/drawable/state_closed.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/state_unknown.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/app/src/main/res/drawable/state_unknown.png
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/171.txt:
--------------------------------------------------------------------------------
1 | * register ssh public key (send to some address)
2 | * remove button for ssh key pair / https certificate
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/200.txt:
--------------------------------------------------------------------------------
1 | * add MQTT support
2 | * parse response the same way for every door type
3 | * remove refresh menu item
--------------------------------------------------------------------------------
/app/src/main/res/drawable/state_disabled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/app/src/main/res/drawable/state_disabled.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/docs/screenshot_bluetooth_settings_part1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/docs/screenshot_bluetooth_settings_part1.png
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/300.txt:
--------------------------------------------------------------------------------
1 | * add option to disable https certificate expiration
2 | * change app id to please the google play app store
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/321.txt:
--------------------------------------------------------------------------------
1 | * fix crash when selecting a background picture
2 | * fix crash when using the file selector on Android 11
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/335.txt:
--------------------------------------------------------------------------------
1 | * add support for HTTP Basic Authentication
2 | * e.g. https://user:password@example.com/open_door
3 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable-hdpi/ic_action_new.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/app/src/main/res/drawable-hdpi/ic_action_new.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-mdpi/ic_action_new.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/app/src/main/res/drawable-mdpi/ic_action_new.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/app/src/main/res/drawable-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/app/src/main/res/drawable-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/docs/screenshot_https_manage_tls_certificate.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/docs/screenshot_https_manage_tls_certificate.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-hdpi/ic_action_about.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/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/HEAD/app/src/main/res/drawable-hdpi/ic_action_edit.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-mdpi/ic_action_about.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/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/HEAD/app/src/main/res/drawable-mdpi/ic_action_edit.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_action_edit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/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/HEAD/app/src/main/res/drawable-xhdpi/ic_action_new.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/ic_action_new.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/app/src/main/res/drawable-xxhdpi/ic_action_new.png
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/180.txt:
--------------------------------------------------------------------------------
1 | * QR-code import/export
2 | * clone setups
3 | * fix update from previous versions
4 | * bluetooth support (untested!)
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/203.txt:
--------------------------------------------------------------------------------
1 | * fix missing german translation
2 | * fix error when setup name is already taken
3 | * fix upgrade error (not critical)
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_action_about.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/app/src/main/res/drawable-xhdpi/ic_action_about.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_action_refresh.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/app/src/main/res/drawable-xhdpi/ic_action_refresh.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/ic_action_about.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/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/HEAD/app/src/main/res/drawable-xxhdpi/ic_action_edit.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/331.txt:
--------------------------------------------------------------------------------
1 | * fix SSH_ORIGINAL_COMMAND for command in .ssh/authorized_keys
2 | * prevent quick sequential calls of the status command
--------------------------------------------------------------------------------
/metadata/en-US/images/phoneScreenshots/01_setup.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/metadata/en-US/images/phoneScreenshots/01_setup.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/ic_action_refresh.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/app/src/main/res/drawable-xxhdpi/ic_action_refresh.png
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/402.txt:
--------------------------------------------------------------------------------
1 | * make icon rounded
2 | * fix crash in for client-pair activity for HTTPS and MQTT
3 | * lots of small layout improvements
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFFFFF
4 |
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/340.txt:
--------------------------------------------------------------------------------
1 | * add MQTT support for TLS client/server certificates
2 | * "Wifi needed" now ignores Internet availability
3 | * fix deletion of key and certificates
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/206.txt:
--------------------------------------------------------------------------------
1 | * register ssh public key address does not need start with tcp://
2 | * allow ssh register endpoint to send back a response
3 | * disable text suggestions
--------------------------------------------------------------------------------
/metadata/en-US/images/phoneScreenshots/02_settings_https_part1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/metadata/en-US/images/phoneScreenshots/02_settings_https_part1.png
--------------------------------------------------------------------------------
/metadata/en-US/images/phoneScreenshots/03_settings_https_part2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mwarning/trigger/HEAD/metadata/en-US/images/phoneScreenshots/03_settings_https_part2.png
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/311.txt:
--------------------------------------------------------------------------------
1 | * fix some SSH key crashes and import issues
2 | * force SSID match setting if used
3 | * fix SSID matching on Android 10
4 | * use Android target SDK version 29
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/401.txt:
--------------------------------------------------------------------------------
1 | * update to SDK version 35 (Android 15)
2 | * remove support for deprecated SharedPreferences
3 | * caused major rewrite
4 | * fix UTF-8 character support for QR code export
5 |
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/406.txt:
--------------------------------------------------------------------------------
1 | * set door state to open/closed in case
2 | the open/close command is successful
3 | * this applies only if the reply patterns are set empty
4 | * translation improvements
5 |
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/170.txt:
--------------------------------------------------------------------------------
1 | * button press animation
2 | * fix item selection in drop-down list
3 | * fetch/import/export https certificate
4 | * ignore certificate host name mismatch (replaces "ignore errors")
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/192.txt:
--------------------------------------------------------------------------------
1 | * allow different ssh key sizes
2 | * store ssh keys in a more efficient format
3 | * always remove html tags from returned text
4 | * allow imported images to use entire screen width
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/201.txt:
--------------------------------------------------------------------------------
1 | * only auto select by ssid on app start or wifi reconnect
2 | * more translatable strings
3 | * prevent image from touching the buttons
4 | * change some button labels and positions
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/332.txt:
--------------------------------------------------------------------------------
1 | * add status patterns for MQTT
2 | * allow key and password authentication for SSH
3 | * call status call only once on connection change
4 | * make SSH execution timeout configurable
5 | * defaults to 5 seconds
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/400.txt:
--------------------------------------------------------------------------------
1 | * convert codebase to Kotlin
2 | * request bluetooth scan permissions for Android 12
3 | * support adaptive and monochrome launcher icon
4 | * fix require WiFi for SSH door setup
5 | * change license to GPL-3.0-or-later
6 |
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/343.txt:
--------------------------------------------------------------------------------
1 | * show dialog to temporarly disable wifi checks
2 | * add support for passphrase protected SSH keys
3 | * fix potential crash on Android 5 when scanning a QR code
4 | * make sure new setups (e.g. via QR code) have unique names
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/341.txt:
--------------------------------------------------------------------------------
1 | * HTTPS with server certificate and client certificate + key
2 | * fix TLS certificate activity titles (client vs. server certificate)
3 | * MQTT now uses mqtt:// and mqtts:// as link schemes
4 | * fix update to Trigger >=3.4.0 for TLS certificates
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/223.txt:
--------------------------------------------------------------------------------
1 | * SSH: fix hang in key register process
2 | * SSH: key register url defaults to host address
3 | * Nuki: show (read only) settings
4 | * Nuki: more error messages
5 | * HTTPS: show messages also in case of http error
6 | * show settings in summary field
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/330.txt:
--------------------------------------------------------------------------------
1 | * use Android storage framework
2 | * switch to SSH code from ConnectBot
3 | * adds support for ED25519 an others
4 | * allow SSH key import via QR-code
5 | * add option to ignore HTTPS certificte validity
6 | * allow export/import SSH keys via clipboard
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Wed Apr 12 08:38:00 CEST 2023
2 | distributionBase=GRADLE_USER_HOME
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
4 | distributionPath=wrapper/dists
5 | zipStorePath=wrapper/dists
6 | zipStoreBase=GRADLE_USER_HOME
7 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/rounded_corners.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/caches
5 | /.idea/libraries
6 | /.idea/modules.xml
7 | /.idea/workspace.xml
8 | /.idea/navEditor.xml
9 | /.idea/assetWizardSettings.xml
10 | .DS_Store
11 | /build
12 | /captures
13 | .externalNativeBuild
14 | .cxx
15 | local.properties
16 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/main_spinner.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/DoorStatus.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger
7 |
8 | // parsed door reply
9 | class DoorStatus(val code: StateCode, val message: String) {
10 | enum class StateCode {
11 | OPEN, CLOSED, UNKNOWN, DISABLED
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/spinner_item_settings.xml:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/OnTaskCompleted.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger
7 |
8 | import app.trigger.DoorReply.ReplyCode
9 |
10 | interface OnTaskCompleted {
11 | fun onTaskResult(setupId: Int, action: MainActivity.Action, code: ReplyCode, message: String)
12 | }
13 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/anim/pressed.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/values-w820dp/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 | 64dp
6 |
7 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | gradlePluginPortal()
4 | google()
5 | mavenCentral()
6 | }
7 | }
8 | dependencyResolutionManagement {
9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
10 | repositories {
11 | google()
12 | mavenCentral()
13 | }
14 | }
15 | rootProject.name = "Trigger"
16 | include ':app'
17 |
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/405.txt:
--------------------------------------------------------------------------------
1 | * fix storage of certificates
2 | * show default images in image selection
3 | * fix MQTT door certificate settings storage
4 | * add Russian translation, thanks tokito@gmail.com
5 | * add Indonesian translation, thanks yurtpage+translate@gmail.com
6 | * improve German translation, thanks moritzwarning@web.de
7 | * replace remaining deprecated permission code
8 | * make more strings translatable
9 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/spinner_dropdown_item_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 16dp
4 | 16dp
5 |
6 | 10dp
7 | 20dp
8 | 50dp
9 |
10 | 16sp
11 | 18sp
12 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_ring.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
--------------------------------------------------------------------------------
/metadata/en-US/full_description.txt:
--------------------------------------------------------------------------------
1 | Open and close doors, ring door bells and see the door state.
2 | Supported HTTPS/SSH/MQTT/Bluetooth and the Nuki SmartLock.
3 |
4 | Features:
5 |
6 | * Support of HTTPS, SSH, Bluetooth and MQTT.
7 | * Support for Nuki SmartLock.
8 | * Multiple door profiles.
9 | * Auto select profiles by a SSID of connected WiFi.
10 | * HTTPS server/client certificate support.
11 | * SSH key generation (ED25519, RSA) with a passphrase.
12 | * Custom status images.
13 | * QR code support.
14 | * Support of backup.
15 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_action_show_qr.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/DoorReply.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger
7 |
8 | // reply from door
9 | class DoorReply(val action: MainActivity.Action, val code: ReplyCode, val message: String) {
10 | enum class ReplyCode {
11 | LOCAL_ERROR, // could establish a connection for some reason
12 | REMOTE_ERROR, // the door send some error
13 | SUCCESS, // the door send some message that has yet to be parsed
14 | DISABLED // Internet, WiFi or Bluetooth disabled or not supported
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
13 |
14 |
--------------------------------------------------------------------------------
/metadata/ru-RU/full_description.txt:
--------------------------------------------------------------------------------
1 | Открыть или закрыть двери, звонить и посмотреть состояние двери.
2 | Поддерживаются общие HTTPS/SSH/MQTT/Bluetooth и Nuki SmartLock.
3 |
4 | Возможности:
5 |
6 | - Поддержка HTTPS, SSH, Bluetooth и MQTT.
7 | - Поддержка Nuki SmartLock.
8 | - Многодверные профили.
9 | - Автоматически выбирать профили по имени подсоединёной сети WiFi.
10 | - Поддержка HTTPS серверного или клиентского сертификата.
11 | - Генерация SSH ключей (ED25519, RSA) с парольными фразами.
12 | - Пользовательские изображения состояния двери.
13 | - Поддержка QR кодов.
14 | - Резервная копия.
15 |
16 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_action_scan_qr.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
--------------------------------------------------------------------------------
/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/res/drawable/trigger_logo.xml:
--------------------------------------------------------------------------------
1 |
6 |
11 |
17 |
18 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_unlock.xml:
--------------------------------------------------------------------------------
1 |
7 |
11 |
15 |
16 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_lock.xml:
--------------------------------------------------------------------------------
1 |
7 |
11 |
15 |
16 |
--------------------------------------------------------------------------------
/docs/screenshots.md:
--------------------------------------------------------------------------------
1 | # Screenshots
2 |
3 | A collection of screenshots of Trigger.
4 |
5 | ## General
6 |
7 |
8 |
9 | ## HTTPS Door
10 |
11 |
12 |
13 | ## SSH Door
14 |
15 |
16 |
17 | ## MQTT Door
18 |
19 |
20 |
21 | ## Bluetooth/BLE Door
22 |
23 |
24 |
25 | ## Nuki Door
26 |
27 |
28 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
6 |
10 |
15 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/mqtt/MqttClientKeyPairActivity.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger.mqtt
7 |
8 | import android.os.Bundle
9 | import app.trigger.ssh.KeyPairBean
10 | import app.trigger.AbstractClientKeyPairActivity
11 | import app.trigger.MqttDoor
12 | import app.trigger.SetupActivity
13 |
14 |
15 | class MqttClientKeyPairActivity : AbstractClientKeyPairActivity() {
16 | private lateinit var mqttDoor: MqttDoor
17 |
18 | override fun getKeyPair(): KeyPairBean? {
19 | return mqttDoor.client_keypair
20 | }
21 |
22 | override fun setKeyPair(keyPair: KeyPairBean?) {
23 | mqttDoor.client_keypair = keyPair
24 | }
25 |
26 | override fun onCreate(savedInstanceState: Bundle?) {
27 | if (SetupActivity.currentDoor is MqttDoor) {
28 | mqttDoor = SetupActivity.currentDoor as MqttDoor
29 | } else {
30 | // not expected to happen
31 | finish()
32 | return
33 | }
34 | super.onCreate(savedInstanceState)
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/https/HttpsClientKeyPairActivity.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger.https
7 |
8 | import android.os.Bundle
9 | import app.trigger.ssh.KeyPairBean
10 | import app.trigger.AbstractClientKeyPairActivity
11 | import app.trigger.HttpsDoor
12 | import app.trigger.SetupActivity
13 |
14 |
15 | class HttpsClientKeyPairActivity : AbstractClientKeyPairActivity() {
16 | private lateinit var httpsDoor: HttpsDoor
17 |
18 | override fun getKeyPair(): KeyPairBean? {
19 | return httpsDoor.client_keypair
20 | }
21 |
22 | override fun setKeyPair(keyPair: KeyPairBean?) {
23 | httpsDoor.client_keypair = keyPair
24 | }
25 |
26 | override fun onCreate(savedInstanceState: Bundle?) {
27 | if (SetupActivity.currentDoor is HttpsDoor) {
28 | httpsDoor = SetupActivity.currentDoor as HttpsDoor
29 | } else {
30 | // not expected to happen
31 | finish()
32 | return
33 | }
34 | super.onCreate(savedInstanceState)
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_qrshow.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
15 |
16 |
20 |
21 |
22 |
23 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/AboutActivity.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger
7 |
8 | import android.content.Intent
9 | import android.os.Bundle
10 | import android.widget.TextView
11 | import androidx.appcompat.app.AppCompatActivity
12 | import androidx.appcompat.widget.Toolbar
13 |
14 | class AboutActivity : AppCompatActivity() {
15 | override fun onCreate(savedInstanceState: Bundle?) {
16 | super.onCreate(savedInstanceState)
17 | setContentView(R.layout.activity_about)
18 | title = getString(R.string.menu_about)
19 |
20 | val toolbar = findViewById(R.id.toolbar)
21 | setSupportActionBar(toolbar)
22 |
23 | findViewById(R.id.versionTv).text = if (BuildConfig.DEBUG) {
24 | BuildConfig.VERSION_NAME + " (debug)"
25 | } else {
26 | BuildConfig.VERSION_NAME
27 | }
28 |
29 | findViewById(R.id.licenseTV).setOnClickListener {
30 | val intent = Intent(this, LicenseActivity::class.java)
31 | startActivity(intent)
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_qrscan.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
15 |
16 |
20 |
21 |
22 |
23 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/Log.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger
7 |
8 | import android.util.Log
9 |
10 | /*
11 | * Wrapper for android.util.Log to disable logging
12 | */
13 | object Log {
14 | private fun contextString(context: Any): String {
15 | return if (context is String) {
16 | context
17 | } else {
18 | context.javaClass.simpleName
19 | }
20 | }
21 |
22 | fun d(context: Any, message: String) {
23 | if (BuildConfig.DEBUG) {
24 | val tag = contextString(context)
25 | Log.d(tag, message)
26 | }
27 | }
28 |
29 | fun w(context: Any, message: String) {
30 | if (BuildConfig.DEBUG) {
31 | val tag = contextString(context)
32 | Log.w(tag, message)
33 | }
34 | }
35 |
36 | fun i(context: Any, message: String) {
37 | if (BuildConfig.DEBUG) {
38 | val tag = contextString(context)
39 | Log.i(tag, message)
40 | }
41 | }
42 |
43 | fun e(context: Any, message: String) {
44 | if (BuildConfig.DEBUG) {
45 | val tag = contextString(context)
46 | Log.e(tag, message)
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/mqtt/MqttClientCertificateActivity.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger.mqtt
7 |
8 | import android.os.Bundle
9 | import app.trigger.AbstractCertificateActivity
10 | import app.trigger.Door
11 | import app.trigger.MqttDoor
12 | import app.trigger.SetupActivity
13 | import java.security.cert.Certificate
14 |
15 | class MqttClientCertificateActivity : AbstractCertificateActivity() {
16 | private lateinit var mqttDoor: MqttDoor
17 |
18 | override fun getDoor(): Door {
19 | return mqttDoor
20 | }
21 |
22 | override fun getCertificate(): Certificate? {
23 | return mqttDoor.client_certificate
24 | }
25 |
26 | override fun setCertificate(certificate: Certificate?) {
27 | mqttDoor.client_certificate = certificate
28 | }
29 |
30 | override fun onCreate(savedInstanceState: Bundle?) {
31 | if (SetupActivity.currentDoor is MqttDoor) {
32 | mqttDoor = SetupActivity.currentDoor as MqttDoor
33 | } else {
34 | // not expected to happen
35 | finish()
36 | return
37 | }
38 | super.onCreate(savedInstanceState)
39 | }
40 |
41 | companion object {
42 | private const val TAG = "MqttClientCertificateActivity"
43 | }
44 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/mqtt/MqttServerCertificateActivity.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger.mqtt
7 |
8 | import android.os.Bundle
9 | import app.trigger.AbstractCertificateActivity
10 | import app.trigger.Door
11 | import app.trigger.MqttDoor
12 | import app.trigger.SetupActivity
13 | import java.security.cert.Certificate
14 |
15 | class MqttServerCertificateActivity : AbstractCertificateActivity() {
16 | private lateinit var mqttDoor: MqttDoor
17 |
18 | override fun getDoor(): Door {
19 | return mqttDoor
20 | }
21 |
22 | override fun getCertificate(): Certificate? {
23 | return mqttDoor.server_certificate
24 | }
25 |
26 | override fun setCertificate(certificate: Certificate?) {
27 | mqttDoor.server_certificate = certificate
28 | }
29 |
30 | override fun onCreate(savedInstanceState: Bundle?) {
31 | if (SetupActivity.currentDoor is MqttDoor) {
32 | mqttDoor = SetupActivity.currentDoor as MqttDoor
33 | } else {
34 | // not expected to happen
35 | finish()
36 | return
37 | }
38 | super.onCreate(savedInstanceState)
39 | }
40 |
41 | companion object {
42 | private const val TAG = "MqttServerCertificateActivity"
43 | }
44 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/https/HttpsClientCertificateActivity.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger.https
7 |
8 | import android.os.Bundle
9 | import app.trigger.AbstractCertificateActivity
10 | import app.trigger.Door
11 | import app.trigger.HttpsDoor
12 | import app.trigger.SetupActivity
13 | import java.security.cert.Certificate
14 |
15 | class HttpsClientCertificateActivity : AbstractCertificateActivity() {
16 | private lateinit var httpsDoor: HttpsDoor
17 |
18 | override fun getDoor(): Door {
19 | return httpsDoor
20 | }
21 |
22 | override fun getCertificate(): Certificate? {
23 | return httpsDoor.client_certificate
24 | }
25 |
26 | override fun setCertificate(certificate: Certificate?) {
27 | httpsDoor.client_certificate = certificate
28 | }
29 |
30 | override fun onCreate(savedInstanceState: Bundle?) {
31 | if (SetupActivity.currentDoor is HttpsDoor) {
32 | httpsDoor = SetupActivity.currentDoor as HttpsDoor
33 | } else {
34 | // not expected to happen
35 | finish()
36 | return
37 | }
38 | super.onCreate(savedInstanceState)
39 | }
40 |
41 | companion object {
42 | private const val TAG = "HttpsClientCertificateActivity"
43 | }
44 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/https/HttpsServerCertificateActivity.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger.https
7 |
8 | import android.os.Bundle
9 | import app.trigger.AbstractCertificateActivity
10 | import app.trigger.Door
11 | import app.trigger.HttpsDoor
12 | import app.trigger.SetupActivity
13 | import java.security.cert.Certificate
14 |
15 | class HttpsServerCertificateActivity : AbstractCertificateActivity() {
16 | private lateinit var httpsDoor: HttpsDoor
17 |
18 | override fun getDoor(): Door {
19 | return httpsDoor
20 | }
21 |
22 | override fun getCertificate(): Certificate? {
23 | return httpsDoor.server_certificate
24 | }
25 |
26 | override fun setCertificate(certificate: Certificate?) {
27 | httpsDoor.server_certificate = certificate
28 | }
29 |
30 | override fun onCreate(savedInstanceState: Bundle?) {
31 | if (SetupActivity.currentDoor is HttpsDoor) {
32 | httpsDoor = SetupActivity.currentDoor as HttpsDoor
33 | } else {
34 | // not expected to happen
35 | finish()
36 | return
37 | }
38 | super.onCreate(savedInstanceState)
39 | }
40 |
41 | companion object {
42 | private const val TAG = "HttpsServerCertificateActivity"
43 | }
44 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app"s APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
24 | android.nonFinalResIds=false
--------------------------------------------------------------------------------
/app/src/main/res/menu/main.xml:
--------------------------------------------------------------------------------
1 |
39 |
--------------------------------------------------------------------------------
/app/src/main/res/values-in/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Tentang aplikasi
4 | Cadangan
5 | Duplikasi
6 | Sunting
7 | Ekspor
8 | Impor
9 | Baru
10 | Pindai code QR
11 | Tampilkan Kode QR
12 | Keterangan:
13 | Lisensi:
14 | Versi:
15 | Membatalkan
16 | Hapus
17 | Dari papan klip
18 | OK
19 | Simpan
20 | Membatalkan
21 | Sertifikat
22 | URL sertifikat
23 | Papan klip kosong
24 | Mengonfirmasi
25 | Selesai
26 | Kesalahan
27 | Ekspor
28 | Impor
29 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/dialog_delete_door.xml:
--------------------------------------------------------------------------------
1 |
5 |
6 |
15 |
16 |
22 |
23 |
30 |
31 |
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/app/src/main/res/values/array.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | - Generic HTTPS
5 | - Generic SSH
6 | - Generic MQTT
7 | - Generic Bluetooth
8 | - Nuki SmartLock
9 |
10 |
11 |
12 | - HttpsDoorSetup
13 | - SshDoorSetup
14 | - MqttDoorSetup
15 | - BluetoothDoorSetup
16 | - NukiDoorSetup
17 |
18 |
19 |
20 | - GET
21 | - POST
22 | - PUT
23 |
24 |
25 |
26 | - GET
27 | - POST
28 | - PUT
29 |
30 |
31 |
32 | - ED25519
33 | - ECDSA (nistp384)
34 | - ECDSA (nistp521)
35 | - RSA (2048)
36 | - RSA (4096)
37 |
38 |
39 |
40 | - Fire and Forget (0)
41 | - At least once (1)
42 | - Exactly once (2)
43 |
44 |
45 |
46 | - 0
47 | - 1
48 | - 2
49 |
50 |
51 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_backup.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
14 |
15 |
19 |
20 |
21 |
22 |
28 |
29 |
35 |
36 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/dialog_ssh_passphrase.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
18 |
19 |
24 |
25 |
29 |
30 |
37 |
38 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/BluetoothTools.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger
7 |
8 | import android.bluetooth.BluetoothAdapter
9 | import android.bluetooth.BluetoothDevice
10 | import android.bluetooth.BluetoothSocket
11 | import android.content.Context
12 | import java.lang.reflect.InvocationTargetException
13 |
14 | object BluetoothTools {
15 | private const val TAG = "BluetoothTools"
16 | private var adapter: BluetoothAdapter? = null
17 |
18 | fun init(context: Context?) {
19 | adapter = BluetoothAdapter.getDefaultAdapter()
20 | }
21 |
22 | fun isEnabled(): Boolean {
23 | return adapter != null && adapter!!.isEnabled
24 | }
25 |
26 | fun isSupported(): Boolean {
27 | return adapter != null
28 | }
29 |
30 | fun createRfcommSocket(device: BluetoothDevice): BluetoothSocket? {
31 | var tmp: BluetoothSocket? = null
32 | try {
33 | val class1: Class<*> = device.javaClass
34 | val aclass: Array> = arrayOf(Integer.TYPE as Class<*>)
35 | val method = class1.getMethod("createRfcommSocket", *aclass)
36 | val aobj = arrayOfNulls(1)
37 | aobj[0] = Integer.valueOf(1)
38 | tmp = method.invoke(device, *aobj) as BluetoothSocket
39 | } catch (e: NoSuchMethodException) {
40 | e.printStackTrace()
41 | Log.e(TAG, "createRfcommSocket() failed $e")
42 | } catch (e: InvocationTargetException) {
43 | e.printStackTrace()
44 | Log.e(TAG, "createRfcommSocket() failed $e")
45 | } catch (e: IllegalAccessException) {
46 | e.printStackTrace()
47 | Log.e(TAG, "createRfcommSocket() failed $e")
48 | }
49 | return tmp
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/ssh/RegisterIdentityTask.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger.ssh
7 |
8 | import app.trigger.Utils.readInputStreamWithTimeout
9 | import app.trigger.Utils.rebuildAddress
10 | import app.trigger.Utils.createSocketAddress
11 | import java.io.DataOutputStream
12 | import java.lang.Exception
13 | import java.net.Socket
14 |
15 | internal class RegisterIdentityTask(private val listener: OnTaskCompleted, private val address: String, private val keypair: KeyPairBean) : Thread() {
16 | interface OnTaskCompleted {
17 | fun onRegisterIdentityTaskCompleted(message: String?)
18 | }
19 |
20 | override fun run() {
21 | try {
22 | val addr = createSocketAddress(
23 | rebuildAddress(address, 0)
24 | )
25 | if (addr.port == 0) {
26 | listener.onRegisterIdentityTaskCompleted("Missing port, use :")
27 | return
28 | }
29 | val client = Socket(addr.address, addr.port)
30 | val os = client.getOutputStream()
31 | val `is` = client.getInputStream()
32 | val writer = DataOutputStream(os)
33 |
34 | // send public key in PEM format
35 | os.write(keypair.openSSHPublicKey!!.toByteArray())
36 | os.flush()
37 | val reply = readInputStreamWithTimeout(`is`, 1024, 1000)
38 | client.close()
39 | if (reply.isNotEmpty()) {
40 | listener.onRegisterIdentityTaskCompleted(reply)
41 | } else {
42 | listener.onRegisterIdentityTaskCompleted("Done")
43 | }
44 | } catch (e: Exception) {
45 | listener.onRegisterIdentityTaskCompleted(e.toString())
46 | }
47 | }
48 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/LicenseActivity.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger
7 |
8 | import android.os.Bundle
9 | import android.view.View
10 | import android.widget.ProgressBar
11 | import android.widget.TextView
12 | import androidx.appcompat.app.AppCompatActivity
13 | import androidx.appcompat.widget.Toolbar
14 | import java.io.BufferedReader
15 | import java.io.IOException
16 | import java.io.InputStreamReader
17 |
18 | class LicenseActivity : AppCompatActivity() {
19 | override fun onCreate(savedInstanceState: Bundle?) {
20 | super.onCreate(savedInstanceState)
21 | setContentView(R.layout.activity_license)
22 | title = getString(R.string.title_license)
23 |
24 | val toolbar = findViewById(R.id.toolbar)
25 | setSupportActionBar(toolbar)
26 |
27 | // reading the license file can be slow => use a thread
28 | Thread {
29 | try {
30 | val buffer = StringBuffer()
31 | val reader = BufferedReader(InputStreamReader(assets.open("license.txt")))
32 | while (reader.ready()) {
33 | val line = reader.readLine()
34 | if (line != null) {
35 | if (line.trim().isEmpty()){
36 | buffer.append("\n")
37 | } else {
38 | buffer.append(line + "\n")
39 | }
40 | } else {
41 | break
42 | }
43 | }
44 | reader.close()
45 | runOnUiThread {
46 | findViewById(R.id.licenseLoadingBar).visibility = View.GONE
47 | findViewById(R.id.licenceText).text = buffer.toString()
48 | }
49 | } catch (e: IOException) {
50 | e.printStackTrace()
51 | }
52 | }.start()
53 | }
54 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/dialog_change_string.xml:
--------------------------------------------------------------------------------
1 |
5 |
6 |
14 |
15 |
26 |
27 |
33 |
34 |
41 |
42 |
49 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_license.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
15 |
16 |
20 |
21 |
22 |
23 |
28 |
29 |
34 |
35 |
40 |
41 |
50 |
51 |
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/Door.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger
7 |
8 | import android.graphics.Bitmap
9 | import app.trigger.DoorStatus.StateCode
10 |
11 | abstract class Door {
12 | // internal id
13 | abstract var id: Int
14 |
15 | // Name of this setup for dropdown menu
16 | abstract var name: String
17 |
18 | // Door mechanism type name
19 | abstract val type: String
20 |
21 | var open_image: Bitmap? = null
22 | var closed_image: Bitmap? = null
23 | var unknown_image: Bitmap? = null
24 | var disabled_image: Bitmap? = null
25 |
26 | // Select setup entry from dropdown if it
27 | // matches any of these SSIDs (comma separated)
28 | abstract fun getWiFiSSIDs(): String
29 |
30 | // only applies for HTTPS, SSH and MQTT so far
31 | abstract fun getWiFiRequired(): Boolean
32 |
33 | // Get image dependent of the door state
34 | //fun getStateImage(state: StateCode?): Bitmap?
35 | fun getStateImage(state: StateCode?): Bitmap? {
36 | return when (state) {
37 | StateCode.OPEN -> open_image
38 | StateCode.CLOSED -> closed_image
39 | StateCode.DISABLED -> disabled_image
40 | StateCode.UNKNOWN -> unknown_image
41 | else -> null
42 | }
43 | }
44 |
45 | fun setStateImage(state: StateCode, bitmap: Bitmap?) {
46 | when (state) {
47 | StateCode.OPEN -> open_image = bitmap
48 | StateCode.CLOSED -> closed_image = bitmap
49 | StateCode.DISABLED -> disabled_image = bitmap
50 | StateCode.UNKNOWN -> unknown_image = bitmap
51 | }
52 | }
53 |
54 | // URL to fetch a https certificate from
55 | // or to send a ssh public key for registration
56 | open fun getRegisterUrl(): String = ""
57 |
58 | // Parse the text reply from
59 | // the door and determine state
60 | abstract fun parseReply(reply: DoorReply): DoorStatus
61 |
62 | // To show/hide respective buttons
63 | abstract fun isActionSupported(action: MainActivity.Action): Boolean
64 | }
65 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/https/CertificateFetchTask.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger.https
7 |
8 | import android.os.AsyncTask
9 | import app.trigger.Log
10 | import java.lang.Exception
11 | import java.net.URL
12 | import java.security.cert.Certificate
13 | import javax.net.ssl.HttpsURLConnection
14 |
15 | class CertificateFetchTask(private val listener: OnTaskCompleted) : AsyncTask() {
16 | interface OnTaskCompleted {
17 | fun onCertificateFetchTaskCompleted(result: Result)
18 | }
19 |
20 | class Result internal constructor(var certificate: Certificate?, var error: String)
21 |
22 | override fun doInBackground(vararg params: Any?): Result {
23 | if (params.size != 1) {
24 | Log.e(TAG, "Unexpected number of params.")
25 | return Result(null, "Internal Error")
26 | }
27 | return try {
28 | var url = URL(params[0] as String)
29 |
30 | // try to establish TLS session only
31 | val port = if (url.port > 0) url.port else url.defaultPort
32 | url = URL("https", url.host, port, "")
33 |
34 | // disable all certification checks
35 | HttpsTools.disableDefaultHostnameVerifier()
36 | HttpsTools.disableDefaultCertificateValidation()
37 | val con = url.openConnection() as HttpsURLConnection
38 | con.connectTimeout = 2000
39 | con.connect()
40 | val certificates = con.serverCertificates
41 | con.disconnect()
42 | if (certificates.size == 0) {
43 | Result(null, "No certificate found.")
44 | } else {
45 | Result(certificates[0], "")
46 | }
47 | } catch (e: Exception) {
48 | Result(null, e.toString())
49 | }
50 | }
51 |
52 | override fun onPostExecute(result: Result) {
53 | listener.onCertificateFetchTaskCompleted(result)
54 | }
55 |
56 | companion object {
57 | const val TAG = "CertificateFetchTask"
58 | }
59 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_image.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
15 |
16 |
20 |
21 |
22 |
23 |
30 |
31 |
38 |
39 |
46 |
47 |
54 |
55 |
62 |
63 |
64 |
65 |
66 |
--------------------------------------------------------------------------------
/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 408
12 | versionName "4.0.8"
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 |
35 | dependenciesInfo {
36 | // Disables dependency metadata when building APKs.
37 | includeInApk = false
38 | // Disables dependency metadata when building Android App Bundles.
39 | includeInBundle = false
40 | }
41 |
42 | buildFeatures {
43 | buildConfig = true
44 | }
45 |
46 | compileOptions {
47 | sourceCompatibility = JavaVersion.VERSION_17
48 | targetCompatibility = JavaVersion.VERSION_17
49 | }
50 |
51 | kotlinOptions {
52 | jvmTarget = "17"
53 | }
54 |
55 | namespace 'app.trigger'
56 | }
57 |
58 | dependencies {
59 | implementation 'org.conscrypt:conscrypt-android:2.5.2'
60 | implementation 'org.connectbot:sshlib:2.2.19'
61 | implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5'
62 | implementation('com.journeyapps:zxing-android-embedded:4.3.0') { transitive = false }
63 | implementation 'androidx.documentfile:documentfile:1.0.1'
64 | implementation 'com.github.joshjdevl.libsodiumjni:libsodium-jni-aar:2.0.2'
65 | implementation 'com.google.zxing:core:3.4.1'
66 | implementation 'androidx.core:core-ktx:1.15.0'
67 | implementation 'androidx.appcompat:appcompat:1.7.0'
68 | implementation 'com.google.android.material:material:1.12.0'
69 | implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
70 | implementation 'androidx.navigation:navigation-fragment-ktx:2.8.5'
71 | implementation 'androidx.navigation:navigation-ui-ktx:2.8.5'
72 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
73 | implementation 'androidx.preference:preference-ktx:1.2.1'
74 | testImplementation 'junit:junit:4.13.2'
75 | androidTestImplementation 'androidx.test.ext:junit:1.2.1'
76 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
77 | }
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/nuki/NukiReadLockStateCallback.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger.nuki
7 |
8 | import app.trigger.Utils.byteArrayToHexString
9 | import app.trigger.Utils.hexStringToByteArray
10 | import app.trigger.DoorReply.ReplyCode
11 | import android.bluetooth.BluetoothGattCharacteristic
12 | import android.bluetooth.BluetoothGatt
13 | import app.trigger.*
14 |
15 | internal class NukiReadLockStateCallback(door_id: Int, action: MainActivity.Action, listener: OnTaskCompleted, setup: NukiDoor)
16 | : NukiCallback(door_id, action, listener, KEYTURNER_SERVICE_UUID, KEYTURNER_USDIO_XTERISTIC_UUID) {
17 | var auth_id: Long
18 | var shared_key: ByteArray
19 | var data: ByteArray = ByteArray(0)
20 |
21 | override fun onConnected(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
22 | Log.d(TAG, "onConnected")
23 | val nr = NukiCommand.NukiRequest(0x0c)
24 | val request: ByteArray? = NukiRequestHandler.encrypt_message(shared_key, auth_id, nr.generate(), null)
25 | characteristic.value = request
26 | val ok = gatt.writeCharacteristic(characteristic)
27 | if (!ok) {
28 | Log.e(TAG, "initial writeCharacteristic failed")
29 | closeConnection(gatt)
30 | }
31 | }
32 |
33 | override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
34 | Log.d(TAG, "onCharacteristicChanged, uiid: ${characteristic.uuid}: ${byteArrayToHexString(characteristic.value)}")
35 | data = if (data == null) {
36 | characteristic.value
37 | } else {
38 | NukiTools.concat(data, characteristic.value)
39 | }
40 | val message: ByteArray? = NukiRequestHandler.decrypt_message(shared_key, data)
41 | val m: NukiCommand? = NukiRequestHandler.parse(message)
42 | if (m == null) {
43 | Log.d(TAG, "NukiCommand is null")
44 | return
45 | } else {
46 | data = ByteArray(0)
47 | }
48 |
49 | if (m is NukiCommand.NukiStates) {
50 | val ns = m
51 | var extra = ""
52 | if (ns.battery_critical == 0x01) {
53 | extra = " (Battery Critical!)"
54 | }
55 | listener.onTaskResult(
56 | door_id, action, ReplyCode.SUCCESS, NukiTools.getLockState(ns.lock_state) + extra
57 | )
58 |
59 | // do not wait until the Nuki closes the connection
60 | closeConnection(gatt)
61 | } else if (m is NukiCommand.NukiError) {
62 | listener.onTaskResult(door_id, action, ReplyCode.REMOTE_ERROR, m.asString())
63 | closeConnection(gatt)
64 | } else {
65 | Log.e(TAG, "Unhandled command.")
66 | closeConnection(gatt)
67 | }
68 | }
69 |
70 | companion object {
71 | private const val TAG = "ReadLockStateCallback"
72 | }
73 |
74 | init {
75 | shared_key = hexStringToByteArray(setup.shared_key)
76 | auth_id = setup.auth_id
77 | }
78 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
14 |
15 |
19 |
20 |
21 |
22 |
27 |
28 |
36 |
37 |
43 |
44 |
55 |
56 |
67 |
68 |
79 |
80 |
81 |
82 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/BluetoothDoor.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger
7 |
8 | import org.json.JSONObject
9 |
10 |
11 | class BluetoothDoor(override var id: Int, override var name: String) : Door() {
12 | override val type = Companion.TYPE
13 | var device_name = ""
14 | var service_uuid = ""
15 | var open_query = ""
16 | var close_query = ""
17 | var ring_query = ""
18 | var status_query = ""
19 | var locked_pattern = ""
20 | var unlocked_pattern = ""
21 |
22 | override fun getWiFiSSIDs(): String = ""
23 | override fun getWiFiRequired(): Boolean = false
24 |
25 | override fun parseReply(reply: DoorReply): DoorStatus {
26 | return Utils.genericDoorReplyParser(reply, unlocked_pattern, locked_pattern)
27 | }
28 |
29 | override fun isActionSupported(action: MainActivity.Action): Boolean {
30 | return when (action) {
31 | MainActivity.Action.OPEN_DOOR -> open_query.isNotEmpty()
32 | MainActivity.Action.CLOSE_DOOR -> close_query.isNotEmpty()
33 | MainActivity.Action.RING_DOOR -> ring_query.isNotEmpty()
34 | MainActivity.Action.FETCH_STATE -> status_query.isNotEmpty()
35 | }
36 | }
37 |
38 | fun toJSONObject(): JSONObject {
39 | val obj = JSONObject()
40 | obj.put("id", id)
41 | obj.put("name", name)
42 | obj.put("type", type)
43 |
44 | obj.put("device_name", device_name)
45 | obj.put("service_uuid", service_uuid)
46 | obj.put("open_query", open_query)
47 | obj.put("close_query", close_query)
48 | obj.put("ring_query", ring_query)
49 | obj.put("locked_pattern", locked_pattern)
50 | obj.put("unlocked_pattern", unlocked_pattern)
51 | obj.put("status_query", status_query)
52 |
53 | obj.put("open_image", Utils.serializeBitmap(open_image))
54 | obj.put("closed_image", Utils.serializeBitmap(closed_image))
55 | obj.put("unknown_image", Utils.serializeBitmap(unknown_image))
56 | obj.put("disabled_image", Utils.serializeBitmap(disabled_image))
57 |
58 | return obj
59 | }
60 |
61 | companion object {
62 | const val TYPE = "BluetoothDoorSetup"
63 |
64 | fun fromJSONObject(obj: JSONObject): BluetoothDoor {
65 | val id = obj.getInt("id")
66 | val name = obj.getString("name")
67 | val setup = BluetoothDoor(id, name)
68 |
69 | setup.device_name = obj.optString("device_name", "")
70 | setup.service_uuid = obj.optString("service_uuid", "")
71 | setup.open_query = obj.optString("open_query", "")
72 | setup.close_query = obj.optString("close_query", "")
73 | setup.ring_query = obj.optString("ring_query", "")
74 | setup.locked_pattern = obj.optString("locked_pattern", "")
75 | setup.unlocked_pattern = obj.optString("unlocked_pattern", "")
76 | setup.status_query = obj.optString("status_query", "")
77 |
78 | setup.open_image = Utils.deserializeBitmap(obj.optString("open_image", ""))
79 | setup.closed_image = Utils.deserializeBitmap(obj.optString("closed_image", ""))
80 | setup.unknown_image = Utils.deserializeBitmap(obj.optString("unknown_image", ""))
81 | setup.disabled_image = Utils.deserializeBitmap(obj.optString("disabled_image", ""))
82 |
83 | return setup
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/QRShowActivity.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger
7 |
8 | import android.os.Bundle
9 | import android.widget.ImageView
10 | import android.widget.Toast
11 | import androidx.appcompat.app.AppCompatActivity
12 | import androidx.appcompat.widget.Toolbar
13 | import com.google.zxing.BarcodeFormat
14 | import com.google.zxing.EncodeHintType
15 | import com.google.zxing.MultiFormatWriter
16 | import com.google.zxing.WriterException
17 | import com.journeyapps.barcodescanner.BarcodeEncoder
18 | import org.json.JSONObject
19 |
20 | class QRShowActivity : AppCompatActivity() {
21 | override fun onCreate(savedInstanceState: Bundle?) {
22 | super.onCreate(savedInstanceState)
23 | setContentView(R.layout.activity_qrshow)
24 |
25 | val toolbar = findViewById(R.id.toolbar)
26 | setSupportActionBar(toolbar)
27 |
28 | val door_id = intent.getIntExtra("door_id", -1)
29 | val door = Settings.getDoor(door_id)
30 | if (door != null) {
31 | title = "$title: ${door.name}"
32 | try {
33 | generateQR(door)
34 | } catch (e: Exception) {
35 | e.printStackTrace()
36 | Toast.makeText(this, e.message, Toast.LENGTH_LONG).show()
37 | }
38 | } else {
39 | Toast.makeText(this, "Setup not found.", Toast.LENGTH_LONG).show()
40 | }
41 | }
42 |
43 | private fun getJsonKeys(obj: JSONObject): ArrayList {
44 | val keys = ArrayList()
45 | val it = obj.keys()
46 | while (it.hasNext()) {
47 | keys.add(it.next())
48 | }
49 | return keys
50 | }
51 |
52 | private fun encodeSetup(obj: JSONObject): String {
53 | // do not export internal id
54 | obj.remove("id")
55 |
56 | // remove empty strings, images and null values
57 | val keys = getJsonKeys(obj)
58 | for (key in keys) {
59 | val value = obj.opt(key)
60 | if (value == null) {
61 | obj.remove(key)
62 | } else if (key.endsWith("_image")) {
63 | obj.remove(key)
64 | } else if (value is String) {
65 | if (value.length == 0) {
66 | obj.remove(key)
67 | }
68 | }
69 | }
70 | return obj.toString()
71 | }
72 |
73 | private fun generateQR(door: Door) {
74 | val multiFormatWriter = MultiFormatWriter()
75 | var data_length = 0
76 | try {
77 | val obj = Settings.toJsonObject(door) ?: throw Exception("Failed to convert setup to JSON")
78 | val data = encodeSetup(obj)
79 | data_length = data.length
80 |
81 | // data has to be a string
82 | val hints = mapOf(EncodeHintType.CHARACTER_SET to "utf-8")
83 | val bitMatrix = multiFormatWriter.encode(data, BarcodeFormat.QR_CODE, 1080, 1080, hints)
84 | val barcodeEncoder = BarcodeEncoder()
85 | val bitmap = barcodeEncoder.createBitmap(bitMatrix)
86 | findViewById(R.id.QRView).setImageBitmap(bitmap)
87 | } catch (e: WriterException) {
88 | Toast.makeText(this, "${e.message} ($data_length Bytes)", Toast.LENGTH_LONG).show()
89 | finish()
90 | }
91 | }
92 |
93 | companion object {
94 | private const val TAG = "QRShowActivity"
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/NukiDoor.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger
7 |
8 | import android.text.Html
9 | import app.trigger.DoorReply.ReplyCode
10 | import app.trigger.DoorStatus.StateCode
11 | import org.json.JSONObject
12 |
13 |
14 | class NukiDoor(override var id: Int, override var name: String) : Door() {
15 | override val type = Companion.TYPE
16 | var device_name = ""
17 | var user_name = "user"
18 | var shared_key = ""
19 | var auth_id: Long = 0
20 | var app_id: Long = 2342
21 |
22 | override fun getWiFiSSIDs(): String = ""
23 | override fun getWiFiRequired(): Boolean = false
24 |
25 | override fun parseReply(reply: DoorReply): DoorStatus {
26 | val msg = Html.fromHtml(reply.message).toString().trim { it <= ' ' }
27 | return when (reply.code) {
28 | ReplyCode.LOCAL_ERROR, ReplyCode.REMOTE_ERROR -> DoorStatus(StateCode.UNKNOWN, msg)
29 | ReplyCode.SUCCESS -> if (reply.message.contains("unlocked")) {
30 | // door unlocked
31 | DoorStatus(StateCode.OPEN, msg)
32 | } else if (reply.message.contains("locked")) {
33 | // door locked
34 | DoorStatus(StateCode.CLOSED, msg)
35 | } else {
36 | DoorStatus(StateCode.UNKNOWN, msg)
37 | }
38 | ReplyCode.DISABLED -> DoorStatus(StateCode.DISABLED, msg)
39 | }
40 | }
41 |
42 | override fun isActionSupported(action: MainActivity.Action): Boolean {
43 | return when (action) {
44 | MainActivity.Action.OPEN_DOOR -> true
45 | MainActivity.Action.CLOSE_DOOR -> true
46 | MainActivity.Action.RING_DOOR -> false
47 | MainActivity.Action.FETCH_STATE -> true
48 | }
49 | }
50 |
51 | fun toJSONObject(): JSONObject {
52 | val obj = JSONObject()
53 | obj.put("id", id)
54 | obj.put("name", name)
55 | obj.put("type", type)
56 | obj.put("device_name", device_name)
57 | obj.put("user_name", user_name)
58 | obj.put("shared_key", shared_key)
59 | obj.put("auth_id", auth_id)
60 | obj.put("app_id", app_id)
61 |
62 | obj.put("open_image", Utils.serializeBitmap(open_image))
63 | obj.put("closed_image", Utils.serializeBitmap(closed_image))
64 | obj.put("unknown_image", Utils.serializeBitmap(unknown_image))
65 | obj.put("disabled_image", Utils.serializeBitmap(disabled_image))
66 |
67 | return obj
68 | }
69 |
70 | companion object {
71 | const val TYPE = "NukiDoorSetup"
72 |
73 | fun fromJSONObject(obj: JSONObject): NukiDoor {
74 | val id = obj.getInt("id")
75 | val name = obj.getString("name")
76 | val setup = NukiDoor(id, name)
77 |
78 | setup.device_name = obj.optString("device_name", "")
79 | setup.user_name = obj.optString("user_name", "")
80 | setup.shared_key = obj.optString("shared_key", "")
81 | setup.auth_id = obj.optLong("auth_id", 0)
82 | setup.app_id = obj.optLong("app_id", 2342)
83 |
84 | setup.open_image = Utils.deserializeBitmap(obj.optString("open_image", ""))
85 | setup.closed_image = Utils.deserializeBitmap(obj.optString("closed_image", ""))
86 | setup.unknown_image = Utils.deserializeBitmap(obj.optString("unknown_image", ""))
87 | setup.disabled_image = Utils.deserializeBitmap(obj.optString("disabled_image", ""))
88 |
89 | return setup
90 | }
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/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 | 
18 |
19 | (more [Screenshots](docs/screenshots.md))
20 |
21 |
22 | ## Download
23 |
24 | [
](https://f-droid.org/packages/com.example.trigger/)
25 | [
](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.example.trigger%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2Fmwarning%2Ftrigger%22%2C%22author%22%3A%22mwarning%22%2C%22name%22%3A%22Trigger%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D)
26 | [
](https://github.com/mwarning/trigger/releases)
27 |
28 | The minimum supported Android version is 5.0.
29 |
30 | ## Documentation
31 |
32 | For further feature explanations and How-Tos, see the [Documentation](docs/documentation.md) page.
33 |
34 | ## Contribution
35 |
36 | Any help, bugfixes, new features, translations are much appreciated.
37 |
38 | For translations use https://toolate.othing.xyz/projects/trigger/
39 |
40 | ## Similar/Related Projects
41 |
42 | * [Sphincter-Remote](https://github.com/openlab-aux/Sphincter-Remote) / [Sphincter](https://github.com/openlab-aux/sphincter) / [Sphincterd](https://github.com/openlab-aux/sphincterd)
43 | * [D00r-app](https://github.com/h42i/d00r-app) / [D00r-key-server](https://github.com/h42i/d00r-key-server)
44 | * [labadoor](https://github.com/ToLABaki/labadoor) / [DoorLock](https://wiki.tolabaki.gr/w/DoorLock_v3)
45 | * [Krautschlüssel](https://gitlab.com/fiveop/krautschluessel)
46 | * [MetalabDoorWidget](https://github.com/zoff99/MetalabDoorWidget)
47 | * [HACKS](https://github.com/ktt-ol/hacs)
48 | * [Stratum0Widget](https://github.com/Valodim/Stratum0Widget)
49 | * [HTTP Request Shortcuts](https://http-shortcuts.rmy.ch/) [F-Droid](https://f-droid.org/packages/ch.rmy.android.http_shortcuts/) send HTTP Requests from Shortcuts on your Home Screen
50 |
51 | ## License
52 |
53 | GNU GENERAL PUBLIC LICENSE 3.0 or later, see [license text](LICENSE) or on [spdx.org](https://spdx.org/licenses/GPL-3.0-or-later.html)
54 |
55 | Icons: [Googles Material Design](https://material.io/tools/icons/)
56 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/bluetooth/BluetoothRequestHandler.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger.bluetooth
7 |
8 | import app.trigger.BluetoothTools.createRfcommSocket
9 | import android.bluetooth.BluetoothSocket
10 | import app.trigger.DoorReply.ReplyCode
11 | import android.bluetooth.BluetoothAdapter
12 | import app.trigger.*
13 | import java.io.IOException
14 | import java.lang.Exception
15 | import java.util.*
16 |
17 |
18 | class BluetoothRequestHandler(private val listener: OnTaskCompleted, private val setup: BluetoothDoor, private val action: MainActivity.Action) : Thread() {
19 | private var socket: BluetoothSocket? = null
20 | override fun run() {
21 | if (setup.id < 0) {
22 | listener.onTaskResult(setup.id, action, ReplyCode.LOCAL_ERROR, "Internal Error")
23 | return
24 | }
25 |
26 | val adapter = BluetoothAdapter.getDefaultAdapter()
27 | if (adapter == null) {
28 | listener.onTaskResult(setup.id, action, ReplyCode.DISABLED, "Device does not support Bluetooth")
29 | return
30 | }
31 |
32 | if (!adapter.isEnabled) {
33 | // request to enable
34 | listener.onTaskResult(setup.id, action, ReplyCode.DISABLED, "Bluetooth is disabled.")
35 | return
36 | }
37 |
38 | val request = when (action) {
39 | MainActivity.Action.OPEN_DOOR -> setup.open_query
40 | MainActivity.Action.RING_DOOR -> setup.ring_query
41 | MainActivity.Action.CLOSE_DOOR -> setup.close_query
42 | MainActivity.Action.FETCH_STATE -> setup.status_query
43 | }
44 |
45 | if (request.isEmpty()) {
46 | listener.onTaskResult(setup.id, action, ReplyCode.LOCAL_ERROR, "")
47 | return
48 | }
49 | try {
50 | val pairedDevices = adapter.bondedDevices
51 | var address = ""
52 | for (device in pairedDevices) {
53 | if (device.name != null && device.name == setup.device_name
54 | || device.address == setup.device_name.uppercase(Locale.ROOT)
55 | ) {
56 | address = device.address
57 | }
58 | }
59 | if (address.isEmpty()) {
60 | listener.onTaskResult(setup.id, action, ReplyCode.LOCAL_ERROR, "Device not paired yet.")
61 | return
62 | }
63 | val device = adapter.getRemoteDevice(address)
64 | socket = if (setup.service_uuid.isEmpty()) {
65 | createRfcommSocket(device)
66 | } else {
67 | val uuid = UUID.fromString(setup.service_uuid)
68 | device.createRfcommSocketToServiceRecord(uuid)
69 | }
70 | socket!!.connect()
71 |
72 | // Get the BluetoothSocket input and output streams
73 | val tmpIn = socket!!.inputStream
74 | val tmpOut = socket!!.outputStream
75 | tmpOut.write(request.toByteArray())
76 | tmpOut.flush()
77 | val response = try {
78 | val buffer = ByteArray(512)
79 | val bytes = tmpIn.read(buffer)
80 | String(buffer, 0, bytes)
81 | } catch (ioe: IOException) {
82 | listener.onTaskResult(setup.id, action, ReplyCode.REMOTE_ERROR, "Cannot reach remote device.")
83 | return
84 | }
85 | socket!!.close()
86 | listener.onTaskResult(setup.id, action, ReplyCode.SUCCESS, response)
87 | } catch (e: Exception) {
88 | listener.onTaskResult(setup.id, action, ReplyCode.LOCAL_ERROR, e.toString())
89 | }
90 | }
91 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/ssh/GenerateIdentityTask.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger.ssh
7 |
8 | import android.os.AsyncTask
9 | import app.trigger.Log
10 | import com.trilead.ssh2.crypto.keys.Ed25519Provider
11 | import java.lang.Exception
12 | import java.security.KeyPairGenerator
13 | import java.security.SecureRandom
14 |
15 | internal class GenerateIdentityTask(var listener: OnTaskCompleted) : AsyncTask() {
16 | var keypair: KeyPairBean? = null
17 |
18 | companion object {
19 | private const val TAG = "GenerateIdentityTask"
20 |
21 | init {
22 | Log.d(TAG, "Ed25519Provider.insertIfNeeded2")
23 | // Since this class deals with Ed25519 keys, we need to make sure this is available.
24 | Ed25519Provider.insertIfNeeded()
25 | }
26 | }
27 |
28 | interface OnTaskCompleted {
29 | fun onGenerateIdentityTaskCompleted(message: String?, keypair: KeyPairBean?)
30 | }
31 |
32 | private fun convertAlgorithmName(algorithm: String): String {
33 | return if ("EdDSA" == algorithm) {
34 | KeyPairBean.KEY_TYPE_ED25519
35 | } else {
36 | algorithm
37 | }
38 | }
39 |
40 | override fun doInBackground(vararg params: Any?): String? {
41 | if (params.size != 1) {
42 | Log.e(TAG, "Unexpected number of params.")
43 | return "Internal Error"
44 | }
45 | keypair = try {
46 | val type = params[0] as String
47 | if (type == "ED25519") {
48 | createKeyPair(KeyPairBean.KEY_TYPE_ED25519, 256)
49 | } else if (type == "ECDSA-384") {
50 | createKeyPair(KeyPairBean.KEY_TYPE_EC, 384)
51 | } else if (type == "ECDSA-521") {
52 | createKeyPair(KeyPairBean.KEY_TYPE_EC, 521)
53 | } else if (type == "RSA-2048") {
54 | createKeyPair(KeyPairBean.KEY_TYPE_RSA, 2048)
55 | } else if (type == "RSA-4096") {
56 | createKeyPair(KeyPairBean.KEY_TYPE_RSA, 4096)
57 | } else if (type == "DSA-1024") {
58 | createKeyPair(KeyPairBean.KEY_TYPE_DSA, 1024)
59 | } else {
60 | return "Unknown key type: $type"
61 | }
62 | } catch (e: Exception) {
63 | return e.message
64 | }
65 | return "Done"
66 | }
67 |
68 | override fun onPostExecute(message: String?) {
69 | listener.onGenerateIdentityTaskCompleted(message, keypair)
70 | }
71 |
72 | fun createKeyPair(type: String, bits: Int): KeyPairBean? {
73 | val random = SecureRandom()
74 |
75 | // Work around JVM bug
76 | //random.nextInt();
77 | //random.setSeed(entropy); //TODO!
78 | try {
79 | val keyPairGen = KeyPairGenerator.getInstance(type)
80 | keyPairGen.initialize(bits, random)
81 | val pair = keyPairGen.generateKeyPair()
82 | val priv = pair.private
83 | val pub = pair.public
84 |
85 | //Log.d(TAG, "PrivateKey: " + priv.getAlgorithm() + " " + priv.getFormat() + " " + priv.getEncoded().length);
86 | //Log.d(TAG, "PublicKey: " + pub.getAlgorithm() + " " + pub.getFormat() + " " + pub.getEncoded().length);
87 | val secret = "" // password for encrypted key
88 |
89 | //Log.d(TAG, "private: " + PubkeyUtils.formatKey(priv));
90 | Log.d(TAG, "public: ${PubkeyUtils.formatKey(pub)}") // public: Key[algorithm=EdDSA, format=X.509, bytes=44]
91 | val privateKey = PubkeyUtils.getEncodedPrivate(priv, secret).clone()
92 | val publicKey = pub.encoded.clone()
93 | return KeyPairBean(type, privateKey, publicKey, false)
94 | } catch (e: Exception) {
95 | Log.e(TAG, e.toString())
96 | }
97 | return null
98 | }
99 | }
--------------------------------------------------------------------------------
/docs/documentation.md:
--------------------------------------------------------------------------------
1 | # Documentation
2 |
3 | ## Compatible Locks
4 |
5 | Trigger makes rather generic requests and needs to be configured depending on the door system. No off the shelf door systems has been tested. So no advice can be given here. While the Nuki SmartLock has basic support now, it is rather expensive.
6 |
7 | ## Door Status
8 |
9 | Trigger reads the door status from the text returned of the HTTPS query, SSH command or MQTT server. You can set a regular expression for the `Reply Pattern (locked)` and `Reply Pattern (unlocked)` settings. The default values are `LOCKED` and `UNLOCKED`. The complete return message is always displayed in the App for a short time (with possible HTML elements stripped and truncated if needed).
10 |
11 | An example of a regular expression pattern is `"state"\s*:\s*"open"`. This would match a part of a typical JSON response.
12 |
13 | ## Auto-Select/Limit Door By SSID
14 |
15 | The door setup can be selected depending on what WiFi network the device is connected to. This can help to avoid to switch the door setup by hand. Even multiple SSIDs can be set as a comma separated list. As of release 3.1.1, this disables the setup if the connected WiFi SSID does not match.
16 |
17 | ## SSH Key Registration
18 |
19 | SSH Public Keys can be send to an IP address and port (e.g. `192.168.1.1:3333`) to be registered. There is a field and button in the SSH Key Managment for that. A simple example server to collect keys using netcat: `
20 | nc -l -k -p 3333 -c 'read key; echo "$key" >> ssh_keys.txt; echo "Your key was received!"'`. This was tested with Ncat 7.80 (https://nmap.org/ncat). Other netcat implementations might need other parameters.
21 |
22 | ## Import Link As QR-Code
23 |
24 | Instead of QR-Codes imported from other Trigger Apps, you can import simple links like `https://example.com/open?pass=secret` as QR-Code to create a simple HTTPS based door setup. Links starting with `mqtt://` and `mqtts://` will be used for MQTT and `ssh://` for SSH based door setups.
25 |
26 | HTTP(s) links may also contain a username and password to be used for basic access authentication, e.g.: `https://user:password@example.com/open_door`.
27 |
28 | ## Import SSH Key As QR-Code
29 |
30 | Use e.g. `qrencode -t ansiutf8 < ~/.ssh/id_ed25519` to show an SSH private key as QR-Code. Scan with trigger and add the server address, user, command etc..
31 |
32 | ## Limit SSH Access To The Server
33 |
34 | To limit a user to call only one command via SSH, put a line into `~/.ssh/authorized_keys` on the SSH server. E.g.:
35 |
36 | ```
37 | no-port-forwarding,no-x11-forwarding,no-agent-forwarding,command="/home/pi/bin/controldoor.sh" ssh-[keyver] [pubkeydata] [comment]
38 | ```
39 |
40 | The command send by Trigger is available as environment variable called `SSH_ORIGINAL_COMMAND` in the command script.
41 |
42 | ## MQTT Server Setup
43 |
44 | Use these two sites as a HowTo:
45 |
46 | * [Run Local MQTT Broker on OpenWrt](https://www.onetransistor.eu/2019/05/run-local-mqtt-broker-on-openwrt-router.html)
47 | * [Mosquitto on OpenWrt Router](https://www.onetransistor.eu/2019/05/mosquitto-mqtt-on-openwrt-router.html)
48 | * [Mosquitto with TLS Certificate](https://www.onetransistor.eu/2019/05/mosquitto-mqtt-tls-certificate.html)
49 |
50 | ## Nuki Smartlock Pairing
51 |
52 | Steps to pair Trigger with the Nuki Smartlock:
53 |
54 | 1. Nuki: Press the door knob button for 3 seconds until the ring lights up (pairing mode).
55 | 2. Phone: Enable Bluetooth.
56 | 3. Phone: Pair phone and Nuki Smartlock.
57 | 4. Trigger: Start App and add a new door entry with door type "Nuki SmartLock".
58 | 5. Trigger: Enter the name of the paired Nuki Smartlock into the "Lock Name/MAC" field (something like "Nuki_1DAB5E34").
59 | 6. Trigger: Enter some user name (not very important) and save the door setup.
60 | 7. Nuki: If the Nuki smartlock is not in pairing mode anymore, just press the button again.
61 | 8. Trigger: Press the open door button in Trigger. This will cause Trigger to register with the Nuki Smartlock.
62 |
63 | Now you should be able to send open/close commands.
64 |
65 | (tested with Android 10 and Nuki SmartLock 2.0)
66 |
67 | ## Build Trigger from Sources
68 |
69 | On Linux based systems:
70 |
71 | ```
72 | ./gradlew assembleRelease
73 | ```
74 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/https/HttpsTools.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger.https
7 |
8 | import kotlin.Throws
9 | import android.util.Base64
10 | import app.trigger.Log
11 | import java.io.ByteArrayInputStream
12 | import java.lang.Exception
13 | import java.security.*
14 | import java.security.cert.Certificate
15 | import java.security.cert.CertificateException
16 | import java.security.cert.CertificateFactory
17 | import java.security.cert.X509Certificate
18 | import javax.net.ssl.*
19 |
20 | object HttpsTools {
21 | private const val TAG = "HttpsTools"
22 | fun isValid(cert: X509Certificate): Boolean {
23 | return try {
24 | cert.checkValidity()
25 | true
26 | } catch (e: Exception) {
27 | false
28 | }
29 | }
30 |
31 | fun isSelfSigned(cert: X509Certificate): Boolean {
32 | return cert.issuerX500Principal.name ==
33 | cert.subjectX500Principal.name
34 | }
35 |
36 | fun serializeCertificate(certificate: Certificate?): String {
37 | if (certificate == null) {
38 | return ""
39 | }
40 | try {
41 | val prefix = "-----BEGIN CERTIFICATE-----"
42 | val mid = Base64.encodeToString(certificate.encoded, Base64.DEFAULT)
43 | val suffix = "-----END CERTIFICATE-----"
44 | return "${prefix}\n${mid}\n${suffix}"
45 | } catch (e: Exception) {
46 | Log.e(TAG, e.toString())
47 | }
48 | return ""
49 | }
50 |
51 | fun deserializeCertificate(certificate: String?): Certificate? {
52 | if (certificate.isNullOrEmpty()) {
53 | return null
54 | }
55 | try {
56 | val derInputStream = ByteArrayInputStream(certificate.toByteArray())
57 | val certificateFactory = CertificateFactory.getInstance("X.509") //KeyStore.getDefaultType() => "BKS"
58 | return certificateFactory.generateCertificate(derInputStream)
59 | } catch (e: Exception) {
60 | Log.e(this, e.toString())
61 | }
62 | return null
63 | }
64 |
65 | // disable any certificate validation
66 | fun disableDefaultHostnameVerifier() {
67 | HttpsURLConnection.setDefaultHostnameVerifier { arg0, arg1 -> true }
68 | }
69 |
70 | // disable any certificate validation
71 | @Throws(NoSuchAlgorithmException::class, KeyManagementException::class)
72 | fun disableDefaultCertificateValidation() {
73 | val trustManager: TrustManager = object : X509TrustManager {
74 | @Throws(CertificateException::class)
75 | override fun checkClientTrusted(cert: Array, authType: String) {
76 | }
77 |
78 | @Throws(CertificateException::class)
79 | override fun checkServerTrusted(cert: Array, authType: String) {
80 | }
81 |
82 | override fun getAcceptedIssuers(): Array {
83 | return arrayOf()
84 | }
85 | }
86 | val trustManagers = arrayOf(trustManager)
87 | val context = SSLContext.getInstance("TLS")
88 | context.init(null, trustManagers, SecureRandom())
89 | HttpsURLConnection.setDefaultSSLSocketFactory(context.socketFactory)
90 | }
91 |
92 | @Throws(NoSuchAlgorithmException::class, KeyStoreException::class, KeyManagementException::class)
93 | fun getSocketFactoryIgnoreCertificateExpiredException(): SSLSocketFactory {
94 | val factory = TrustManagerFactory.getInstance("X509")
95 | factory.init(null as KeyStore?)
96 | val trustManagers = factory.trustManagers
97 | for (i in trustManagers.indices) {
98 | if (trustManagers[i] is X509TrustManager) {
99 | trustManagers[i] = IgnoreExpirationTrustManager(trustManagers[i] as X509TrustManager)
100 | }
101 | }
102 | val sslContext = SSLContext.getInstance("TLS")
103 | sslContext.init(null, trustManagers, null)
104 | return sslContext.socketFactory
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/ssh/KeyPairBean.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger.ssh
7 |
8 | import app.trigger.Log
9 | import com.trilead.ssh2.crypto.PEMDecoder
10 | import java.io.IOException
11 | import java.io.Serializable
12 | import java.lang.Exception
13 | import java.lang.RuntimeException
14 | import java.lang.StringBuilder
15 | import java.security.NoSuchAlgorithmException
16 | import java.security.spec.InvalidKeySpecException
17 | import java.util.*
18 |
19 | /*
20 | * A wrapper for a private key in PEM (text) format.
21 | * This is necessary for type inference mechanism.
22 | * The public key is derived/extracted if needed.
23 | */
24 | class KeyPairBean(val type: String, val privateKey: ByteArray, val publicKey: ByteArray, val encrypted: Boolean) : Serializable {
25 | var nickname = ""
26 |
27 | val openSSHPublicKey: String?
28 | get() {
29 | try {
30 | val pk = PubkeyUtils.decodePublic(publicKey, type)
31 | return PubkeyUtils.convertToOpenSSHFormat(pk, nickname)
32 | } catch (e: Exception) {
33 | Log.e(TAG, "getOpenSSHPublicKey: " + e.message)
34 | }
35 | return null
36 | }
37 | val openSSHPrivateKey: String?
38 | get() {
39 | try {
40 | return if (type == KEY_TYPE_IMPORTED) {
41 | String(privateKey)
42 | } else {
43 | val pk = PubkeyUtils.decodePrivate(privateKey, type)
44 | PubkeyUtils.exportPEM(pk, null)
45 | }
46 | } catch (e: Exception) {
47 | Log.e(TAG, "getOpenSSHPrivateKey: " + e.message)
48 | }
49 | return null
50 | }
51 |
52 | // 256 bit, but this might give the wrong impression regarding security
53 | val description: String
54 | get() = if (KEY_TYPE_IMPORTED == type) {
55 | var type = ""
56 | try {
57 | val struct = PEMDecoder.parsePEM(String(privateKey).toCharArray())
58 | type = when (struct.pemType) {
59 | PEMDecoder.PEM_RSA_PRIVATE_KEY -> "RSA"
60 | PEMDecoder.PEM_DSA_PRIVATE_KEY -> "DSA"
61 | PEMDecoder.PEM_EC_PRIVATE_KEY -> "EC"
62 | PEMDecoder.PEM_OPENSSH_PRIVATE_KEY -> "OpenSSH"
63 | else -> throw RuntimeException("Unexpected key type: ${struct.pemType}")
64 | }
65 | } catch (e: IOException) {
66 | Log.e(TAG, "Error decoding IMPORTED public key: $e")
67 | }
68 | String.format("%s unknown-bit", type)
69 | } else {
70 | var bits: Int? = null
71 | try {
72 | bits = PubkeyUtils.getBitStrength(publicKey, type)
73 | } catch (ignored: NoSuchAlgorithmException) {
74 | } catch (ignored: InvalidKeySpecException) {
75 | }
76 | val sb = StringBuilder()
77 | if (KEY_TYPE_RSA == type) {
78 | sb.append(String.format(Locale.getDefault(), "RSA %d-bit", bits))
79 | } else if (KEY_TYPE_DSA == type) {
80 | sb.append(String.format(Locale.getDefault(), "DSA %d-bit", 1024))
81 | } else if (KEY_TYPE_EC == type) {
82 | sb.append(String.format(Locale.getDefault(), "EC %d-bit", bits))
83 | } else if (KEY_TYPE_ED25519 == type) {
84 | sb.append("ED25519") // 256 bit, but this might give the wrong impression regarding security
85 | } else {
86 | sb.append("Unknown key type")
87 | }
88 | if (encrypted) {
89 | sb.append(" (encrypted)")
90 | }
91 | sb.toString()
92 | }
93 |
94 | companion object {
95 | private const val TAG = "KeyPairBean"
96 | const val KEY_TYPE_RSA = "RSA"
97 | const val KEY_TYPE_DSA = "DSA"
98 | const val KEY_TYPE_IMPORTED = "IMPORTED" // imported PEM key
99 | const val KEY_TYPE_EC = "EC"
100 | const val KEY_TYPE_ED25519 = "ED25519"
101 | }
102 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/nuki/NukiLockActionCallback.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger.nuki
7 |
8 | import app.trigger.Utils.byteArrayToHexString
9 | import app.trigger.Utils.hexStringToByteArray
10 | import app.trigger.DoorReply.ReplyCode
11 | import android.bluetooth.BluetoothGattCharacteristic
12 | import android.bluetooth.BluetoothGatt
13 | import app.trigger.nuki.NukiCommand.NukiRequest
14 | import app.trigger.nuki.NukiCommand.NukiChallenge
15 | import app.trigger.nuki.NukiCommand.NukiStates
16 | import app.trigger.nuki.NukiCommand.NukiStatus
17 | import app.trigger.nuki.NukiCommand.NukiError
18 | import app.trigger.nuki.NukiCommand.NukiLockAction
19 | import app.trigger.*
20 |
21 | internal class NukiLockActionCallback(door_id: Int, action: MainActivity.Action, listener: OnTaskCompleted, setup: NukiDoor, lock_action: Int)
22 | : NukiCallback(door_id, action, listener, KEYTURNER_SERVICE_UUID, KEYTURNER_USDIO_XTERISTIC_UUID) {
23 | var auth_id: Long
24 | var app_id: Long
25 | var lock_action: Int
26 | var shared_key: ByteArray
27 | var data = ByteArray(0)
28 |
29 | override fun onConnected(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
30 | Log.d(TAG, "onConnected")
31 | val nr = NukiRequest(0x04)
32 | val request: ByteArray? = NukiRequestHandler.encrypt_message(shared_key, auth_id, nr.generate(), null)
33 | characteristic.value = request
34 | val ok = gatt.writeCharacteristic(characteristic)
35 | if (!ok) {
36 | Log.e(TAG, "initial writeCharacteristic failed")
37 | closeConnection(gatt)
38 | }
39 | }
40 |
41 | override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
42 | Log.d(TAG, "onCharacteristicChanged, uiid: ${characteristic.uuid}: ${byteArrayToHexString(characteristic.value)}")
43 | data = if (data.isEmpty()) {
44 | characteristic.value
45 | } else {
46 | NukiTools.concat(data, characteristic.value)
47 | }
48 | val message: ByteArray? = NukiRequestHandler.decrypt_message(shared_key, data)
49 | val command: NukiCommand? = NukiRequestHandler.parse(message)
50 | if (command == null) {
51 | Log.d(TAG, "NukiCommand is null")
52 | return
53 | } else {
54 | data = ByteArray(0)
55 | }
56 | if (command is NukiChallenge) {
57 | Log.d(TAG, "NukiCommand.NukiChallenge")
58 | val nla = NukiLockAction(lock_action, app_id, 0x00, command.nonce)
59 | val response: ByteArray? = NukiRequestHandler.encrypt_message(shared_key, auth_id, nla.generate(), null)
60 | characteristic.value = response
61 | val ok = gatt.writeCharacteristic(characteristic)
62 | if (!ok) {
63 | Log.e(TAG, "writeCharacteristic failed for NukiLockAction")
64 | closeConnection(gatt)
65 | }
66 | } else if (command is NukiStatus) {
67 | Log.d(TAG, "NukiCommand.NukiStatus")
68 | if (command.status == NukiStatus.STATUS_COMPLETE) {
69 | // do not wait until the Nuki closes the connection
70 | closeConnection(gatt)
71 | }
72 | } else if (command is NukiStates) {
73 | Log.d(TAG, "NukiCommand.NukiStates")
74 | val ns = command
75 | var extra = ""
76 | if (ns.battery_critical == 0x01) {
77 | extra = " (Battery Critical!)"
78 | }
79 | listener.onTaskResult(door_id, action, ReplyCode.SUCCESS, NukiTools.getLockState(ns.lock_state) + extra)
80 | } else if (command is NukiError) {
81 | Log.d(TAG, "NukiCommand.NukiError")
82 | listener.onTaskResult(door_id, action, ReplyCode.REMOTE_ERROR, command.asString())
83 | closeConnection(gatt)
84 | } else {
85 | Log.e(TAG, "Unhandled command")
86 | closeConnection(gatt)
87 | }
88 | }
89 |
90 | companion object {
91 | private const val TAG = "LockActionCallback"
92 | }
93 |
94 | init {
95 | shared_key = hexStringToByteArray(setup.shared_key)
96 | auth_id = setup.auth_id
97 | app_id = setup.app_id
98 | this.lock_action = lock_action
99 | }
100 | }
--------------------------------------------------------------------------------
/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/kotlin/app/trigger/BackupActivity.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger
7 |
8 | import android.app.Activity
9 | import android.app.AlertDialog
10 | import android.content.Intent
11 | import android.net.Uri
12 | import android.os.Bundle
13 | import android.view.View
14 | import android.widget.Button
15 | import android.widget.Toast
16 | import androidx.activity.result.contract.ActivityResultContracts
17 | import androidx.appcompat.app.AppCompatActivity
18 | import androidx.appcompat.widget.Toolbar
19 | import org.json.JSONObject
20 |
21 | class BackupActivity : AppCompatActivity() {
22 | private lateinit var builder: AlertDialog.Builder
23 | private lateinit var exportButton: Button
24 | private lateinit var importButton: Button
25 |
26 | private fun showErrorMessage(title: String, message: String) {
27 | builder.setTitle(title)
28 | builder.setMessage(message)
29 | builder.setPositiveButton(android.R.string.ok, null)
30 | builder.show()
31 | }
32 |
33 | public override fun onCreate(savedInstanceState: Bundle?) {
34 | super.onCreate(savedInstanceState)
35 | setContentView(R.layout.activity_backup)
36 |
37 | val toolbar = findViewById(R.id.toolbar)
38 | setSupportActionBar(toolbar)
39 |
40 | builder = AlertDialog.Builder(this)
41 |
42 | importButton = findViewById(R.id.ImportButton)
43 | exportButton = findViewById(R.id.ExportButton)
44 |
45 | importButton.setOnClickListener { v: View? ->
46 | val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
47 | intent.addCategory(Intent.CATEGORY_OPENABLE)
48 | intent.type = "application/json"
49 | importFileLauncher.launch(intent)
50 | }
51 |
52 | exportButton.setOnClickListener {
53 | val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
54 | intent.addCategory(Intent.CATEGORY_OPENABLE)
55 | intent.putExtra(Intent.EXTRA_TITLE, "trigger-backup.json")
56 | intent.type = "application/json"
57 | exportFileLauncher.launch(intent)
58 | }
59 | }
60 |
61 | private var importFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
62 | if (result.resultCode == Activity.RESULT_OK) {
63 | val intent = result.data ?: return@registerForActivityResult
64 | val uri = intent.data ?: return@registerForActivityResult
65 | importBackup(uri)
66 | }
67 | }
68 |
69 | private var exportFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
70 | if (result.resultCode == Activity.RESULT_OK) {
71 | val intent = result.data ?: return@registerForActivityResult
72 | val uri: Uri = intent.data ?: return@registerForActivityResult
73 | exportBackup(uri)
74 | }
75 | }
76 |
77 | private fun exportBackup(uri: Uri) {
78 | try {
79 | val obj = JSONObject()
80 | var count = 0
81 | for (door in Settings.getDoors()) {
82 | val json_obj = Settings.toJsonObject(door)
83 | json_obj!!.remove("id")
84 | obj.put(door.name, json_obj)
85 | count += 1
86 | }
87 | Utils.writeFile(this, uri, obj.toString().toByteArray())
88 | Toast.makeText(this, "Exported $count entries.", Toast.LENGTH_LONG).show()
89 | } catch (e: Exception) {
90 | showErrorMessage("Error", e.toString())
91 | }
92 | }
93 |
94 | private fun importBackup(uri: Uri) {
95 | try {
96 | val data = Utils.readFile(this, uri)
97 | val json_data = JSONObject(
98 | String(data, 0, data.size)
99 | )
100 | var count = 0
101 | val keys = json_data.keys()
102 | while (keys.hasNext()) {
103 | val key = keys.next()
104 | val obj = json_data.getJSONObject(key)
105 | obj.put("id", Settings.getNewDoorIdentifier())
106 | val door = Settings.fromJsonObject(obj)
107 | if (door != null) {
108 | Settings.storeDoorSetup(door)
109 | }
110 | count += 1
111 | }
112 | Toast.makeText(this, "Imported $count doors", Toast.LENGTH_LONG).show()
113 | } catch (e: Exception) {
114 | showErrorMessage("Error", e.toString())
115 | }
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/SshDoor.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger
7 |
8 | import app.trigger.ssh.KeyPairBean
9 | import app.trigger.ssh.SshTools
10 | import org.json.JSONObject
11 |
12 |
13 | class SshDoor(override var id: Int, override var name: String) : Door() {
14 | override val type = Companion.TYPE
15 |
16 | var require_wifi = false
17 | var keypair: KeyPairBean? = null
18 | var user = ""
19 | var password = ""
20 | var host = ""
21 | var port = 22
22 | var open_command = ""
23 | var close_command = ""
24 | var ring_command = ""
25 | var state_command = ""
26 |
27 | // regex to evaluate the door return message
28 | var unlocked_pattern = ""
29 | var locked_pattern = ""
30 |
31 | var register_url = ""
32 | var ssids = ""
33 | var timeout = 5000 // milliseconds
34 | var passphrase_tmp = ""
35 |
36 | override fun getWiFiRequired(): Boolean = require_wifi
37 | override fun getWiFiSSIDs(): String = ssids
38 |
39 | override fun getRegisterUrl(): String {
40 | return register_url.ifEmpty { host }
41 | }
42 |
43 | override fun parseReply(reply: DoorReply): DoorStatus {
44 | return Utils.genericDoorReplyParser(reply, unlocked_pattern, locked_pattern)
45 | }
46 |
47 | override fun isActionSupported(action: MainActivity.Action): Boolean {
48 | return when (action) {
49 | MainActivity.Action.OPEN_DOOR -> open_command.isNotEmpty()
50 | MainActivity.Action.CLOSE_DOOR -> close_command.isNotEmpty()
51 | MainActivity.Action.RING_DOOR -> ring_command.isNotEmpty()
52 | MainActivity.Action.FETCH_STATE -> state_command.isNotEmpty()
53 | }
54 | }
55 |
56 | fun needsPassphrase(): Boolean {
57 | return keypair != null && keypair!!.encrypted
58 | }
59 |
60 | fun toJSONObject(): JSONObject {
61 | val obj = JSONObject()
62 | obj.put("id", id)
63 | obj.put("name", name)
64 | obj.put("type", type)
65 |
66 | obj.put("require_wifi", require_wifi)
67 | obj.put("keypair", SshTools.serializeKeyPair(keypair))
68 | obj.put("user", user)
69 | obj.put("password", password)
70 | obj.put("host", host)
71 | obj.put("port", port)
72 | obj.put("open_command", open_command)
73 | obj.put("close_command", close_command)
74 | obj.put("ring_command", ring_command)
75 | obj.put("state_command", state_command)
76 |
77 | obj.put("unlocked_pattern", unlocked_pattern)
78 | obj.put("locked_pattern", locked_pattern)
79 | obj.put("open_image", Utils.serializeBitmap(open_image))
80 | obj.put("closed_image", Utils.serializeBitmap(closed_image))
81 | obj.put("unknown_image", Utils.serializeBitmap(unknown_image))
82 | obj.put("disabled_image", Utils.serializeBitmap(disabled_image))
83 |
84 | obj.put("register_url", register_url)
85 | obj.put("ssids", ssids)
86 | obj.put("timeout", timeout)
87 |
88 | return obj
89 | }
90 |
91 | companion object {
92 | const val TYPE = "SshDoorSetup"
93 |
94 | fun fromJSONObject(obj: JSONObject): SshDoor {
95 | val id = obj.getInt("id")
96 | val name = obj.getString("name")
97 | val setup = SshDoor(id, name)
98 |
99 | setup.require_wifi = obj.optBoolean("require_wifi", false)
100 | setup.keypair = SshTools.deserializeKeyPair(obj.optString("keypair", ""))
101 | setup.user = obj.optString("user", "")
102 | setup.password = obj.optString("password", "")
103 | setup.host = obj.optString("host", "")
104 | setup.port = obj.optInt("port", 22)
105 | setup.open_command = obj.optString("open_command", "")
106 | setup.close_command = obj.optString("close_command", "")
107 | setup.ring_command = obj.optString("ring_command", "")
108 | setup.state_command = obj.optString("state_command", "")
109 |
110 | setup.unlocked_pattern = obj.optString("unlocked_pattern", "")
111 | setup.locked_pattern = obj.optString("locked_pattern", "")
112 |
113 | setup.open_image = Utils.deserializeBitmap(obj.optString("open_image", ""))
114 | setup.closed_image = Utils.deserializeBitmap(obj.optString("closed_image", ""))
115 | setup.unknown_image = Utils.deserializeBitmap(obj.optString("unknown_image", ""))
116 | setup.disabled_image = Utils.deserializeBitmap(obj.optString("disabled_image", ""))
117 |
118 | setup.register_url = obj.optString("register_url", "")
119 | setup.ssids = obj.optString("ssids", "")
120 | setup.timeout = obj.optInt("timeout", 5000)
121 |
122 | return setup
123 | }
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/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/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 |
78 |
79 |
86 |
87 |
94 |
95 |
96 |
97 |
102 |
103 |
110 |
111 |
118 |
119 |
126 |
127 |
128 |
129 |
130 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/nuki/NukiCommand.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger.nuki
7 |
8 | import org.libsodium.jni.Sodium
9 | import android.util.Log
10 | import java.util.*
11 |
12 | open class NukiCommand(var command: Int) {
13 | internal class NukiRequest(var command_id: Int) : NukiCommand(0x0001) {
14 | fun generate(): ByteArray {
15 | return NukiTools.concat(NukiTools.from16(command), NukiTools.from16(command_id))
16 | }
17 | }
18 |
19 | internal class NukiAuthIdConfirm(var authenticator: ByteArray, var auth_id: Long) : NukiCommand(0x001E) {
20 | fun generate(): ByteArray {
21 | return NukiTools.concat(NukiTools.from16(command), authenticator, NukiTools.from32_auth_id(auth_id))
22 | }
23 | }
24 |
25 | internal class NukiAuthData(var authenticator: ByteArray, // 0x00: App, 0x01: Bridge, 0x02 Fob, 0x03 Keypad
26 | var id_type: Int, // The ID of the Nuki App, Nuki Bridge or Nuki Fob to be authorized. same as auth_id????
27 | var app_id: Long, var name: String, var nonce: ByteArray) : NukiCommand(0x0006) {
28 | fun generate(): ByteArray {
29 | return NukiTools.concat(NukiTools.from16(command), authenticator, NukiTools.from8(id_type), NukiTools.from32_app_id(app_id), NukiTools.nameToBytes(name, 32), nonce)
30 | }
31 | }
32 |
33 | internal class NukiError(var error_code: Int, var command_id: Int) : NukiCommand(0x0012) {
34 | fun asString(): String {
35 | return "Nuki Error: " + NukiTools.getError(error_code)
36 | }
37 | }
38 |
39 | internal class NukiPublicKey(var public_key: ByteArray) : NukiCommand(0x0003) {
40 | fun generate(): ByteArray {
41 | return NukiTools.concat(NukiTools.from16(command), public_key)
42 | }
43 | }
44 |
45 | internal class NukiChallenge(var nonce: ByteArray) : NukiCommand(0x0004) {
46 | fun generate(): ByteArray {
47 | return NukiTools.concat(NukiTools.from16(command), nonce)
48 | }
49 |
50 | init {
51 | if (nonce.size != 32) {
52 | Log.e("NukiChallenge", "invalid nonce length: " + nonce.size + " (expected " + 32 + ")")
53 | }
54 | }
55 | }
56 |
57 | internal class NukiAuthID(var authenticator: ByteArray, var auth_id: Long, var uuid: ByteArray, var nonce: ByteArray) : NukiCommand(0x0007) {
58 | fun verify(shared_key: ByteArray?, nonce: ByteArray?): Boolean {
59 | val valueR = NukiTools.concat(NukiTools.from32_auth_id(auth_id), uuid, this.nonce, nonce!!)
60 | val authenticator = ByteArray(Sodium.crypto_auth_hmacsha256_bytes())
61 | if (Sodium.crypto_auth_hmacsha256(authenticator, valueR, valueR.size, shared_key) != 0) {
62 | Log.e("NukiAuthID", "crypto_auth_hmacsha256 failed")
63 | return false
64 | }
65 | return Arrays.equals(this.authenticator, authenticator)
66 | }
67 | }
68 |
69 | internal class NukiStatus(var status: Int) : NukiCommand(0x000E) {
70 | fun generate(): ByteArray {
71 | return NukiTools.concat(NukiTools.from16(command), NukiTools.from8(status))
72 | }
73 |
74 | companion object {
75 | const val STATUS_COMPLETE = 0x00 // Returned to signal the successful completion of a command
76 | const val STATUS_ACCEPTED = 0x01 // Returned to signal that a command has been accepted but the completion status will be signaled later.
77 | }
78 | }
79 |
80 | 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)
81 | internal class NukiLockAction(var lock_action: Int, var app_id: Long, var flags: Int, // optional
82 | var name_suffix: String?, var nonce: ByteArray?) : NukiCommand(0x000D) {
83 | constructor(lock_action: Int, app_id: Long, flags: Int, nonce: ByteArray?) : this(lock_action, app_id, flags, null, nonce)
84 |
85 | fun generate(): ByteArray {
86 | val name_suffix_padded = if (name_suffix == null) {
87 | ByteArray(0)
88 | } else {
89 | NukiTools.nameToBytes(name_suffix, 20)
90 | }
91 | return NukiTools.concat(NukiTools.from16(command), NukiTools.from8(lock_action), NukiTools.from32_app_id(app_id), NukiTools.from8(flags), name_suffix_padded, nonce!!)
92 | }
93 |
94 | init {
95 | if (nonce!!.size != 32) {
96 | Log.e("NukiLockAction", "nonce has wrong length: " + nonce!!.size)
97 | }
98 | }
99 | }
100 |
101 | internal class NukiAuthAuthentication(private var authenticator: ByteArray) : NukiCommand(0x0005) {
102 | fun generate(): ByteArray {
103 | return NukiTools.concat(NukiTools.from16(command), authenticator)
104 | }
105 | }
106 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/WifiTools.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger
7 |
8 | import android.content.Context
9 | import android.net.ConnectivityManager
10 | import android.net.wifi.WifiManager
11 |
12 | object WifiTools {
13 | private const val TAG = "WifiTools"
14 | private var wifiManager: WifiManager? = null
15 | private var connectivityManager: ConnectivityManager? = null
16 |
17 | fun init(context: Context) {
18 | wifiManager = context.getSystemService(Context.WIFI_SERVICE) as WifiManager
19 | connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
20 | }
21 |
22 | fun matchSSID(ssids: String?, ssid: String): Boolean {
23 | if (ssids != null) {
24 | for (element in ssids.split(",").toTypedArray()) {
25 | val e = element.trim { it <= ' ' }
26 | if (e.isNotEmpty() && e == ssid) {
27 | return true
28 | }
29 | }
30 | }
31 | return false
32 | }
33 |
34 | // Note: needs coarse location permission
35 | fun getCurrentSSID(): String {
36 | // Note: needs coarse location permission
37 | return if (wifiManager != null) {
38 | val info = wifiManager!!.connectionInfo
39 | val ssid = info.ssid
40 | if (ssid.length >= 2 && ssid.startsWith("\"") && ssid.endsWith("\"")) {
41 | // quoted string
42 | ssid.substring(1, ssid.length - 1)
43 | } else {
44 | // hexadecimal string...
45 | ssid
46 | }
47 | } else {
48 | ""
49 | }
50 | }
51 |
52 | /*
53 | public static ArrayList getScannedSSIDs() {
54 | ArrayList ssids;
55 | List results;
56 |
57 | ssids = new ArrayList<>();
58 | if (wifiManager != null) {
59 | results = wifiManager.getScanResults();
60 | if (results != null) {
61 | for (ScanResult result : results) {
62 | ssids.add(result.SSID);
63 | }
64 | }
65 | }
66 |
67 | return ssids;
68 | }
69 |
70 | public static ArrayList getConfiguredSSIDs() {
71 | // Note: needs coarse location permission
72 | List configs;
73 | ArrayList ssids;
74 |
75 | ssids = new ArrayList<>();
76 | if (wifiManager != null) {
77 | configs = wifiManager.getConfiguredNetworks();
78 | if (configs != null) {
79 | for (WifiConfiguration config : configs) {
80 | ssids.add(config.SSID);
81 | }
82 | }
83 | }
84 |
85 | return ssids;
86 | }
87 |
88 | public static WifiConfiguration findConfig(List configs, String ssid) {
89 | for (WifiConfiguration config : configs) {
90 | if (config.SSID.equals(ssid)) {
91 | return config;
92 | }
93 | }
94 | return null;
95 | }
96 |
97 | // connect to the best wifi that is configured by this app and system
98 | void connectBestOf(ArrayList ssids) {
99 | String current_ssid = this.getCurrentSSID();
100 | List configs;
101 | WifiConfiguration config;
102 | List scanned;
103 |
104 | if (wifiManager == null) {
105 | return;
106 | }
107 |
108 | configs = wifiManager.getConfiguredNetworks();
109 | scanned = wifiManager.getScanResults();
110 |
111 | if (scanned == null && configs == null) {
112 | Log.e("Wifi", "Insufficient data for connect.");
113 | return;
114 | }
115 |
116 | // TODO: sort by signal
117 | for (ScanResult scan : scanned) {
118 | config = findConfig(configs, scan.SSID);
119 | if (config != null) {
120 | if (!current_ssid.equals(scan.SSID)) {
121 | wifiManager.disconnect();
122 | wifiManager.enableNetwork(config.networkId, true);
123 | wifiManager.reconnect();
124 | }
125 | break;
126 | }
127 | }
128 | }
129 | */
130 | fun isConnected(): Boolean {
131 | val networks = connectivityManager!!.allNetworks
132 | for (network in networks) {
133 | val networkInfo = connectivityManager!!.getNetworkInfo(network)
134 | if (networkInfo!!.type == ConnectivityManager.TYPE_WIFI) {
135 | return true
136 | }
137 | }
138 | return false
139 | }
140 |
141 | fun isConnectedWithInternet(): Boolean {
142 | if (connectivityManager == null) {
143 | return false
144 | }
145 | val mWifi = connectivityManager!!.getNetworkInfo(ConnectivityManager.TYPE_WIFI)
146 | return mWifi!!.isConnected
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_abstract_client_keypair.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
15 |
16 |
20 |
21 |
22 |
23 |
31 |
32 |
41 |
42 |
49 |
50 |
51 |
52 |
60 |
61 |
65 |
66 |
73 |
74 |
81 |
82 |
83 |
84 |
88 |
89 |
96 |
97 |
103 |
104 |
105 |
106 |
107 |
108 |
115 |
116 |
123 |
124 |
131 |
132 |
139 |
140 |
141 |
142 |
143 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/https/IgnoreExpirationTrustManager.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger.https
7 |
8 | import kotlin.Throws
9 | import java.math.BigInteger
10 | import java.security.*
11 | import java.security.cert.CertificateEncodingException
12 | import java.security.cert.CertificateException
13 | import java.security.cert.X509Certificate
14 | import java.util.*
15 | import javax.net.ssl.X509TrustManager
16 |
17 | internal class IgnoreExpirationTrustManager(private val innerTrustManager: X509TrustManager) : X509TrustManager {
18 | @Throws(CertificateException::class)
19 | override fun checkClientTrusted(chain: Array, authType: String) {
20 | innerTrustManager.checkClientTrusted(chain, authType)
21 | }
22 |
23 | @Throws(CertificateException::class)
24 | override fun checkServerTrusted(chain: Array, authType: String) {
25 | val newChain = arrayOf(EternalCertificate(chain[0]))
26 | /*
27 | var chain = chain
28 | chain = Arrays.copyOf(chain, chain.size)
29 | val newChain = arrayOfNulls(chain.size)
30 | newChain[0] = EternalCertificate(chain[0])
31 | System.arraycopy(chain, 1, newChain, 1, chain.size - 1)
32 | chain = newChain
33 | innerTrustManager.checkServerTrusted(chain, authType)
34 | */
35 | innerTrustManager.checkServerTrusted(newChain, authType)
36 | }
37 |
38 | override fun getAcceptedIssuers(): Array {
39 | return innerTrustManager.acceptedIssuers
40 | }
41 |
42 | private inner class EternalCertificate(private val originalCertificate: X509Certificate) : X509Certificate() {
43 | override fun checkValidity() {
44 | // ignore notBefore/notAfter
45 | }
46 |
47 | override fun checkValidity(date: Date) {
48 | // ignore notBefore/notAfter
49 | }
50 |
51 | override fun getVersion(): Int {
52 | return originalCertificate.version
53 | }
54 |
55 | override fun getSerialNumber(): BigInteger {
56 | return originalCertificate.serialNumber
57 | }
58 |
59 | override fun getIssuerDN(): Principal {
60 | return originalCertificate.issuerDN
61 | }
62 |
63 | override fun getSubjectDN(): Principal {
64 | return originalCertificate.subjectDN
65 | }
66 |
67 | override fun getNotBefore(): Date {
68 | return originalCertificate.notBefore
69 | }
70 |
71 | override fun getNotAfter(): Date {
72 | return originalCertificate.notAfter
73 | }
74 |
75 | @Throws(CertificateEncodingException::class)
76 | override fun getTBSCertificate(): ByteArray {
77 | return originalCertificate.tbsCertificate
78 | }
79 |
80 | override fun getSignature(): ByteArray {
81 | return originalCertificate.signature
82 | }
83 |
84 | override fun getSigAlgName(): String {
85 | return originalCertificate.sigAlgName
86 | }
87 |
88 | override fun getSigAlgOID(): String {
89 | return originalCertificate.sigAlgOID
90 | }
91 |
92 | override fun getSigAlgParams(): ByteArray {
93 | return originalCertificate.sigAlgParams
94 | }
95 |
96 | override fun getIssuerUniqueID(): BooleanArray {
97 | return originalCertificate.issuerUniqueID
98 | }
99 |
100 | override fun getSubjectUniqueID(): BooleanArray {
101 | return originalCertificate.subjectUniqueID
102 | }
103 |
104 | override fun getKeyUsage(): BooleanArray {
105 | return originalCertificate.keyUsage
106 | }
107 |
108 | override fun getBasicConstraints(): Int {
109 | return originalCertificate.basicConstraints
110 | }
111 |
112 | @Throws(CertificateEncodingException::class)
113 | override fun getEncoded(): ByteArray {
114 | return originalCertificate.encoded
115 | }
116 |
117 | @Throws(CertificateException::class, NoSuchAlgorithmException::class, InvalidKeyException::class, NoSuchProviderException::class, SignatureException::class)
118 | override fun verify(key: PublicKey) {
119 | originalCertificate.verify(key)
120 | }
121 |
122 | @Throws(CertificateException::class, NoSuchAlgorithmException::class, InvalidKeyException::class, NoSuchProviderException::class, SignatureException::class)
123 | override fun verify(key: PublicKey, sigProvider: String) {
124 | originalCertificate.verify(key, sigProvider)
125 | }
126 |
127 | override fun toString(): String {
128 | return originalCertificate.toString()
129 | }
130 |
131 | override fun getPublicKey(): PublicKey {
132 | return originalCertificate.publicKey
133 | }
134 |
135 | override fun getCriticalExtensionOIDs(): Set {
136 | return originalCertificate.criticalExtensionOIDs
137 | }
138 |
139 | override fun getExtensionValue(oid: String): ByteArray {
140 | return originalCertificate.getExtensionValue(oid)
141 | }
142 |
143 | override fun getNonCriticalExtensionOIDs(): Set {
144 | return originalCertificate.nonCriticalExtensionOIDs
145 | }
146 |
147 | override fun hasUnsupportedCriticalExtension(): Boolean {
148 | return originalCertificate.hasUnsupportedCriticalExtension()
149 | }
150 | }
151 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/MqttDoor.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger
7 |
8 | import app.trigger.https.HttpsTools
9 | import app.trigger.ssh.KeyPairBean
10 | import app.trigger.ssh.SshTools
11 | import org.json.JSONObject
12 | import java.security.cert.Certificate
13 |
14 |
15 | class MqttDoor(override var id: Int, override var name: String) : Door() {
16 | override val type = Companion.TYPE
17 | var require_wifi = false
18 | var username = ""
19 | var password = ""
20 | var server = ""
21 | var status_topic = ""
22 | var command_topic = ""
23 | var retained = false
24 | var qos = 0
25 | var open_command = ""
26 | var close_command = ""
27 | var ring_command = ""
28 | var ssids = ""
29 | var locked_pattern = ""
30 | var unlocked_pattern = ""
31 |
32 | var server_certificate: Certificate? = null
33 | var client_certificate: Certificate? = null
34 | var client_keypair: KeyPairBean? = null
35 | var ignore_certificate = false
36 | var ignore_hostname_mismatch = false
37 | var ignore_expiration = false
38 |
39 | override fun getWiFiRequired(): Boolean = require_wifi
40 | override fun getWiFiSSIDs(): String = ssids
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_command.isNotEmpty()
49 | MainActivity.Action.CLOSE_DOOR -> close_command.isNotEmpty()
50 | MainActivity.Action.RING_DOOR -> ring_command.isNotEmpty()
51 | MainActivity.Action.FETCH_STATE -> status_topic.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 |
61 | obj.put("require_wifi", require_wifi)
62 | obj.put("username", username)
63 | obj.put("password", password)
64 | obj.put("server", server)
65 | obj.put("status_topic", status_topic)
66 | obj.put("command_topic", command_topic)
67 | obj.put("retained", retained)
68 | obj.put("qos", qos)
69 |
70 | obj.put("open_command", open_command)
71 | obj.put("close_command", close_command)
72 | obj.put("ring_command", ring_command)
73 | obj.put("ssids", ssids)
74 |
75 | obj.put("unlocked_pattern", unlocked_pattern)
76 | obj.put("locked_pattern", locked_pattern)
77 | obj.put("open_image", Utils.serializeBitmap(open_image))
78 | obj.put("closed_image", Utils.serializeBitmap(closed_image))
79 | obj.put("unknown_image", Utils.serializeBitmap(unknown_image))
80 | obj.put("disabled_image", Utils.serializeBitmap(disabled_image))
81 |
82 | obj.put("server_certificate", HttpsTools.serializeCertificate(server_certificate))
83 | obj.put("client_certificate", HttpsTools.serializeCertificate(client_certificate))
84 | obj.put("client_keypair", SshTools.serializeKeyPair(client_keypair))
85 |
86 | obj.put("ignore_certificate", ignore_certificate)
87 | obj.put("ignore_hostname_mismatch", ignore_hostname_mismatch)
88 | obj.put("ignore_expiration", ignore_expiration)
89 |
90 | return obj
91 | }
92 |
93 | companion object {
94 | const val TYPE = "MqttDoorSetup"
95 |
96 | fun fromJSONObject(obj: JSONObject): MqttDoor {
97 | val id = obj.getInt("id")
98 | val name = obj.getString("name")
99 | val setup = MqttDoor(id, name)
100 |
101 | setup.require_wifi = obj.optBoolean("require_wifi", false)
102 | setup.username = obj.optString("username", "")
103 | setup.password = obj.optString("password", "")
104 | setup.server = obj.optString("server", "")
105 | setup.status_topic = obj.optString("status_topic", "")
106 | setup.command_topic = obj.optString("command_topic", "")
107 | setup.retained = obj.optBoolean("retained", false)
108 | setup.qos = obj.optInt("qos", 0)
109 | setup.open_command = obj.optString("open_command", "")
110 | setup.close_command = obj.optString("close_command", "")
111 | setup.ring_command = obj.optString("ring_command", "")
112 | setup.ssids = obj.optString("ssids", "")
113 |
114 | setup.unlocked_pattern = obj.optString("unlocked_pattern", "")
115 | setup.locked_pattern = obj.optString("locked_pattern", "")
116 |
117 | setup.open_image = Utils.deserializeBitmap(obj.optString("open_image", ""))
118 | setup.closed_image = Utils.deserializeBitmap(obj.optString("closed_image", ""))
119 | setup.unknown_image = Utils.deserializeBitmap(obj.optString("unknown_image", ""))
120 | setup.disabled_image = Utils.deserializeBitmap(obj.optString("disabled_image", ""))
121 |
122 | setup.server_certificate = HttpsTools.deserializeCertificate(obj.optString("server_certificate", ""))
123 | setup.client_certificate = HttpsTools.deserializeCertificate(obj.optString("client_certificate", ""))
124 | setup.client_keypair = SshTools.deserializeKeyPair(obj.optString("client_keypair", ""))
125 |
126 | setup.ignore_certificate = obj.optBoolean("ignore_certificate", false)
127 | setup.ignore_hostname_mismatch = obj.optBoolean("ignore_hostname_mismatch", false)
128 | setup.ignore_expiration = obj.optBoolean("ignore_expiration", false)
129 |
130 | return setup
131 | }
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/ssh/SshTools.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger.ssh
7 |
8 | import app.trigger.Log
9 | import app.trigger.Utils.byteArrayToHexString
10 | import app.trigger.Utils.hexStringToByteArray
11 | import org.json.JSONObject
12 | import org.json.JSONException
13 | import com.trilead.ssh2.crypto.PEMDecoder
14 | import com.trilead.ssh2.crypto.Base64
15 | import java.io.*
16 | import java.lang.Exception
17 | import java.security.KeyPair
18 |
19 |
20 | object SshTools {
21 | private const val TAG = "SshTools"
22 |
23 | fun serializeKeyPair(keypair: KeyPairBean?): String? {
24 | if (keypair == null) {
25 | return ""
26 | }
27 |
28 | try {
29 | val obj = JSONObject()
30 | obj.put("type", keypair.type)
31 | obj.put("privateKey", byteArrayToHexString(keypair.privateKey))
32 | obj.put("publicKey", byteArrayToHexString(keypair.publicKey))
33 | obj.put("encrypted", keypair.encrypted)
34 | return obj.toString()
35 | } catch (e: JSONException) {
36 | Log.e(TAG, "serializeKeyPair: $e")
37 | }
38 | return null
39 | }
40 |
41 | fun deserializeKeyPair(str: String?): KeyPairBean? {
42 | return if (str == null || str.length == 0) {
43 | null
44 | } else try {
45 | val obj = JSONObject(str)
46 | KeyPairBean(
47 | obj.getString("type"),
48 | hexStringToByteArray(obj.getString("privateKey")),
49 | hexStringToByteArray(obj.getString("publicKey")),
50 | obj.getBoolean("encrypted")
51 | )
52 | } catch (e: JSONException) {
53 | Log.e(TAG, "deserializeKeyPair: $e")
54 |
55 | // fallback for old
56 | deserializeKeyPair_3_2_3(str)
57 | }
58 | }
59 |
60 | fun deserializeKeyPair_3_2_3(str: String?): KeyPairBean? {
61 | if (str == null || str.length == 0) {
62 | return null
63 | }
64 | try {
65 | return parsePrivateKeyPEM(str)
66 | } catch (e: Exception) {
67 | Log.e(TAG, "deserialize error: $e")
68 | }
69 | return null
70 | }
71 |
72 | // for <= 1.9.1
73 | fun deserializeKeyPair_1_9_1(str: String?): KeyPairBean? {
74 | if (str == null || str.length == 0) {
75 | return null
76 | }
77 | try {
78 | // base64 string to bytes
79 | val bytes = Base64.decode(str.toCharArray())
80 |
81 | // bytes to KeyPairData
82 | val bais = ByteArrayInputStream(bytes)
83 | val ios = ObjectInputStream(bais)
84 | val obj = ios.readObject() as KeyPairData
85 | return parsePrivateKeyPEM(String(obj.prvkey))
86 | } catch (e: Exception) {
87 | Log.e(TAG, "deserialize error: $e")
88 | }
89 | return null
90 | }
91 |
92 | private fun readPKCS8Key(keyData: ByteArray): KeyPair? {
93 | val reader = BufferedReader(InputStreamReader(ByteArrayInputStream(keyData)))
94 |
95 | // parse the actual key once to check if its encrypted
96 | // then save original file contents into our database
97 | try {
98 | val keyBytes = ByteArrayOutputStream()
99 | var line: String?
100 | var inKey = false
101 |
102 | while (reader.readLine().also { line = it } != null) {
103 | if (line == PubkeyUtils.PKCS8_START) {
104 | inKey = true
105 | } else if (line == PubkeyUtils.PKCS8_END) {
106 | break
107 | } else if (inKey) {
108 | keyBytes.write(line!!.toByteArray(charset("US-ASCII")))
109 | }
110 | }
111 | if (keyBytes.size() > 0) {
112 | val decoded = Base64.decode(keyBytes.toString().toCharArray())
113 | return PubkeyUtils.recoverKeyPair(decoded)
114 | }
115 | } catch (e: Exception) {
116 | return null
117 | }
118 | return null
119 | }
120 |
121 | private fun convertAlgorithmName(algorithm: String): String {
122 | return if ("EdDSA" == algorithm) {
123 | KeyPairBean.KEY_TYPE_ED25519
124 | } else {
125 | algorithm
126 | }
127 | }
128 |
129 | fun parsePrivateKeyPEM(keyData: String): KeyPairBean? {
130 | val kp2 = readPKCS8Key(keyData.toByteArray())
131 | if (kp2 != null) {
132 | val algorithm = convertAlgorithmName(kp2.private.algorithm)
133 | return KeyPairBean(algorithm, kp2.private.encoded, kp2.public.encoded, false)
134 | } else {
135 | try {
136 | val struct = PEMDecoder.parsePEM(keyData.toCharArray())
137 | val encrypted = PEMDecoder.isPEMEncrypted(struct)
138 | return if (!encrypted) {
139 | val kp = PEMDecoder.decode(struct, null)
140 | val algorithm = convertAlgorithmName(kp.private.algorithm)
141 | KeyPairBean(algorithm, kp.private.encoded, kp.public.encoded, encrypted)
142 | } else {
143 | KeyPairBean(KeyPairBean.KEY_TYPE_IMPORTED, keyData.toByteArray(), ByteArray(0), encrypted)
144 | }
145 | } catch (e: IOException) {
146 | Log.e(TAG, "Problem parsing imported private key: $e")
147 | }
148 | }
149 | return null
150 | }
151 |
152 | // helper class that holds the content of the old id_rsa/id_rsa.pub file content (PEM format)
153 | private class KeyPairData(val prvkey: ByteArray, val pubkey: ByteArray) : Serializable
154 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/nuki/NukiCallback.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger.nuki
7 |
8 | import app.trigger.Utils.byteArrayToHexString
9 | import app.trigger.DoorReply.ReplyCode
10 | import android.bluetooth.BluetoothGattCharacteristic
11 | import android.bluetooth.BluetoothGattCallback
12 | import android.bluetooth.BluetoothGatt
13 | import android.bluetooth.BluetoothGattDescriptor
14 | import app.trigger.*
15 | import java.util.*
16 |
17 | 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)
18 | : BluetoothGattCallback() {
19 | /*
20 | * This is mostly called to end the connection quickly instead
21 | * of waiting for the other side to close the connection
22 | */
23 | protected fun closeConnection(gatt: BluetoothGatt) {
24 | Log.d(TAG, "closeConnection")
25 | gatt.close()
26 | NukiRequestHandler.Companion.bluetooth_in_use.set(false)
27 | }
28 |
29 | override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
30 | Log.d(TAG, "onConnectionStateChange, status: "
31 | + NukiRequestHandler.getGattStatus(status)
32 | + ", newState: " + NukiRequestHandler.getGattState(newState))
33 | if (status == BluetoothGatt.GATT_SUCCESS) {
34 | when (newState) {
35 | BluetoothGatt.STATE_CONNECTED -> gatt.discoverServices()
36 | BluetoothGatt.STATE_CONNECTING -> {}
37 | BluetoothGatt.STATE_DISCONNECTED -> closeConnection(gatt)
38 | BluetoothGatt.STATE_DISCONNECTING -> closeConnection(gatt)
39 | else -> closeConnection(gatt)
40 | }
41 | } else {
42 | closeConnection(gatt)
43 | listener.onTaskResult(
44 | door_id, action, ReplyCode.REMOTE_ERROR, "Connection error: ${NukiRequestHandler.getGattStatus(status)}"
45 | )
46 | }
47 | }
48 |
49 | override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
50 | Log.d(TAG, "onServicesDiscovered")
51 |
52 | if (status == BluetoothGatt.GATT_SUCCESS) {
53 | val service = gatt.getService(service_uuid)
54 | if (service == null) {
55 | Log.d(TAG, "Service not found: $service_uuid")
56 | closeConnection(gatt)
57 | listener.onTaskResult(
58 | door_id, action, ReplyCode.REMOTE_ERROR, "Service not found: $service_uuid"
59 | )
60 | return
61 | }
62 | val characteristic = service.getCharacteristic(characteristic_uuid)
63 | if (characteristic == null) {
64 | Log.d(TAG, "Characteristic not found: $characteristic_uuid")
65 | closeConnection(gatt)
66 | listener.onTaskResult(
67 | door_id, action, ReplyCode.REMOTE_ERROR, "Characteristic not found: $characteristic_uuid"
68 | )
69 | return
70 | }
71 | gatt.setCharacteristicNotification(characteristic, true)
72 | val descriptor = characteristic.getDescriptor(CCC_DESCRIPTOR_UUID)
73 | if (descriptor == null) {
74 | Log.d(TAG, "Descriptor not found: $CCC_DESCRIPTOR_UUID")
75 | closeConnection(gatt)
76 | listener.onTaskResult(
77 | door_id, action, ReplyCode.REMOTE_ERROR, "Descriptor not found: $CCC_DESCRIPTOR_UUID"
78 | )
79 | return
80 | }
81 |
82 | //Log.i(TAG, "characteristic properties: " + NukiTools.getProperties(characteristic));
83 | descriptor.value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
84 | val ok = gatt.writeDescriptor(descriptor)
85 | if (!ok) {
86 | Log.e(TAG, "descriptor write failed")
87 | closeConnection(gatt)
88 | }
89 | } else {
90 | Log.d(TAG, "Client not found: ${NukiRequestHandler.getGattStatus(status)}")
91 | closeConnection(gatt)
92 | listener.onTaskResult(
93 | door_id, action, ReplyCode.LOCAL_ERROR, "Client not found: ${NukiRequestHandler.getGattStatus(status)}"
94 | )
95 | }
96 | }
97 |
98 | override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
99 | Log.d(TAG, "onDescriptorWrite, uiid: ${descriptor.uuid}: ${byteArrayToHexString(descriptor.value)}")
100 |
101 | if (status == BluetoothGatt.GATT_SUCCESS) {
102 | onConnected(gatt, descriptor.characteristic)
103 | } else {
104 | Log.e(TAG, "failed to write to client: ${NukiRequestHandler.getGattStatus(status)}")
105 | closeConnection(gatt)
106 | }
107 | }
108 |
109 | abstract fun onConnected(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic)
110 |
111 | companion object {
112 | private const val TAG = "NukiCallback"
113 |
114 | // Client Characteristic Configuration Descriptor
115 | val CCC_DESCRIPTOR_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
116 |
117 | // Pairing UUIDs
118 | val PAIRING_SERVICE_UUID = UUID.fromString("a92ee100-5501-11e4-916c-0800200c9a66")
119 | val PAIRING_GDIO_XTERISTIC_UUID = UUID.fromString("a92ee101-5501-11e4-916c-0800200c9a66")
120 |
121 | // Keyturner UUIDs
122 | val KEYTURNER_SERVICE_UUID = UUID.fromString("a92ee200-5501-11e4-916c-0800200c9a66")
123 | val KEYTURNER_GDIO_XTERISTIC_UUID = UUID.fromString("a92ee201-5501-11e4-916c-0800200c9a66")
124 | val KEYTURNER_USDIO_XTERISTIC_UUID = UUID.fromString("a92ee202-5501-11e4-916c-0800200c9a66")
125 | }
126 | }
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/HttpsDoor.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger
7 |
8 | import app.trigger.https.HttpsTools
9 | import app.trigger.ssh.KeyPairBean
10 | import app.trigger.ssh.SshTools
11 | import org.json.JSONObject
12 | import java.security.cert.Certificate
13 |
14 | class HttpsDoor(override var id: Int, override var name: String) : Door() {
15 | override val type = Companion.TYPE
16 |
17 | var require_wifi = false
18 | var open_query = ""
19 | var open_method = "GET"
20 | var close_query = ""
21 | var close_method = "GET"
22 | var ring_query = ""
23 | var ring_method = "GET"
24 | var status_query = ""
25 | var status_method = "GET"
26 | var ssids = ""
27 |
28 | // regex to evaluate the door return message
29 | var unlocked_pattern = ""
30 | var locked_pattern = ""
31 |
32 | var server_certificate: Certificate? = null
33 | var client_certificate: Certificate? = null
34 | var client_keypair: KeyPairBean? = null
35 | var ignore_certificate = false
36 | var ignore_hostname_mismatch = false
37 | var ignore_expiration = false
38 |
39 | override fun getWiFiRequired(): Boolean = require_wifi
40 | override fun getWiFiSSIDs(): String = ssids
41 |
42 | // extract from known urls
43 | override fun getRegisterUrl(): String {
44 | return stripUrls(open_query, ring_query, close_query, status_query)
45 | }
46 |
47 | override fun parseReply(reply: DoorReply): DoorStatus {
48 | return Utils.genericDoorReplyParser(reply, unlocked_pattern, locked_pattern)
49 | }
50 |
51 | override fun isActionSupported(action: MainActivity.Action): Boolean {
52 | return when (action) {
53 | MainActivity.Action.OPEN_DOOR -> open_query.isNotEmpty()
54 | MainActivity.Action.CLOSE_DOOR -> close_query.isNotEmpty()
55 | MainActivity.Action.RING_DOOR -> ring_query.isNotEmpty()
56 | MainActivity.Action.FETCH_STATE -> status_query.isNotEmpty()
57 | }
58 | }
59 |
60 | fun toJSONObject(): JSONObject {
61 | val obj = JSONObject()
62 | obj.put("id", id)
63 | obj.put("name", name)
64 | obj.put("type", type)
65 | obj.put("require_wifi", require_wifi)
66 | obj.put("open_query", open_query)
67 | obj.put("open_method", open_method)
68 | obj.put("close_query", close_query)
69 | obj.put("close_method", close_method)
70 | obj.put("ring_query", ring_query)
71 | obj.put("ring_method", ring_method)
72 | obj.put("status_query", status_query)
73 | obj.put("status_method", status_method)
74 | obj.put("ssids", ssids)
75 | obj.put("unlocked_pattern", unlocked_pattern)
76 | obj.put("locked_pattern", locked_pattern)
77 |
78 | obj.put("open_image", Utils.serializeBitmap(open_image))
79 | obj.put("closed_image", Utils.serializeBitmap(closed_image))
80 | obj.put("unknown_image", Utils.serializeBitmap(unknown_image))
81 | obj.put("disabled_image", Utils.serializeBitmap(disabled_image))
82 |
83 | obj.put("server_certificate", HttpsTools.serializeCertificate(server_certificate))
84 | obj.put("client_certificate", HttpsTools.serializeCertificate(client_certificate))
85 | obj.put("client_keypair", SshTools.serializeKeyPair(client_keypair))
86 |
87 | obj.put("ignore_certificate", ignore_certificate)
88 | obj.put("ignore_hostname_mismatch", ignore_hostname_mismatch)
89 | obj.put("ignore_expiration", ignore_expiration)
90 |
91 | return obj
92 | }
93 |
94 | companion object {
95 | const val TYPE = "HttpsDoorSetup"
96 |
97 | fun fromJSONObject(obj: JSONObject): HttpsDoor {
98 | val id = obj.getInt("id")
99 | val name = obj.getString("name")
100 | val setup = HttpsDoor(id, name)
101 |
102 | val defaultMethod = obj.optString("method", "GET")
103 |
104 | setup.require_wifi = obj.optBoolean("require_wifi", false)
105 | setup.open_query = obj.optString("open_query", "")
106 | setup.open_method = obj.optString("open_method", defaultMethod)
107 | setup.close_query = obj.optString("close_query", "")
108 | setup.close_method = obj.optString("close_method", defaultMethod)
109 | setup.ring_query = obj.optString("ring_query", "")
110 | setup.ring_method = obj.optString("ring_method", defaultMethod)
111 | setup.status_query = obj.optString("status_query", "")
112 | setup.status_method = obj.optString("status_method", defaultMethod)
113 | setup.ssids = obj.optString("ssids", "")
114 | setup.unlocked_pattern = obj.optString("unlocked_pattern", "")
115 | setup.locked_pattern = obj.optString("locked_pattern", "")
116 | setup.open_image = Utils.deserializeBitmap(obj.optString("open_image", ""))
117 | setup.closed_image = Utils.deserializeBitmap(obj.optString("closed_image", ""))
118 | setup.unknown_image = Utils.deserializeBitmap(obj.optString("unknown_image", ""))
119 | setup.disabled_image = Utils.deserializeBitmap(obj.optString("disabled_image", ""))
120 | setup.server_certificate = HttpsTools.deserializeCertificate(obj.optString("server_certificate", ""))
121 | setup.client_certificate = HttpsTools.deserializeCertificate(obj.optString("client_certificate", ""))
122 | setup.client_keypair = SshTools.deserializeKeyPair(obj.optString("client_keypair", ""))
123 |
124 | setup.ignore_certificate = obj.optBoolean("ignore_certificate", false)
125 | setup.ignore_hostname_mismatch = obj.optBoolean("ignore_hostname_mismatch", false)
126 | setup.ignore_expiration = obj.optBoolean("ignore_expiration", false)
127 |
128 | return setup
129 | }
130 |
131 | private fun stripUrls(vararg urls: String): String {
132 | // remove path
133 | val prefix = "https://"
134 | for (url in urls) {
135 | if (url.startsWith(prefix)) {
136 | val i = url.indexOf('/', prefix.length)
137 | if (i > 0) {
138 | return url.substring(0, i)
139 | }
140 | }
141 | return url
142 | }
143 | return ""
144 | }
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/app/trigger/QRScanActivity.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2025 The Trigger Contributors
3 | * SPDX-License-Identifier: GPL-3.0-or-later
4 | */
5 |
6 | package app.trigger
7 |
8 | import android.Manifest
9 | import android.os.Build
10 | import android.os.Bundle
11 | import android.widget.Toast
12 | import androidx.activity.result.contract.ActivityResultContracts
13 | import androidx.appcompat.app.AppCompatActivity
14 | import androidx.appcompat.widget.Toolbar
15 | import app.trigger.ssh.SshTools
16 | import com.google.zxing.BarcodeFormat
17 | import com.google.zxing.ResultPoint
18 | import com.journeyapps.barcodescanner.BarcodeCallback
19 | import com.journeyapps.barcodescanner.BarcodeResult
20 | import com.journeyapps.barcodescanner.DecoratedBarcodeView
21 | import com.journeyapps.barcodescanner.DefaultDecoderFactory
22 | import org.json.JSONException
23 | import org.json.JSONObject
24 | import java.net.URI
25 |
26 | class QRScanActivity : AppCompatActivity(), BarcodeCallback {
27 | private lateinit var barcodeView: DecoratedBarcodeView
28 |
29 | override fun onCreate(savedInstanceState: Bundle?) {
30 | super.onCreate(savedInstanceState)
31 | setContentView(R.layout.activity_qrscan)
32 |
33 | val toolbar = findViewById(R.id.toolbar)
34 | setSupportActionBar(toolbar)
35 |
36 | barcodeView = findViewById(R.id.barcodeScannerView)
37 | if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M) {
38 | if (Utils.hasCameraPermission(this)) {
39 | startScan()
40 | } else {
41 | enabledCameraForResult.launch(Manifest.permission.CAMERA)
42 | }
43 | } else {
44 | startScan()
45 | }
46 | }
47 |
48 | private fun startScan() {
49 | val formats: Collection = listOf(BarcodeFormat.QR_CODE)
50 | barcodeView.barcodeView.decoderFactory = DefaultDecoderFactory(formats)
51 | barcodeView.initializeFromIntent(intent)
52 | barcodeView.decodeContinuous(this)
53 | }
54 |
55 | override fun onResume() {
56 | super.onResume()
57 | if (Utils.hasCameraPermission(this)) {
58 | barcodeView.resume()
59 | }
60 | }
61 |
62 | override fun onPause() {
63 | super.onPause()
64 | if (Utils.hasCameraPermission(this)) {
65 | barcodeView.pause()
66 | }
67 | }
68 |
69 | private val enabledCameraForResult = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
70 | isGranted -> if (isGranted) {
71 | startScan()
72 | } else {
73 | Toast.makeText(this, R.string.missing_camera_permission, Toast.LENGTH_LONG).show()
74 | finish()
75 | }
76 | }
77 |
78 | private fun decodeSetup(data: String): JSONObject {
79 | try {
80 | val kp = SshTools.parsePrivateKeyPEM(data)
81 | if (kp != null) {
82 | val obj = JSONObject()
83 | obj.put("type", SshDoor.TYPE)
84 | obj.put("name", Settings.getNewDoorName("SSH Door"))
85 | obj.put("keypair", SshTools.serializeKeyPair(kp))
86 | return obj
87 | } else {
88 | // assume raw link
89 | val uri = URI(data.trim { it <= ' ' })
90 | val scheme = uri.scheme
91 | val domain = uri.host
92 | val path = uri.path
93 | val query = uri.query
94 | val port = uri.port
95 | when (scheme) {
96 | "http", "https" -> {
97 | val http_server = domain + if (port > 0) ":$port" else ""
98 | val obj = JSONObject()
99 | obj.put("type", HttpsDoor.TYPE)
100 | obj.put("name", Settings.getNewDoorName(domain))
101 | obj.put("open_query", data)
102 | return obj
103 | }
104 | "mqtt", "mqtts" -> {
105 | val mqtt_server = scheme + "://" + domain + if (port > 0) ":$port" else ""
106 | val obj = JSONObject()
107 | obj.put("type", MqttDoor.TYPE)
108 | obj.put("name", Settings.getNewDoorName(domain))
109 | obj.put("server", mqtt_server)
110 | obj.put("command_topic", path)
111 | obj.put("open_command", query)
112 | return obj
113 | }
114 | "ssh" -> {
115 | val obj = JSONObject()
116 | obj.put("type", SshDoor.TYPE)
117 | obj.put("name", Settings.getNewDoorName(domain))
118 | obj.put("host", domain)
119 | obj.put("port", port)
120 | obj.put("open_command", query)
121 | return obj
122 | }
123 | else -> {}
124 | }
125 | }
126 | } catch (e: Exception) {
127 | // continue
128 | }
129 |
130 | // assume json data, throws exception otherwise
131 | return JSONObject(data)
132 | }
133 |
134 | override fun barcodeResult(result: BarcodeResult) {
135 | try {
136 | val obj = decodeSetup(result.text)
137 | // give entry a new id
138 | obj.put("id", Settings.getNewDoorIdentifier())
139 | val setup = Settings.fromJsonObject(obj)
140 | if (setup != null) {
141 | Settings.storeDoorSetup(setup)
142 | Toast.makeText(this, "Added ${setup.name}", Toast.LENGTH_LONG).show()
143 | } else {
144 | Toast.makeText(this, "Invalid QR Code", Toast.LENGTH_LONG).show()
145 | }
146 | } catch (e: JSONException) {
147 | Toast.makeText(this, "Invalid QR Code", Toast.LENGTH_LONG).show()
148 | } catch (e: IllegalAccessException) {
149 | Toast.makeText(this, "Incompatible QR Code", Toast.LENGTH_LONG).show()
150 | } catch (e: Exception) {
151 | Toast.makeText(this, e.message, Toast.LENGTH_LONG).show()
152 | }
153 | finish()
154 | }
155 |
156 | override fun possibleResultPoints(resultPoints: List) {}
157 |
158 | companion object {
159 | private const val TAG = "QRScanActivity"
160 | }
161 | }
162 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------