From f05b80e1e93f4e3b807ce1d2b9e8218ede050ee9 Mon Sep 17 00:00:00 2001 From: Pratyush Date: Thu, 22 Feb 2024 20:48:05 +0530 Subject: [PATCH 1/2] add support for various actionable Qr codes add support for `sms`, `smsto`, `tel`, `facetime`, `facetime-audio`, `geo`, `mailto`, `vcard` and `mecard` [facetime and facetime audio spec](https://developer.apple.com/library/archive/featuredarticles/iPhoneURLScheme_Reference/FacetimeLinks/FacetimeLinks.html) --- .../app/grapheneos/camera/qr/data/QrCards.kt | 153 ++++++++++++++++++ .../camera/qr/handler/MeCardIntents.kt | 66 ++++++++ .../camera/qr/handler/WifiQrIntents.kt | 38 +++++ .../grapheneos/camera/qr/parser/GeoParser.kt | 24 +++ .../grapheneos/camera/qr/parser/MailParser.kt | 12 ++ .../camera/qr/parser/MeCardParser.kt | 93 +++++++++++ .../camera/qr/parser/PhoneParser.kt | 40 +++++ .../grapheneos/camera/qr/parser/SmsParser.kt | 28 ++++ .../camera/qr/parser/WifiQrParser.kt | 78 +++++++++ .../camera/qr/parser/vCardParser.kt | 64 ++++++++ .../java/app/grapheneos/camera/util/String.kt | 8 + app/src/main/res/values/strings.xml | 6 + 12 files changed, 610 insertions(+) create mode 100644 app/src/main/java/app/grapheneos/camera/qr/data/QrCards.kt create mode 100644 app/src/main/java/app/grapheneos/camera/qr/handler/MeCardIntents.kt create mode 100644 app/src/main/java/app/grapheneos/camera/qr/handler/WifiQrIntents.kt create mode 100644 app/src/main/java/app/grapheneos/camera/qr/parser/GeoParser.kt create mode 100644 app/src/main/java/app/grapheneos/camera/qr/parser/MailParser.kt create mode 100644 app/src/main/java/app/grapheneos/camera/qr/parser/MeCardParser.kt create mode 100644 app/src/main/java/app/grapheneos/camera/qr/parser/PhoneParser.kt create mode 100644 app/src/main/java/app/grapheneos/camera/qr/parser/SmsParser.kt create mode 100644 app/src/main/java/app/grapheneos/camera/qr/parser/WifiQrParser.kt create mode 100644 app/src/main/java/app/grapheneos/camera/qr/parser/vCardParser.kt create mode 100644 app/src/main/java/app/grapheneos/camera/util/String.kt diff --git a/app/src/main/java/app/grapheneos/camera/qr/data/QrCards.kt b/app/src/main/java/app/grapheneos/camera/qr/data/QrCards.kt new file mode 100644 index 000000000..d82f9c4e9 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/qr/data/QrCards.kt @@ -0,0 +1,153 @@ +package app.grapheneos.camera.qr.data + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.appcompat.view.ContextThemeWrapper +import androidx.core.net.MailTo +import app.grapheneos.camera.R +import app.grapheneos.camera.qr.handler.addToContact +import app.grapheneos.camera.qr.handler.convertWifiQrDataToIntent +import app.grapheneos.camera.qr.parser.vcardToIntent +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +sealed class QrIntent { + abstract fun startIntent(context: Context): Boolean +} + +enum class WifiSecurityType { + Open, + WPA, + WPA2, + WPA3 +} + +data class Wifi( + val ssid: String, + val securityType: WifiSecurityType, + val sharedKey: String, + val isHidden: Boolean +) : QrIntent() { + override fun startIntent(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + context.startActivity(convertWifiQrDataToIntent(this)) + } else { + showWifiDialog(context) + } + return true + } + + private fun showWifiDialog(context: Context) { + val dialogContext = ContextThemeWrapper( + context, + com.google.android.material.R.style.Theme_MaterialComponents_DayNight + ) + MaterialAlertDialogBuilder(dialogContext) + .setTitle(R.string.wifi_dialog_title) + .setMessage(context.getString(R.string.wifi_dialog_message, ssid)) + .setPositiveButton(R.string.wifi_dialog_button_positive) { _, _ -> + copySharedKeyToClipboard(context) + } + .setNegativeButton(R.string.wifi_dialog_button_negative, null) + .show() + } + + private fun copySharedKeyToClipboard(context: Context) { + val sharedKeyClipData = ClipData.newPlainText( + context.getString(R.string.wifi_password_clipboard_label), + sharedKey + ) + context.getSystemService(ClipboardManager::class.java).setPrimaryClip(sharedKeyClipData) + } + +} + +data class SMS(val number: String, val message: String) : QrIntent() { + + companion object { + private const val EXTRA_SMS_BODY = "sms_body" + private const val SMS_URI = "sms" + } + + override fun startIntent(context: Context): Boolean { + val messageIntent = Intent(Intent.ACTION_SENDTO).apply { + data = Uri.parse("$SMS_URI:$number") + putExtra(EXTRA_SMS_BODY, message) + putExtra(Intent.EXTRA_TEXT, message) + } + context.startActivity(Intent.createChooser(messageIntent, null)) + return true + } +} + +data class Phone(val number: Int) : QrIntent() { + override fun startIntent(context: Context): Boolean { + context.startActivity( + Intent.createChooser( + Intent(Intent.ACTION_DIAL).apply { + data = Uri.parse("tel:${number}") + }, null + ) + ) + return true + } +} + +data class Mail(val mailTo: MailTo, val uri: Uri) : QrIntent() { + override fun startIntent(context: Context): Boolean { + context.startActivity( + Intent.createChooser( + Intent(Intent.ACTION_SENDTO, uri), null + ) + ) + return true + } +} + +data class GEO(val lat: String, val long: String, val altitude: String, val uri: Uri) : QrIntent() { + override fun startIntent(context: Context): Boolean { + context.startActivity( + Intent.createChooser( + Intent(Intent.ACTION_VIEW).apply { + data = uri + }, null + ) + ) + return true + } +} + +data class VCard(val input: String) : QrIntent() { + override fun startIntent(context: Context): Boolean { + context.startActivity(vcardToIntent(input, context)) + return true + } +} + +data class MeCard( + + val name: String, + val email: String, + val note: String, + val sound: String, + val telephoneNumber: String, + + //supported in v2+// + + val telephoneNumberAv: String, + + //supported in v3+// + + val birthDate: String, //YYYY-MM-DD + val address: String, + val nickName: String, + val url: String +) : QrIntent() { + override fun startIntent(context: Context): Boolean { + context.startActivity(addToContact()) + return true + } +} diff --git a/app/src/main/java/app/grapheneos/camera/qr/handler/MeCardIntents.kt b/app/src/main/java/app/grapheneos/camera/qr/handler/MeCardIntents.kt new file mode 100644 index 000000000..b8e5da17d --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/qr/handler/MeCardIntents.kt @@ -0,0 +1,66 @@ +package app.grapheneos.camera.qr.handler + + +import android.content.ContentValues +import android.content.Intent +import android.provider.ContactsContract +import android.provider.ContactsContract.CommonDataKinds +import android.provider.ContactsContract.CommonDataKinds.Nickname +import android.provider.ContactsContract.CommonDataKinds.Website +import android.provider.ContactsContract.Intents +import app.grapheneos.camera.qr.data.MeCard + +private fun urlToContactField(url: String, type: Int = Website.TYPE_HOMEPAGE): ContentValues { + return ContentValues().apply { + put(ContactsContract.Data.MIMETYPE, Website.CONTENT_ITEM_TYPE) + put(Website.TYPE, type) + put(Website.URL, url) + } +} + +private fun nickNameToContactField(name: String, type: Int = Nickname.TYPE_DEFAULT): ContentValues { + return ContentValues().apply { + put(ContactsContract.Data.MIMETYPE, Nickname.CONTENT_ITEM_TYPE) + put(Nickname.TYPE, type) + put(Nickname.NAME, name) + } +} + +private fun birthdayToContactField(date: String): ContentValues { + return ContentValues().apply { + put(ContactsContract.Data.MIMETYPE, CommonDataKinds.Event.CONTENT_ITEM_TYPE) + put(CommonDataKinds.Event.TYPE, CommonDataKinds.Event.TYPE_BIRTHDAY) + put(CommonDataKinds.Event.START_DATE, date) + } +} + +private fun phoneticNameToContactField(name: String): ContentValues { + return ContentValues().apply { + put(ContactsContract.Data.MIMETYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) + put(CommonDataKinds.StructuredName.DISPLAY_NAME, name) + } +} + +fun MeCard.addToContact(): Intent { + + val data = ArrayList().apply { + if (url.isNotBlank()) add(urlToContactField(url)) + if (nickName.isNotBlank()) add(nickNameToContactField(nickName)) + if (birthDate.isNotBlank()) add(birthdayToContactField(birthDate)) + if (sound.isNotBlank()) add(phoneticNameToContactField(sound)) + } + + return Intent(Intents.Insert.ACTION).apply { + type = ContactsContract.RawContacts.CONTENT_TYPE + + if (name.isNotBlank()) putExtra(Intents.Insert.NAME, name) + if (email.isNotBlank()) putExtra(Intents.Insert.EMAIL, email) + if (note.isNotBlank()) putExtra(Intents.Insert.NOTES, note) + if (telephoneNumber.isNotBlank()) putExtra(Intents.Insert.PHONE, telephoneNumber) + if (address.isNotBlank()) putExtra(Intents.Insert.POSTAL, address) + if (data.isNotEmpty()) putParcelableArrayListExtra(Intents.Insert.DATA, data) + if (telephoneNumberAv.isNotBlank()) { + putExtra(Intents.Insert.SECONDARY_PHONE, telephoneNumberAv) + } + } +} diff --git a/app/src/main/java/app/grapheneos/camera/qr/handler/WifiQrIntents.kt b/app/src/main/java/app/grapheneos/camera/qr/handler/WifiQrIntents.kt new file mode 100644 index 000000000..f587ec64b --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/qr/handler/WifiQrIntents.kt @@ -0,0 +1,38 @@ +package app.grapheneos.camera.qr.handler + +import android.content.Intent +import android.net.wifi.WifiNetworkSuggestion +import android.os.Build +import android.provider.Settings +import androidx.annotation.RequiresApi +import app.grapheneos.camera.qr.data.Wifi +import app.grapheneos.camera.qr.data.WifiSecurityType + +@RequiresApi(Build.VERSION_CODES.R) +fun convertWifiQrDataToIntent(wifi: Wifi): Intent { + + return Intent(Settings.ACTION_WIFI_ADD_NETWORKS).apply { + putExtra( + Settings.EXTRA_WIFI_NETWORK_LIST, + arrayListOf(convertWifiQrDataToWifiNetworkSuggestion(wifi)) + ) + } +} + +fun convertWifiQrDataToWifiNetworkSuggestion(wifi: Wifi): WifiNetworkSuggestion { + + val builder = WifiNetworkSuggestion.Builder() + .setIsHiddenSsid(wifi.isHidden) + .setSsid(wifi.ssid) + + return when (wifi.securityType) { + WifiSecurityType.Open -> builder.build() + + WifiSecurityType.WPA, WifiSecurityType.WPA2 -> + builder.setWpa2Passphrase(wifi.sharedKey) + .build() + + WifiSecurityType.WPA3 -> builder.setWpa3Passphrase(wifi.sharedKey).build() + } + +} diff --git a/app/src/main/java/app/grapheneos/camera/qr/parser/GeoParser.kt b/app/src/main/java/app/grapheneos/camera/qr/parser/GeoParser.kt new file mode 100644 index 000000000..cad6ee053 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/qr/parser/GeoParser.kt @@ -0,0 +1,24 @@ +package app.grapheneos.camera.qr.parser + +import android.net.Uri +import app.grapheneos.camera.qr.data.GEO +import app.grapheneos.camera.util.removePrefixCaseInsensitive +import java.util.regex.Pattern + +const val KEY_GEO = "geo:" + +fun parseGeo(input: String): GEO? { + + if (!input.startsWith(KEY_GEO, ignoreCase = true)) return null + val rawText = input.removePrefixCaseInsensitive(KEY_GEO) + val geoDividerFinder = Regex(Pattern.quote(",")) + val parts = rawText.split(geoDividerFinder) + val defaultValue = "" + + return GEO( + lat = parts.getOrElse(0) { defaultValue }, + long = parts.getOrElse(1) { defaultValue }, + altitude = parts.getOrElse(2) { defaultValue }, + uri = Uri.parse(input) + ) +} diff --git a/app/src/main/java/app/grapheneos/camera/qr/parser/MailParser.kt b/app/src/main/java/app/grapheneos/camera/qr/parser/MailParser.kt new file mode 100644 index 000000000..f7bd887dd --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/qr/parser/MailParser.kt @@ -0,0 +1,12 @@ +package app.grapheneos.camera.qr.parser + +import android.net.Uri +import androidx.core.net.MailTo +import app.grapheneos.camera.qr.data.Mail + +fun parseMail(input: String): Mail? { + val uri = Uri.parse(input) ?: return null + if (!MailTo.isMailTo(uri)) return null + val mailTo = MailTo.parse(uri) + return Mail(mailTo, uri) +} diff --git a/app/src/main/java/app/grapheneos/camera/qr/parser/MeCardParser.kt b/app/src/main/java/app/grapheneos/camera/qr/parser/MeCardParser.kt new file mode 100644 index 000000000..58ce0b6d8 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/qr/parser/MeCardParser.kt @@ -0,0 +1,93 @@ +package app.grapheneos.camera.qr.parser + +import app.grapheneos.camera.qr.data.MeCard +import app.grapheneos.camera.util.removePrefixCaseInsensitive + +const val KEY_MECARD = "MECARD:" +const val MECARD_KEY_ADDRESS = "ADR:" +const val MECARD_KEY_BIRTHDAY = "BDAY:" +const val MECARD_KEY_EMAIL = "EMAIL:" +const val MECARD_KEY_NAME = "N:" +const val MECARD_KYE_NICKNAME = "NICKNAME:" +const val MECARD_KEY_NOTE = "NOTE:" +const val MECARD_KYE_SOUND = "SOUND:" +const val MECARD_KEY_TELEPHONE = "TEL:" +const val MECARD_KEY_TELEPHONE_AV = "TEL-AV:" +const val MECARD_KEY_URL = "URL:" + +fun parseMeCard(input: String): MeCard? { + + if (!input.startsWith(KEY_MECARD, ignoreCase = true)) { + return null + } + + val rawText = input.removePrefixCaseInsensitive(KEY_MECARD) + + val escapeChar = Regex.escape("\\") + val splitAt = Regex.escape(";") + val pattern = Regex("(? { + name = field.removePrefixCaseInsensitive(MECARD_KEY_NAME) + } + + field.startsWith(MECARD_KYE_NICKNAME, ignoreCase = true) -> { + nickname = field.removePrefixCaseInsensitive(MECARD_KYE_NICKNAME) + } + + field.startsWith(MECARD_KYE_SOUND, ignoreCase = true) -> { + sound = field.removePrefixCaseInsensitive(MECARD_KYE_SOUND) + } + + field.startsWith(MECARD_KEY_ADDRESS, ignoreCase = true) -> { + address = field.removePrefixCaseInsensitive(MECARD_KEY_ADDRESS) + } + + field.startsWith(MECARD_KEY_TELEPHONE, ignoreCase = true) -> { + telephoneNumber = field.removePrefixCaseInsensitive(MECARD_KEY_TELEPHONE) + } + + field.startsWith(MECARD_KEY_TELEPHONE_AV, ignoreCase = true) -> { + telephoneNumberAv = field.removePrefixCaseInsensitive(MECARD_KEY_TELEPHONE_AV) + } + + field.startsWith(MECARD_KEY_EMAIL, ignoreCase = true) -> { + email = field.removePrefixCaseInsensitive(MECARD_KEY_EMAIL) + } + + field.startsWith(MECARD_KEY_URL, ignoreCase = true) -> { + url = field.removePrefixCaseInsensitive(MECARD_KEY_URL) + } + + field.startsWith(MECARD_KEY_NOTE, ignoreCase = true) -> { + note = field.removePrefixCaseInsensitive(MECARD_KEY_NOTE) + } + + field.startsWith(MECARD_KEY_BIRTHDAY, ignoreCase = true) -> { + birthDate = field.removePrefixCaseInsensitive(MECARD_KEY_BIRTHDAY) + } + + } + } + + return MeCard( + name = name, email = email, note = note, sound = sound, + telephoneNumber = telephoneNumber, telephoneNumberAv = telephoneNumberAv, + birthDate = birthDate, address = address, nickName = nickname, url = url + ) + +} diff --git a/app/src/main/java/app/grapheneos/camera/qr/parser/PhoneParser.kt b/app/src/main/java/app/grapheneos/camera/qr/parser/PhoneParser.kt new file mode 100644 index 000000000..a098d54e0 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/qr/parser/PhoneParser.kt @@ -0,0 +1,40 @@ +package app.grapheneos.camera.qr.parser + +import android.util.Patterns +import app.grapheneos.camera.qr.data.Phone +import app.grapheneos.camera.util.removePrefixCaseInsensitive + +const val KEY_PHONE = "tel:" +const val KEY_FACETIME = "facetime:" +const val KEY_FACETIME_AUDIO = "facetime-audio:" + +fun parsePhoneOrFacetime(input: String): Phone? { + return when { + input.startsWith(KEY_PHONE, ignoreCase = true) -> { + + val rawText = input.removePrefixCaseInsensitive(KEY_PHONE).replace("-", "") + if (!Patterns.PHONE.matcher(rawText).find()) { + return null + } + val phoneNumber = rawText.toIntOrNull() ?: 0 + return Phone(phoneNumber) + } + + input.startsWith(KEY_FACETIME, ignoreCase = true) || + input.startsWith(KEY_FACETIME_AUDIO, ignoreCase = true) -> { + + val rawText = input.removePrefixCaseInsensitive(KEY_FACETIME) + .removePrefixCaseInsensitive(KEY_FACETIME_AUDIO) + .replace("-", "") + + if (!Patterns.PHONE.matcher(rawText).find()) { + return null + } + val number = rawText.toIntOrNull() ?: 0 + return Phone(number) + } + + else -> null + } + +} diff --git a/app/src/main/java/app/grapheneos/camera/qr/parser/SmsParser.kt b/app/src/main/java/app/grapheneos/camera/qr/parser/SmsParser.kt new file mode 100644 index 000000000..fe87f357a --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/qr/parser/SmsParser.kt @@ -0,0 +1,28 @@ +package app.grapheneos.camera.qr.parser + +import app.grapheneos.camera.qr.data.SMS +import app.grapheneos.camera.util.removePrefixCaseInsensitive +import java.util.regex.Pattern +import kotlin.math.min + +const val KEY_SMSTO = "smsto:" +const val KEY_SMS = "sms:" + +fun parseSMS(input: String): SMS? { + + if (!input.startsWith(KEY_SMSTO, ignoreCase = true) && + !input.startsWith(KEY_SMS, ignoreCase = true) + ) { + return null + } + + val rawText = input.removePrefixCaseInsensitive(KEY_SMSTO).removePrefixCaseInsensitive(KEY_SMS) + + val numberEndMatch = Regex(Pattern.quote(":")).find(rawText) + val numberEndIndex = numberEndMatch?.range?.endInclusive ?: rawText.length + + val number = rawText.substring(0, numberEndIndex) + val message = rawText.substring(min(numberEndIndex.plus(1), rawText.length), rawText.length) + + return SMS(number, message) +} diff --git a/app/src/main/java/app/grapheneos/camera/qr/parser/WifiQrParser.kt b/app/src/main/java/app/grapheneos/camera/qr/parser/WifiQrParser.kt new file mode 100644 index 000000000..3f8213985 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/qr/parser/WifiQrParser.kt @@ -0,0 +1,78 @@ +package app.grapheneos.camera.qr.parser + +import app.grapheneos.camera.qr.data.Wifi +import app.grapheneos.camera.qr.data.WifiSecurityType +import app.grapheneos.camera.util.removePrefixCaseInsensitive + +const val WIFI_BEGINNING = "WIFI:" +const val KYE_SSID = "S:" +const val KYE_SECURITY_TYPE = "T:" +const val KYE_SHARED_KEY = "P:" +const val KYE_IS_HIDDEN = "H:" + +fun parseWifi(input: String): Wifi? { + if (!input.startsWith(WIFI_BEGINNING, ignoreCase = true)) { + return null + } + + val rawText = input.removePrefixCaseInsensitive(WIFI_BEGINNING) + + val escapeChar = Regex.escape("\\") + val splitAt = Regex.escape(";") + val pattern = Regex("(? { + ssid = field.removePrefixCaseInsensitive(KYE_SSID) + } + + field.startsWith(KYE_SECURITY_TYPE, ignoreCase = true) -> { + type = wifiQrTypeToSecurityType( + field.removePrefixCaseInsensitive(KYE_SECURITY_TYPE) + ) + } + + field.startsWith(KYE_SHARED_KEY, ignoreCase = true) -> { + sharedKey = field.removePrefixCaseInsensitive(KYE_SHARED_KEY) + } + + field.startsWith(KYE_IS_HIDDEN, ignoreCase = true) -> { + isHidden = field.removePrefixCaseInsensitive(KYE_IS_HIDDEN) == "true" + } + } + + } + + if (ssid.isBlank() || + type == null || + (type != WifiSecurityType.Open && sharedKey.isEmpty()) + ) { + return null + } + + return Wifi( + ssid, + type, + sharedKey, + isHidden + ) +} + + +private fun wifiQrTypeToSecurityType(type: String): WifiSecurityType? { + return when (type.uppercase()) { + "NOPASS" -> WifiSecurityType.Open + "WPA" -> WifiSecurityType.WPA + "WPA2" -> WifiSecurityType.WPA2 + "WPA3" -> WifiSecurityType.WPA3 + else -> null + } +} diff --git a/app/src/main/java/app/grapheneos/camera/qr/parser/vCardParser.kt b/app/src/main/java/app/grapheneos/camera/qr/parser/vCardParser.kt new file mode 100644 index 000000000..13906380d --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/qr/parser/vCardParser.kt @@ -0,0 +1,64 @@ +package app.grapheneos.camera.qr.parser + +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Environment +import android.provider.MediaStore +import android.webkit.MimeTypeMap +import app.grapheneos.camera.qr.data.VCard +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +const val VCARD_BEGINNING = "BEGIN:VCARD" +const val VCARD_ENDING = "END:VCARD" + +fun parseVCard(input: String): VCard? { + if (!input.startsWith(VCARD_BEGINNING, ignoreCase = true) + || !input.endsWith(VCARD_ENDING, ignoreCase = true) + ) { + return null + } + + return VCard(input) +} + +fun vcardToIntent(input: String, context: Context): Intent { + val time = SimpleDateFormat("yyyy-MM-dd_HH-mmss-SSS", Locale.US).format(Date()) + val name = "vcard-${time}.vcf" + val uri = saveViaMediaStore(context.contentResolver, name, input) + + val type = MimeTypeMap.getSingleton().getMimeTypeFromExtension("vcf") + return Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, type) + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } +} + +private fun saveViaMediaStore( + contentResolver: ContentResolver, + name: String, + content: String +): Uri? { + val values = ContentValues() + values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + values.put(MediaStore.MediaColumns.IS_PENDING, true) + values.put(MediaStore.MediaColumns.DISPLAY_NAME, name) + + val target = MediaStore.Downloads.EXTERNAL_CONTENT_URI + val uri = contentResolver.insert(target, values) ?: return null + + contentResolver.openOutputStream(uri, "rw")?.use { + it.write(content.toByteArray()) + } + + contentResolver.update(uri, ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, false) + }, null, null) + + return uri +} diff --git a/app/src/main/java/app/grapheneos/camera/util/String.kt b/app/src/main/java/app/grapheneos/camera/util/String.kt new file mode 100644 index 000000000..8bbecaf47 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/util/String.kt @@ -0,0 +1,8 @@ +package app.grapheneos.camera.util + +fun String.removePrefixCaseInsensitive(prefix: String): String { + if (startsWith(prefix, ignoreCase = true)) { + return substring(prefix.length) + } + return this +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8503d0a16..8c537de4a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -185,4 +185,10 @@ Use ZSL in Latency mode Uses Zero Shutter Lag (ZSL) in Latency mode for faster capture. Certain devices may have a buggy implementation for this. + + Wifi Details + You can connect to %s Wi-Fi network from settings. + Copy Password + Dismiss + Wi-Fi Password From df1e960197c66666771c7dde562210b15422915a Mon Sep 17 00:00:00 2001 From: Pratyush Date: Thu, 22 Feb 2024 15:56:38 +0530 Subject: [PATCH 2/2] show action dialog for supported actionable qr codes --- .../camera/ui/activities/MainActivity.kt | 5 + .../camera/ui/dialog/ActionableQrDialog.kt | 100 ++++++++++++++++++ app/src/main/res/values/strings.xml | 21 ++++ 3 files changed, 126 insertions(+) create mode 100644 app/src/main/java/app/grapheneos/camera/ui/dialog/ActionableQrDialog.kt diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt index 5c35c06da..ea34a7a86 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt @@ -87,6 +87,7 @@ import app.grapheneos.camera.ui.CustomGrid import app.grapheneos.camera.ui.QROverlay import app.grapheneos.camera.ui.QRToggle import app.grapheneos.camera.ui.SettingsDialog +import app.grapheneos.camera.ui.dialog.showActionableDialog import app.grapheneos.camera.ui.seekbar.ExposureBar import app.grapheneos.camera.ui.seekbar.ZoomBar import app.grapheneos.camera.util.CameraControl @@ -1127,6 +1128,10 @@ open class MainActivity : AppCompatActivity(), isQRDialogShowing = true + if (showActionableDialog(this, rawText) { isQRDialogShowing = false }) { + return + } + val hString = bytesToHex( rawText.toByteArray(StandardCharsets.UTF_8) ) diff --git a/app/src/main/java/app/grapheneos/camera/ui/dialog/ActionableQrDialog.kt b/app/src/main/java/app/grapheneos/camera/ui/dialog/ActionableQrDialog.kt new file mode 100644 index 000000000..374f36bab --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/dialog/ActionableQrDialog.kt @@ -0,0 +1,100 @@ +package app.grapheneos.camera.ui.dialog + +import android.app.Activity +import androidx.annotation.StringRes +import androidx.appcompat.view.ContextThemeWrapper +import app.grapheneos.camera.R +import app.grapheneos.camera.qr.data.GEO +import app.grapheneos.camera.qr.data.Mail +import app.grapheneos.camera.qr.data.MeCard +import app.grapheneos.camera.qr.data.Phone +import app.grapheneos.camera.qr.data.SMS +import app.grapheneos.camera.qr.data.VCard +import app.grapheneos.camera.qr.data.Wifi +import app.grapheneos.camera.qr.parser.parseGeo +import app.grapheneos.camera.qr.parser.parseMail +import app.grapheneos.camera.qr.parser.parseMeCard +import app.grapheneos.camera.qr.parser.parsePhoneOrFacetime +import app.grapheneos.camera.qr.parser.parseSMS +import app.grapheneos.camera.qr.parser.parseVCard +import app.grapheneos.camera.qr.parser.parseWifi +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +private data class DialogContent( + @StringRes val title: Int, + val message: String, + @StringRes val action: Int +) + +fun showActionableDialog(activity: Activity, rawContent: String, onDismiss: () -> Unit): Boolean { + + val card = parsePhoneOrFacetime(rawContent) + ?: parseSMS(rawContent) + ?: parseGeo(rawContent) + ?: parseMeCard(rawContent) + ?: parseMail(rawContent) + ?: parseVCard(rawContent) + ?: parseWifi(rawContent) + ?: return false + + val (title, message, action) = when (card) { + is GEO -> DialogContent( + R.string.address, + activity.getString(R.string.address_message, card.long, card.lat), + R.string.open_in_maps + ) + + is MeCard -> DialogContent( + R.string.contact_card_me_card, + activity.getString(R.string.mecard_message), + R.string.add_to_contacts + ) + + is Phone -> DialogContent( + R.string.phone, + activity.getString(R.string.call_message, "${card.number}"), + R.string.call + ) + + is SMS -> DialogContent( + R.string.message, + activity.getString(R.string.sms_message, card.number), + R.string.message + ) + + is Mail -> DialogContent( + R.string.mail, + activity.getString(R.string.mail_message, card.mailTo.to), + R.string.mail, + ) + + is VCard -> DialogContent( + R.string.contact_card_vcard, + activity.getString(R.string.vcard_message), + R.string.add_to_contacts + ) + + is Wifi -> DialogContent( + R.string.connect_to_wifi, + activity.getString(R.string.wifi_message, card.ssid), + R.string.connect + ) + } + + activity.runOnUiThread { + val dialogContext = ContextThemeWrapper( + activity, + com.google.android.material.R.style.Theme_MaterialComponents_DayNight + ) + MaterialAlertDialogBuilder(dialogContext) + .setTitle(title) + .setMessage(message) + .setOnDismissListener { onDismiss() } + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(action) { _, _ -> + card.startIntent(activity) + }.show() + } + + return true +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c537de4a..7ed9b3678 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -186,6 +186,27 @@ Use ZSL in Latency mode Uses Zero Shutter Lag (ZSL) in Latency mode for faster capture. Certain devices may have a buggy implementation for this. + Address + Contact Card (MeCard) + Contact Card (vCard) + Connect To Network (Wi-Fi) + Phone + Message + Email + + Navigate + Call + Add to contacts + Connect + + Do you want to navigate to longitude %s and latitude %s? + Do you want to save this contact info? + Do you want to import this contact info? + Do you want to connect to %s Wi-Fi network? + Do you want to call %s? + Do you want to chat with %s? + Do you want to send email to %s? + Wifi Details You can connect to %s Wi-Fi network from settings. Copy Password