taler-android

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

AndroidUtils.kt (10768B)


      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.lib.android
     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.common.R
     65 import net.taler.common.Version
     66 import java.io.File
     67 import java.io.FileOutputStream
     68 import java.io.IOException
     69 import androidx.core.view.isVisible
     70 import androidx.core.net.toUri
     71 
     72 fun View.fadeIn(endAction: () -> Unit = {}) {
     73     if (isVisible && alpha == 1f) return
     74     alpha = 0f
     75     visibility = VISIBLE
     76     animate().alpha(1f).withEndAction {
     77         if (context != null) endAction.invoke()
     78     }.start()
     79 }
     80 
     81 fun View.fadeOut(endAction: () -> Unit = {}) {
     82     if (visibility == INVISIBLE) return
     83     animate().alpha(0f).withEndAction {
     84         if (context == null) return@withEndAction
     85         visibility = INVISIBLE
     86         alpha = 1f
     87         endAction.invoke()
     88     }.start()
     89 }
     90 
     91 fun View.hideKeyboard() {
     92     getSystemService(context, InputMethodManager::class.java)
     93         ?.hideSoftInputFromWindow(windowToken, 0)
     94 }
     95 
     96 fun assertUiThread() {
     97     check(Looper.getMainLooper().thread == Thread.currentThread())
     98 }
     99 
    100 /**
    101  * Use this with 'when' expressions when you need it to handle all possibilities/branches.
    102  */
    103 val <T> T.exhaustive: T
    104     get() = this
    105 
    106 @RequiresPermission(ACCESS_NETWORK_STATE)
    107 fun Context.isOnline(): Boolean {
    108     val cm = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
    109     return if (SDK_INT < 29) {
    110         @Suppress("DEPRECATION")
    111         cm.activeNetworkInfo?.isConnected == true
    112     } else {
    113         val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
    114         capabilities.hasCapability(NET_CAPABILITY_INTERNET)
    115     }
    116 }
    117 
    118 fun FragmentActivity.showError(mainText: String, detailText: String = "") = ErrorBottomSheet
    119     .newInstance(mainText, detailText)
    120     .show(supportFragmentManager, "ERROR_BOTTOM_SHEET")
    121 
    122 fun FragmentActivity.showError(@StringRes mainId: Int, detailText: String = "") {
    123     showError(getString(mainId), detailText)
    124 }
    125 
    126 fun Fragment.showError(mainText: String, detailText: String = "") = ErrorBottomSheet
    127     .newInstance(mainText, detailText)
    128     .show(parentFragmentManager, "ERROR_BOTTOM_SHEET")
    129 
    130 fun Fragment.showError(@StringRes mainId: Int, detailText: String = "") {
    131     showError(getString(mainId), detailText)
    132 }
    133 
    134 fun Context.startActivitySafe(intent: Intent) {
    135     try {
    136         startActivity(intent)
    137     } catch (e: ActivityNotFoundException) {
    138         Log.e("taler-kotlin-android", "Error starting $intent", e)
    139     }
    140 }
    141 
    142 fun Context.canAppHandleUri(uri: String): Boolean {
    143     val intent = Intent(Intent.ACTION_VIEW).apply {
    144         data = uri.toUri()
    145     }
    146 
    147     return packageManager.queryIntentActivities(intent, 0).any {
    148         it.activityInfo.packageName != packageName
    149     }
    150 }
    151 
    152 fun Context.openUri(uri: String, title: String, excludeOwn: Boolean = true) {
    153     val intent = Intent(Intent.ACTION_VIEW).apply {
    154         data = uri.toUri()
    155     }
    156 
    157     if (excludeOwn) {
    158         val possiblePackageNames = mutableListOf<String>()
    159         val possibleIntents = packageManager.queryIntentActivities(intent, 0).filter {
    160             it.activityInfo.packageName != packageName
    161         }.map {
    162             val possibleIntent = Intent(intent)
    163             possibleIntent.`package` = it.activityInfo.packageName
    164             possiblePackageNames.add(it.activityInfo.packageName)
    165             return@map possibleIntent
    166         }
    167 
    168         val defaultResolveInfo = packageManager.resolveActivity(intent, 0)
    169         if (defaultResolveInfo == null || possiblePackageNames.isEmpty()) return
    170 
    171         // If there is a default app to handle the intent (which is not the app), use it.
    172         if (possiblePackageNames.contains(defaultResolveInfo.activityInfo.packageName)) {
    173             startActivitySafe(intent)
    174         } else {
    175             val chooser = Intent.createChooser(possibleIntents[0], title)
    176             chooser.putExtra(EXTRA_INITIAL_INTENTS, possibleIntents.drop(1).toTypedArray())
    177             startActivitySafe(chooser)
    178         }
    179     } else {
    180         startActivitySafe(Intent.createChooser(intent, title))
    181     }
    182 }
    183 
    184 fun Context.shareText(text: String) {
    185     val intent = Intent(Intent.ACTION_SEND).apply {
    186         putExtra(Intent.EXTRA_TEXT, text)
    187         type = "text/plain"
    188     }
    189 
    190     startActivitySafe(Intent.createChooser(intent, null))
    191 }
    192 
    193 fun Fragment.navigate(directions: NavDirections) = findNavController().navigate(directions)
    194 
    195 fun Long.toRelativeTime(context: Context): CharSequence {
    196     val now = System.currentTimeMillis()
    197     return if (now - this > DAY_IN_MILLIS * 2) {
    198         val flags = FORMAT_SHOW_TIME or FORMAT_SHOW_DATE or FORMAT_ABBREV_MONTH or FORMAT_NO_YEAR
    199         formatDateTime(context, this, flags)
    200     } else getRelativeTimeSpanString(this, now, MINUTE_IN_MILLIS, FORMAT_ABBREV_RELATIVE)
    201 }
    202 
    203 fun Long.toAbsoluteTime(context: Context): CharSequence {
    204     val flags = FORMAT_SHOW_TIME or FORMAT_SHOW_DATE or FORMAT_SHOW_YEAR
    205     return formatDateTime(context, this, flags)
    206 }
    207 
    208 fun Long.toShortDate(context: Context): CharSequence {
    209     val flags = FORMAT_SHOW_DATE or FORMAT_SHOW_YEAR or FORMAT_ABBREV_ALL
    210     return formatDateTime(context, this, flags)
    211 }
    212 
    213 fun Version.getIncompatibleStringOrNull(context: Context, otherVersion: String): String? {
    214     val other = Version.parse(otherVersion) ?: return context.getString(R.string.version_invalid)
    215     val match = compare(other) ?: return context.getString(R.string.version_invalid)
    216     if (match.compatible) return null
    217     if (match.currentCmp < 0) return context.getString(R.string.version_too_old)
    218     if (match.currentCmp > 0) return context.getString(R.string.version_too_new)
    219     throw AssertionError("$this == $other")
    220 }
    221 
    222 fun copyToClipBoard(context: Context, label: String, str: String) {
    223     val clipboard = context.getSystemService<ClipboardManager>()
    224     val clip = ClipData.newPlainText(label, str)
    225     clipboard?.setPrimaryClip(clip)
    226 }
    227 
    228 const val SHARE_QR_TEMP_PREFIX = "taler_qr_"
    229 const val SHARE_QR_SIZE = 512
    230 const val SHARE_QR_QUALITY = 90
    231 
    232 /**
    233  * Share string as QR code via sharing dialog
    234  *
    235  * NOTE: make sure to properly setup file provider
    236  * https://developer.android.com/training/secure-file-sharing/setup-sharing
    237  */
    238 suspend fun String.shareAsQrCode(context: Context, authority: String, qrBitmap: Bitmap? = null) {
    239     val qrBitmap = qrBitmap ?: QrCodeManager.makeQrCode(
    240         text = this,
    241         size = SHARE_QR_SIZE,
    242         margin = 2,
    243         errorCorrection = com.google.zxing.qrcode.decoder.ErrorCorrectionLevel.M,
    244         centerLogo = null,
    245         centerLogoSize = null,
    246         drawBackground = false,
    247         darkColor = android.graphics.Color.BLACK,
    248         lightColor = android.graphics.Color.WHITE,
    249         trimQuietZone = false,
    250     )
    251     val outputDir = context.cacheDir
    252     try {
    253         val uri = withContext(Dispatchers.IO) {
    254             val outputFile = File.createTempFile(SHARE_QR_TEMP_PREFIX, ".png", outputDir)
    255             outputFile.deleteOnExit()
    256             val stream = FileOutputStream(outputFile)
    257             qrBitmap.compress(Bitmap.CompressFormat.PNG, SHARE_QR_QUALITY, stream)
    258             stream.flush()
    259             stream.close()
    260             FileProvider.getUriForFile(context, authority, outputFile)
    261         }
    262 
    263         // TODO: also allow saving QR to files (under a human-readable name?)
    264         val intent = Intent(ACTION_SEND).apply {
    265             putExtra(EXTRA_STREAM, uri)
    266             clipData = ClipData.newRawUri("", uri)
    267             addFlags(FLAG_GRANT_READ_URI_PERMISSION)
    268             setType("image/png")
    269         }
    270 
    271         val shareIntent = Intent.createChooser(intent, null)
    272         context.startActivitySafe(shareIntent)
    273     } catch(e: IOException) {
    274         Log.d("taler-kotlin-android", "Failed to generate or store PNG image")
    275     }
    276 }
    277 
    278 private val REGEX_BASE64_IMAGE = Regex("^data:image/(jpeg|png);base64,([A-Za-z0-9+/=]+)$")
    279 
    280 val String.base64Bitmap: Bitmap?
    281     get() = REGEX_BASE64_IMAGE.matchEntire(this)?.let { match ->
    282         match.groups[2]?.value?.let { group ->
    283             val decodedString = Base64.decode(group, Base64.DEFAULT)
    284             decodeByteArray(decodedString, 0, decodedString.size)
    285         }
    286     }