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 }