taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

AndroidUtils.kt (10751B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2020 Taler Systems S.A.
      4  *
      5  * GNU Taler is free software; you can redistribute it and/or modify it under the
      6  * terms of the GNU General Public License as published by the Free Software
      7  * Foundation; either version 3, or (at your option) any later version.
      8  *
      9  * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
     10  * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11  * A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
     12  *
     13  * You should have received a copy of the GNU General Public License along with
     14  * GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15  */
     16 
     17 package net.taler.common
     18 
     19 import android.Manifest.permission.ACCESS_NETWORK_STATE
     20 import android.content.ActivityNotFoundException
     21 import android.content.ClipData
     22 import android.content.ClipboardManager
     23 import android.content.Context
     24 import android.content.Context.CONNECTIVITY_SERVICE
     25 import android.content.Intent
     26 import android.content.Intent.ACTION_SEND
     27 import android.content.Intent.EXTRA_INITIAL_INTENTS
     28 import android.graphics.BitmapFactory.decodeByteArray
     29 import android.content.Intent.EXTRA_STREAM
     30 import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
     31 import android.graphics.Bitmap
     32 import android.net.ConnectivityManager
     33 import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
     34 import android.os.Build.VERSION.SDK_INT
     35 import android.os.Looper
     36 import android.text.format.DateUtils.DAY_IN_MILLIS
     37 import android.text.format.DateUtils.FORMAT_ABBREV_ALL
     38 import android.text.format.DateUtils.FORMAT_ABBREV_MONTH
     39 import android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE
     40 import android.text.format.DateUtils.FORMAT_NO_YEAR
     41 import android.text.format.DateUtils.FORMAT_SHOW_DATE
     42 import android.text.format.DateUtils.FORMAT_SHOW_TIME
     43 import android.text.format.DateUtils.FORMAT_SHOW_YEAR
     44 import android.text.format.DateUtils.MINUTE_IN_MILLIS
     45 import android.text.format.DateUtils.formatDateTime
     46 import android.text.format.DateUtils.getRelativeTimeSpanString
     47 import android.util.Base64
     48 import android.util.Log
     49 import android.view.View
     50 import android.view.View.INVISIBLE
     51 import android.view.View.VISIBLE
     52 import android.view.inputmethod.InputMethodManager
     53 import androidx.annotation.RequiresPermission
     54 import androidx.annotation.StringRes
     55 import androidx.core.content.ContextCompat.getSystemService
     56 import androidx.core.content.FileProvider
     57 import androidx.core.content.getSystemService
     58 import androidx.fragment.app.Fragment
     59 import androidx.fragment.app.FragmentActivity
     60 import androidx.navigation.NavDirections
     61 import androidx.navigation.fragment.findNavController
     62 import kotlinx.coroutines.Dispatchers
     63 import kotlinx.coroutines.withContext
     64 import net.taler.lib.android.ErrorBottomSheet
     65 import java.io.File
     66 import java.io.FileOutputStream
     67 import java.io.IOException
     68 import androidx.core.view.isVisible
     69 import androidx.core.net.toUri
     70 
     71 fun View.fadeIn(endAction: () -> Unit = {}) {
     72     if (isVisible && alpha == 1f) return
     73     alpha = 0f
     74     visibility = VISIBLE
     75     animate().alpha(1f).withEndAction {
     76         if (context != null) endAction.invoke()
     77     }.start()
     78 }
     79 
     80 fun View.fadeOut(endAction: () -> Unit = {}) {
     81     if (visibility == INVISIBLE) return
     82     animate().alpha(0f).withEndAction {
     83         if (context == null) return@withEndAction
     84         visibility = INVISIBLE
     85         alpha = 1f
     86         endAction.invoke()
     87     }.start()
     88 }
     89 
     90 fun View.hideKeyboard() {
     91     getSystemService(context, InputMethodManager::class.java)
     92         ?.hideSoftInputFromWindow(windowToken, 0)
     93 }
     94 
     95 fun assertUiThread() {
     96     check(Looper.getMainLooper().thread == Thread.currentThread())
     97 }
     98 
     99 /**
    100  * Use this with 'when' expressions when you need it to handle all possibilities/branches.
    101  */
    102 val <T> T.exhaustive: T
    103     get() = this
    104 
    105 @RequiresPermission(ACCESS_NETWORK_STATE)
    106 fun Context.isOnline(): Boolean {
    107     val cm = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
    108     return if (SDK_INT < 29) {
    109         @Suppress("DEPRECATION")
    110         cm.activeNetworkInfo?.isConnected == true
    111     } else {
    112         val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
    113         capabilities.hasCapability(NET_CAPABILITY_INTERNET)
    114     }
    115 }
    116 
    117 fun FragmentActivity.showError(mainText: String, detailText: String = "") = ErrorBottomSheet
    118     .newInstance(mainText, detailText)
    119     .show(supportFragmentManager, "ERROR_BOTTOM_SHEET")
    120 
    121 fun FragmentActivity.showError(@StringRes mainId: Int, detailText: String = "") {
    122     showError(getString(mainId), detailText)
    123 }
    124 
    125 fun Fragment.showError(mainText: String, detailText: String = "") = ErrorBottomSheet
    126     .newInstance(mainText, detailText)
    127     .show(parentFragmentManager, "ERROR_BOTTOM_SHEET")
    128 
    129 fun Fragment.showError(@StringRes mainId: Int, detailText: String = "") {
    130     showError(getString(mainId), detailText)
    131 }
    132 
    133 fun Context.startActivitySafe(intent: Intent) {
    134     try {
    135         startActivity(intent)
    136     } catch (e: ActivityNotFoundException) {
    137         Log.e("taler-kotlin-android", "Error starting $intent", e)
    138     }
    139 }
    140 
    141 fun Context.canAppHandleUri(uri: String): Boolean {
    142     val intent = Intent(Intent.ACTION_VIEW).apply {
    143         data = uri.toUri()
    144     }
    145 
    146     return packageManager.queryIntentActivities(intent, 0).any {
    147         it.activityInfo.packageName != packageName
    148     }
    149 }
    150 
    151 fun Context.openUri(uri: String, title: String, excludeOwn: Boolean = true) {
    152     val intent = Intent(Intent.ACTION_VIEW).apply {
    153         data = uri.toUri()
    154     }
    155 
    156     if (excludeOwn) {
    157         val possiblePackageNames = mutableListOf<String>()
    158         val possibleIntents = packageManager.queryIntentActivities(intent, 0).filter {
    159             it.activityInfo.packageName != packageName
    160         }.map {
    161             val possibleIntent = Intent(intent)
    162             possibleIntent.`package` = it.activityInfo.packageName
    163             possiblePackageNames.add(it.activityInfo.packageName)
    164             return@map possibleIntent
    165         }
    166 
    167         val defaultResolveInfo = packageManager.resolveActivity(intent, 0)
    168         if (defaultResolveInfo == null || possiblePackageNames.isEmpty()) return
    169 
    170         // If there is a default app to handle the intent (which is not the app), use it.
    171         if (possiblePackageNames.contains(defaultResolveInfo.activityInfo.packageName)) {
    172             startActivitySafe(intent)
    173         } else {
    174             val chooser = Intent.createChooser(possibleIntents[0], title)
    175             chooser.putExtra(EXTRA_INITIAL_INTENTS, possibleIntents.drop(1).toTypedArray())
    176             startActivitySafe(chooser)
    177         }
    178     } else {
    179         startActivitySafe(Intent.createChooser(intent, title))
    180     }
    181 }
    182 
    183 fun Context.shareText(text: String) {
    184     val intent = Intent(Intent.ACTION_SEND).apply {
    185         putExtra(Intent.EXTRA_TEXT, text)
    186         type = "text/plain"
    187     }
    188 
    189     startActivitySafe(Intent.createChooser(intent, null))
    190 }
    191 
    192 fun Fragment.navigate(directions: NavDirections) = findNavController().navigate(directions)
    193 
    194 fun Long.toRelativeTime(context: Context): CharSequence {
    195     val now = System.currentTimeMillis()
    196     return if (now - this > DAY_IN_MILLIS * 2) {
    197         val flags = FORMAT_SHOW_TIME or FORMAT_SHOW_DATE or FORMAT_ABBREV_MONTH or FORMAT_NO_YEAR
    198         formatDateTime(context, this, flags)
    199     } else getRelativeTimeSpanString(this, now, MINUTE_IN_MILLIS, FORMAT_ABBREV_RELATIVE)
    200 }
    201 
    202 fun Long.toAbsoluteTime(context: Context): CharSequence {
    203     val flags = FORMAT_SHOW_TIME or FORMAT_SHOW_DATE or FORMAT_SHOW_YEAR
    204     return formatDateTime(context, this, flags)
    205 }
    206 
    207 fun Long.toShortDate(context: Context): CharSequence {
    208     val flags = FORMAT_SHOW_DATE or FORMAT_SHOW_YEAR or FORMAT_ABBREV_ALL
    209     return formatDateTime(context, this, flags)
    210 }
    211 
    212 fun Version.getIncompatibleStringOrNull(context: Context, otherVersion: String): String? {
    213     val other = Version.parse(otherVersion) ?: return context.getString(R.string.version_invalid)
    214     val match = compare(other) ?: return context.getString(R.string.version_invalid)
    215     if (match.compatible) return null
    216     if (match.currentCmp < 0) return context.getString(R.string.version_too_old)
    217     if (match.currentCmp > 0) return context.getString(R.string.version_too_new)
    218     throw AssertionError("$this == $other")
    219 }
    220 
    221 fun copyToClipBoard(context: Context, label: String, str: String) {
    222     val clipboard = context.getSystemService<ClipboardManager>()
    223     val clip = ClipData.newPlainText(label, str)
    224     clipboard?.setPrimaryClip(clip)
    225 }
    226 
    227 const val SHARE_QR_TEMP_PREFIX = "taler_qr_"
    228 const val SHARE_QR_SIZE = 512
    229 const val SHARE_QR_QUALITY = 90
    230 
    231 /**
    232  * Share string as QR code via sharing dialog
    233  *
    234  * NOTE: make sure to properly setup file provider
    235  * https://developer.android.com/training/secure-file-sharing/setup-sharing
    236  */
    237 suspend fun String.shareAsQrCode(context: Context, authority: String, qrBitmap: Bitmap? = null) {
    238     val qrBitmap = qrBitmap ?: QrCodeManager.makeQrCode(
    239         text = this,
    240         size = SHARE_QR_SIZE,
    241         margin = 2,
    242         errorCorrection = com.google.zxing.qrcode.decoder.ErrorCorrectionLevel.M,
    243         centerLogo = null,
    244         centerLogoSize = null,
    245         drawBackground = false,
    246         darkColor = android.graphics.Color.BLACK,
    247         lightColor = android.graphics.Color.WHITE,
    248         trimQuietZone = false,
    249     )
    250     val outputDir = context.cacheDir
    251     try {
    252         val uri = withContext(Dispatchers.IO) {
    253             val outputFile = File.createTempFile(SHARE_QR_TEMP_PREFIX, ".png", outputDir)
    254             outputFile.deleteOnExit()
    255             val stream = FileOutputStream(outputFile)
    256             qrBitmap.compress(Bitmap.CompressFormat.PNG, SHARE_QR_QUALITY, stream)
    257             stream.flush()
    258             stream.close()
    259             FileProvider.getUriForFile(context, authority, outputFile)
    260         }
    261 
    262         // TODO: also allow saving QR to files (under a human-readable name?)
    263         val intent = Intent(ACTION_SEND).apply {
    264             putExtra(EXTRA_STREAM, uri)
    265             clipData = ClipData.newRawUri("", uri)
    266             addFlags(FLAG_GRANT_READ_URI_PERMISSION)
    267             setType("image/png")
    268         }
    269 
    270         val shareIntent = Intent.createChooser(intent, null)
    271         context.startActivitySafe(shareIntent)
    272     } catch(e: IOException) {
    273         Log.d("taler-kotlin-android", "Failed to generate or store PNG image")
    274     }
    275 }
    276 
    277 private val REGEX_BASE64_IMAGE = Regex("^data:image/(jpeg|png);base64,([A-Za-z0-9+/=]+)$")
    278 
    279 val String.base64Bitmap: Bitmap?
    280     get() = REGEX_BASE64_IMAGE.matchEntire(this)?.let { match ->
    281         match.groups[2]?.value?.let { group ->
    282             val decodedString = Base64.decode(group, Base64.DEFAULT)
    283             decodeByteArray(decodedString, 0, decodedString.size)
    284         }
    285     }