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 }