diff --git a/app/src/main/assets/img/icon.png b/app/src/main/assets/img/icon.png new file mode 100644 index 00000000..07f95b0a Binary files /dev/null and b/app/src/main/assets/img/icon.png differ diff --git a/app/src/main/assets/img/tsapre.png b/app/src/main/assets/img/tsapre.png new file mode 100644 index 00000000..16c05be5 Binary files /dev/null and b/app/src/main/assets/img/tsapre.png differ diff --git a/app/src/main/java/com/example/barcodescanner/feature/barcode/BarcodeActivity.kt b/app/src/main/java/com/example/barcodescanner/feature/barcode/BarcodeActivity.kt index c91db46d..91f3feb1 100644 --- a/app/src/main/java/com/example/barcodescanner/feature/barcode/BarcodeActivity.kt +++ b/app/src/main/java/com/example/barcodescanner/feature/barcode/BarcodeActivity.kt @@ -30,6 +30,7 @@ import com.example.barcodescanner.model.SearchEngine import com.example.barcodescanner.model.schema.BarcodeSchema import com.example.barcodescanner.model.schema.OtpAuth import com.example.barcodescanner.usecase.Logger +import com.example.barcodescanner.usecase.BarcodePkpassSaver import com.example.barcodescanner.usecase.save import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.CompositeDisposable @@ -200,6 +201,8 @@ class BarcodeActivity : BaseActivity(), DeleteConfirmationDialogFragment.Listene button_open_otp.setOnClickListener { openOtpInOtherApp() } button_open_bitcoin_uri.setOnClickListener { openBitcoinUrl() } button_open_link.setOnClickListener { openLink() } + button_save_as_pkpass.setOnClickListener { savePkpass() } + button_share_as_pkpass.setOnClickListener { sharePkpass() } button_save_bookmark.setOnClickListener { saveBookmark() } button_call_phone_1.setOnClickListener { callPhone(barcode.phone) } @@ -455,6 +458,41 @@ class BarcodeActivity : BaseActivity(), DeleteConfirmationDialogFragment.Listene startActivityIfExists(intent) } + private fun savePkpass() { + BarcodePkpassSaver.saveBarcodeAsPkpass(this, originalBarcode, null) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + Toast.makeText(this, R.string.activity_save_barcode_as_text_file_name_saved, Toast.LENGTH_LONG).show() + }, + { error -> + showError(error) + } + ) + .addTo(disposable) + } + + private fun sharePkpass() { + val uri = Array(1, init={i:Int -> null}) + BarcodePkpassSaver.saveBarcodeAsPkpass(this, originalBarcode, uri) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri[0], "application/vnd.apple.pkpass") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + startActivityIfExists(intent) + }, + { error -> + showError(error) + } + ) + .addTo(disposable) + } + private fun shareBarcodeAsText() { val intent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" @@ -694,6 +732,8 @@ class BarcodeActivity : BaseActivity(), DeleteConfirmationDialogFragment.Listene button_open_bitcoin_uri.isVisible = barcode.bitcoinUri.isNullOrEmpty().not() button_open_link.isVisible = barcode.url.isNullOrEmpty().not() button_save_bookmark.isVisible = barcode.schema == BarcodeSchema.BOOKMARK + button_save_as_pkpass.isVisible = barcode.schema == BarcodeSchema.BOARDINGPASS + button_share_as_pkpass.isVisible = barcode.schema == BarcodeSchema.BOARDINGPASS } private fun showButtonText() { diff --git a/app/src/main/java/com/example/barcodescanner/model/schema/BoardingPass.kt b/app/src/main/java/com/example/barcodescanner/model/schema/BoardingPass.kt index ecc72aa1..c79dd609 100644 --- a/app/src/main/java/com/example/barcodescanner/model/schema/BoardingPass.kt +++ b/app/src/main/java/com/example/barcodescanner/model/schema/BoardingPass.kt @@ -16,6 +16,7 @@ class BoardingPass( val carrier: String? = null, val flight: String? = null, val date: String? = null, + val dateJ: Int = 0, val cabin: String? = null, val seat: String? = null, val seq: String? = null, @@ -32,84 +33,99 @@ class BoardingPass( private val DATE_FORMATTER by unsafeLazy { SimpleDateFormat("d MMMM", Locale.ENGLISH) } fun parse(text: String): BoardingPass? { + try { - // M1 means single leg barcode - if (text.startsWithIgnoreCase("M1").not()) { - return null - } - // E means electronic ticket - if (text[22] != 'E') { - return null - } - val fieldSize: Int = text.slice(58..59).toInt(16) - // > is the marker for the airline specific optional fields - if (fieldSize != 0 && text[60] != '>') { - return null - } - // ^ is the mandatory security marker - if (text[60+fieldSize] != '^') { - return null - } - - val name: String = text.slice(2..21).trim() - val pnr: String = text.slice(23..29).trim() - val from: String = text.slice(30..32) - val to: String = text.slice(33..35) - val carrier: String = text.slice(36..38).trim() - val flight: String = text.slice(39..43).trim() - val dateJ: String = text.slice(44..46) - val cabin: String = text.slice(47..47) - val seat: String = text.slice(48..51).trim() - val seq: String = text.slice(52..56).trim() - // 57 is status - ignore - - val today = Calendar.getInstance() - today.set(Calendar.DAY_OF_YEAR, dateJ.toInt()) - val date: String = DATE_FORMATTER.format(today.getTime()) - var selectee : String = "" - var ticket : String = "" - var ffAirline : String = "" - var ffNo : String = "" - var fasttrack: String = "" + if (text.length < 60) { + return null + } - if (fieldSize != 0) { - val size: Int = text.slice(62..63).toInt(16) - if (size != 0 && size != 24) { + // M1 means single leg barcode + if (text.startsWithIgnoreCase("M1").not()) { + return null + } + // E means electronic ticket + if (text[22] != 'E') { + return null + } + val fieldSize: Int = text.slice(58..59).toInt(16) + // > is the marker for the airline specific optional fields + if (fieldSize != 0 && text[60] != '>') { return null } - // don't really care about the first optional field - // it's mostly baggage and checkin information - val size1: Int = text.slice(64+size..65+size).toInt(16) - // European boarding passes are 42 to have appended fasttrack - // US boarding passes are size 41 with no fasttrack - if (size1 != 0 && size1 != 41 && size1 != 42) { + // ^ is the security marker; sometimes missing on paper passes + if (text.length > 60 + fieldSize && text[60+fieldSize] != '^') { return null - } else { - ticket = text.slice(66+size..78+size).trim() - // TSA field: - // blank for not US flights - // 0 - normal cleared - // 1 - no fly - // 2 - selected for enhanced security - // 3 - precheck - selectee = text.slice(79+size..79+size) - ffAirline = text.slice(84+size..86+size).trim() - ffNo = text.slice(87+size..102+size).trim() - if (size1 == 42) { - // Y - fasttrack eligible - fasttrack = text.slice(107+size..107+size) + } + + val name = text.slice(2..21).trim() + val pnr = text.slice(23..29).trim() + val from = text.slice(30..32) + val to = text.slice(33..35) + val carrier = text.slice(36..38).trim() + val flight = text.slice(39..43).trim() + val dateJ = text.slice(44..46).toInt() + val cabin = text.slice(47..47) + val seat = text.slice(48..51).trim() + val seq = text.slice(52..56) + // 57 is status - ignore + + val today = Calendar.getInstance() + today.set(Calendar.DAY_OF_YEAR, dateJ) + val date: String = DATE_FORMATTER.format(today.getTime()) + var selectee : String? = null + var ticket : String? = null + var ffAirline : String? = null + var ffNo : String? = null + var fasttrack: String? = null + + if (fieldSize != 0) { + // don't actually use version but it must parse as an Int + @Suppress("UNUSED_VARIABLE") + val version: Int = text.slice(61..61).toInt() + val size: Int = text.slice(62..63).toInt(16) + + if (size != 0 && size < 11) { + return null + } + // don't really care about the first optional field + // it's mostly baggage and checkin information + val size1: Int = text.slice(64+size..65+size).toInt(16) + // European boarding passes are 42 to have appended fasttrack + // US boarding passes are size 41 with no fasttrack + if (size1 != 0 && (size1 < 37 || size1 > 42)) { + return null + } else { + ticket = text.slice(66+size..78+size).trim() + // TSA field: + // blank for not US flights + // 0 - normal cleared + // 1 - no fly + // 2 - selected for enhanced security + // 3 - precheck + selectee = text.slice(79+size..79+size) + ffAirline = text.slice(84+size..86+size).trim() + ffNo = text.slice(87+size..102+size).trim() + if (size1 == 42) { + // Y - fasttrack eligible + fasttrack = text.slice(107+size..107+size) + } } } - } - return BoardingPass(name, pnr, from, to, carrier, flight, date, - cabin, seat, seq, ticket, selectee, - ffAirline, ffNo, fasttrack, - text) + return BoardingPass(name, pnr, from, to, carrier, flight, date, + dateJ, cabin, seat, seq, ticket, selectee, + ffAirline, ffNo, fasttrack, + text) + } catch(e: Exception) { + // mostly number format and parse past end of string errors + return null + } } } override val schema = BarcodeSchema.BOARDINGPASS override fun toFormattedText(): String = listOf(name, pnr, "$from->$to", "$carrier$flight", date, cabin, seat, seq, ticket, selectee, "$ffAirline$ffNo", fasttrack).joinToStringNotNullOrBlankWithLineSeparator() - override fun toBarcodeText(): String = "$blob" + override fun toBarcodeText(): String { + return blob ?: "" + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/barcodescanner/usecase/BarcodePkpassSaver.kt b/app/src/main/java/com/example/barcodescanner/usecase/BarcodePkpassSaver.kt new file mode 100644 index 00000000..26e9e271 --- /dev/null +++ b/app/src/main/java/com/example/barcodescanner/usecase/BarcodePkpassSaver.kt @@ -0,0 +1,267 @@ +package com.example.barcodescanner.usecase + +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.annotation.RequiresApi +import com.example.barcodescanner.model.Barcode +import com.example.barcodescanner.model.schema.BoardingPass +import com.example.barcodescanner.extension.unsafeLazy +import io.reactivex.Completable +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.OutputStream +import java.security.MessageDigest +import java.text.SimpleDateFormat +import java.util.* +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +import android.util.Log + +object BarcodePkpassSaver { + private const val MIME_TYPE = "application/vnd.apple.pkpass" + private const val FILE_EXTENSION = ".pkpass" + private const val TAG = "BarcodePkpassSaver" + + private val DATE_FORMATTER by unsafeLazy { SimpleDateFormat("YYYY-MM-dd\'T\'HH:MMZZZZZ", Locale.ENGLISH) } + + fun saveBarcodeAsPkpass(context: Context, barcode: Barcode, uri: Array?): Completable { + Log.i(TAG, "saving barcode") + return Completable.create { emitter -> + try { + val bp = BoardingPass.parse(barcode.text) + saveToDownloads(context, barcode, bp!!, FILE_EXTENSION, MIME_TYPE, uri) + emitter.onComplete() + } catch (ex: Exception) { + Log.e(TAG, "Save zipfile", ex) + emitter.onError(ex) + } + } + } + + private fun airportTrim(name: String) : String { + return name.removeSuffix("Airport") + .removeSuffix("airport") + .trim() + .removeSuffix("International") + .removeSuffix("international") + .trim() + } + + private fun JSONArray.label(key: String, label: String, + value: String?) : JSONArray { + if (value == null) { + return this + } + + return this.put(JSONObject() + .put("key", key) + .put("label", label) + .put("value", value) + ) + } + private fun removeLeadingZeros(value: String?) : String? { + when(value) { + null -> return null + "" -> return "" + } + + for (i in 0 until value!!.length) { + if (value[i] != '0') { + return value.slice(i..value.length-1) + } + } + return "0" + } + + private fun alternate(bp: BoardingPass) : String { + if (bp.fasttrack == "Y" && bp.selectee == "3") { + return "FAST TRACK|TSA PRECHK" + } else if (bp.fasttrack == "Y") { + return "FAST TRACK" + } else if (bp.selectee == "3") { + return "TSA PRECHK" + } else { + return "" + } + } + + private fun cabin(bp: BoardingPass) : String? { + if (bp.cabin == null) { + return null + } + when (bp.cabin) { + in "R","P" -> return "Premium First" + in "F","A" -> return "First" + in "J","C","D","I","Z" -> return "Business" + "W" -> return "Premium Economy" + in "Y","B","M","S","H","K","L","N","Q","T","V","X" -> return "Economy" + } + return null + } + + private fun convertToJson(bp : BoardingPass): JSONObject { + val flight = removeLeadingZeros(bp.flight) + val date = Calendar.getInstance() + var from = bp.from!! + var to = bp.to!! + val locations = JSONArray() + val seq = if (bp.seq.isNullOrBlank()) { + null + } else { + removeLeadingZeros(bp.seq) + } + val ticket = if (bp.ticket.isNullOrBlank()) { + null + } else { + bp.ticket + } + val ff = if (bp.ffNo.isNullOrBlank()) { + null + } else { + "${bp.ffAirline} ${bp.ffNo}" + } + date.set(Calendar.DAY_OF_YEAR, bp.dateJ) + // cheat: we don't know the time, so set to current time on boarding day + val relevantDate: String = DATE_FORMATTER.format(date.getTime()) + return JSONObject() + .put("barcode", JSONObject() + .put("altText", alternate(bp)) + .put("format", "PkBarcodeFormatAztec") + .put("message", bp.blob) + .put("messageEncoding", "iso-8859-1") + ) + .put("description", "${bp.carrier} Flight ${flight} on ${bp.date} departing ${from}") + .put("locations", locations) + .put("boardingPass", JSONObject() + .put("primaryFields", JSONArray() + .label("depart", "Depart", from) + .label("arrive", "Arrive", to) + ) + .put("secondaryFields", JSONArray() + .label("passenger", "", bp.name) + .label("priorityaccess", "Cabin", cabin(bp)) + ) + .put("headerFields", JSONArray() + .label("flight", "Flight", "${bp.carrier}${flight}") + .label("gate", "Gate", "") + ) + .put("auxiliaryFields", JSONArray() + .label("group", "Group", "") + .label("seat", "Seat", removeLeadingZeros(bp.seat)) + .label("status", "Status", "On Time") + .label("terminal", "Terminal", "") + .label("boardingTime", "Departs", "") + ) + .put("backFields", JSONArray() + .label("date", "Date", bp.date) + .label("gateBoardingTime", "Boarding Time", "") + .label("record_locator", "Record Locator", bp.pnr) + .label("seq", "Sequence", seq) + .label("ff_number", "Frequent Flyer", ff) + .label("ticket_number", "Ticket", ticket) + ) + .put("transitType", "PKTransitTypeAir") + ) + .put("serialNumber", UUID.randomUUID()) + .put("organizationName", "QRAndBarcodeScanner") + .put("passTypeIdentifier", "com.example.barcodescanner") + .put("formatVersion", 1) + .put("backgroundColor", "#ff0000ff") + .put("relevantDate", relevantDate) + .put("voided", false) + } + + private fun ByteArray.toHex(): String = joinToString(separator = "") { + eachByte -> "%02x".format(eachByte) + } + + private fun ZipOutputStream.addManifest(manifest: JSONObject, fileName: String, content: ByteArray) { + val md = MessageDigest.getInstance("SHA-1") + this.putNextEntry(ZipEntry(fileName)) + this.write(content) + this.closeEntry() + md.update(content) + manifest.put(fileName, md.digest().toHex()) + } + + + private fun saveToDownloads(context: Context, barcode: Barcode, bp: BoardingPass, extension: String, mimeType: String, uri: Array?) { + val fileName = "BoardingPass_${barcode.date}$extension" + Log.i(TAG, "constructed filename $fileName") + val main = convertToJson(bp).toString() + val out = openFileOutputStream(context, fileName, mimeType, uri) + val zipout = ZipOutputStream(out) + val manifest = JSONObject() + val assets = context.getAssets() + + val icon = try { + assets.open("img/${bp.carrier}/icon.png").readBytes() + } catch(e: Exception) { + assets.open("img/icon.png").readBytes() + } + + zipout.addManifest(manifest, "pass.json", main.toByteArray()) + zipout.addManifest(manifest, "icon.png", icon) + if (bp.selectee == "3") { + val footer = assets.open("img/tsapre.png").readBytes() + zipout.addManifest(manifest, "footer.png", footer) + } + try { + val thumbnail = assets.open("img/${bp.carrier}/thumbnail.png").readBytes() + zipout.addManifest(manifest, "thumbnail.png", thumbnail) + } catch(e: Exception) { } + try { + val logo = assets.open("img/${bp.carrier}/logo.png").readBytes() + zipout.addManifest(manifest, "logo.png", logo) + } catch(e: Exception) { } + zipout.putNextEntry(ZipEntry("manifest.json")) + zipout.write(manifest.toString().toByteArray()) + zipout.close() + out.close() + } + + private fun openFileOutputStream(context: Context, fileName: String, mimeType: String, uri: Array?): OutputStream { + return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + openFileOutputStreamOldSdk(fileName, uri) + } else { + openFileOutputStreamNewSdk(context, fileName, mimeType, uri) + } + } + + @Suppress("DEPRECATION") + private fun openFileOutputStreamOldSdk(fileName: String, uri: Array?): OutputStream { + val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val file = File(dir, fileName) + if (file.exists()) { + file.delete() + } + if (uri != null) { + uri[0] = Uri.fromFile(file) + file.deleteOnExit() + } + return FileOutputStream(file) + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun openFileOutputStreamNewSdk(context: Context, fileName: String, mimeType: String, uri: Array?): OutputStream { + val resolver = context.contentResolver + val values = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, fileName) + put(MediaStore.Downloads.MIME_TYPE, mimeType) + } + val uris = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) ?: throw IOException() + if (uri != null) { + Log.i(TAG, "URI is " + uris) + uri[0] = uris + } + return resolver.openOutputStream(uris) ?: throw IOException() + } +} diff --git a/app/src/main/res/layout/activity_barcode.xml b/app/src/main/res/layout/activity_barcode.xml index a5541427..cc04588a 100644 --- a/app/src/main/res/layout/activity_barcode.xml +++ b/app/src/main/res/layout/activity_barcode.xml @@ -357,6 +357,26 @@ android:visibility="gone" tools:visibility="visible" /> + + - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 05b6ed48..f28f0bf9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -315,6 +315,8 @@ Copied to clipboard No app found for this action Cancel + Save as pkpass + Share as pkpass Increase brightness