Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Qr scanning improvement #419

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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