Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
add support for various actionable Qr codes
Browse files Browse the repository at this point in the history
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)
empratyush committed May 20, 2024

Verified

This commit was signed with the committer’s verified signature.
empratyush Pratyush
1 parent 2db0f15 commit f05b80e
Showing 12 changed files with 610 additions and 0 deletions.
153 changes: 153 additions & 0 deletions app/src/main/java/app/grapheneos/camera/qr/data/QrCards.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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<ContentValues>().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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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()
}

}
24 changes: 24 additions & 0 deletions app/src/main/java/app/grapheneos/camera/qr/parser/GeoParser.kt
Original file line number Diff line number Diff line change
@@ -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)
)
}
12 changes: 12 additions & 0 deletions app/src/main/java/app/grapheneos/camera/qr/parser/MailParser.kt
Original file line number Diff line number Diff line change
@@ -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)
}
93 changes: 93 additions & 0 deletions app/src/main/java/app/grapheneos/camera/qr/parser/MeCardParser.kt
Original file line number Diff line number Diff line change
@@ -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("(?<!${escapeChar})${splitAt}")
val fields = rawText.splitToSequence(pattern)

var name = ""
var nickname = ""
var email = ""
var note = ""
var sound = ""
var telephoneNumber = ""
var telephoneNumberAv = ""
var birthDate = ""
var address = ""
var url = ""

for (field in fields) {
when {
field.startsWith(MECARD_KEY_NAME, ignoreCase = true) -> {
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
)

}
40 changes: 40 additions & 0 deletions app/src/main/java/app/grapheneos/camera/qr/parser/PhoneParser.kt
Original file line number Diff line number Diff line change
@@ -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
}

}
28 changes: 28 additions & 0 deletions app/src/main/java/app/grapheneos/camera/qr/parser/SmsParser.kt
Original file line number Diff line number Diff line change
@@ -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)
}
78 changes: 78 additions & 0 deletions app/src/main/java/app/grapheneos/camera/qr/parser/WifiQrParser.kt
Original file line number Diff line number Diff line change
@@ -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("(?<!${escapeChar})${splitAt}")
val fields = rawText.splitToSequence(pattern)

var ssid = ""
var type: WifiSecurityType? = null
var sharedKey = ""
var isHidden = false


for (field in fields) {
when {
field.startsWith(KYE_SSID, ignoreCase = true) -> {
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
}
}
64 changes: 64 additions & 0 deletions app/src/main/java/app/grapheneos/camera/qr/parser/vCardParser.kt
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 8 additions & 0 deletions app/src/main/java/app/grapheneos/camera/util/String.kt
Original file line number Diff line number Diff line change
@@ -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
}
6 changes: 6 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -185,4 +185,10 @@

<string name="zsl_setting_title">Use ZSL in Latency mode</string>
<string name="zsl_setting_desc">Uses Zero Shutter Lag (ZSL) in Latency mode for faster capture. Certain devices may have a buggy implementation for this.</string>

<string name="wifi_dialog_title">Wifi Details</string>
<string name="wifi_dialog_message">You can connect to %s Wi-Fi network from settings.</string>
<string name="wifi_dialog_button_positive">Copy Password</string>
<string name="wifi_dialog_button_negative">Dismiss</string>
<string name="wifi_password_clipboard_label">Wi-Fi Password</string>
</resources>

0 comments on commit f05b80e

Please sign in to comment.