Skip to content

Commit

Permalink
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)
  • Loading branch information
empratyush committed Feb 22, 2024
1 parent 6607772 commit 7e80019
Show file tree
Hide file tree
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
)

}
Loading

0 comments on commit 7e80019

Please sign in to comment.