From e9ba600c07749f575b5bfcc07ad244a768bbe25e Mon Sep 17 00:00:00 2001 From: Tamim Hossain <132823494+codewithtamim@users.noreply.github.com> Date: Tue, 2 Dec 2025 02:10:43 +0600 Subject: [PATCH] feat : Hide QR code and Account ID feat : Hide QR code and Account ID --- core/build.gradle | 2 +- core/src/main/cpp/CMakeLists.txt | 5 + core/src/main/cpp/blur/blurlib.cpp | 330 ++++++++++++++++++ core/src/main/cpp/blur/blurlib.h | 54 +++ .../java/net/ivpn/core/IVPNApplication.kt | 7 + .../ivpn/core/v2/account/AccountFragment.kt | 14 + .../core/v2/account/widget/MaskedImageView.kt | 281 +++++++++++++++ .../core/v2/account/widget/MaskedTextView.kt | 252 +++++++++++++ .../res/drawable/outline_visibility_24.xml | 5 + .../drawable/outline_visibility_off_24.xml | 5 + core/src/main/res/layout/content_account.xml | 4 +- fdroid/build.gradle | 2 +- liboqs-android/build.gradle | 2 +- site/build.gradle | 2 +- store/build.gradle | 2 +- 15 files changed, 960 insertions(+), 7 deletions(-) create mode 100644 core/src/main/cpp/blur/blurlib.cpp create mode 100644 core/src/main/cpp/blur/blurlib.h create mode 100644 core/src/main/java/net/ivpn/core/v2/account/widget/MaskedImageView.kt create mode 100644 core/src/main/java/net/ivpn/core/v2/account/widget/MaskedTextView.kt create mode 100644 core/src/main/res/drawable/outline_visibility_24.xml create mode 100644 core/src/main/res/drawable/outline_visibility_off_24.xml diff --git a/core/build.gradle b/core/build.gradle index 35c92a534..b30ab8eef 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -34,7 +34,7 @@ android { targetSdkVersion 35 versionCode 139 versionName "2.11.1" - ndkVersion "25.1.8937393" + ndkVersion "29.0.13113456" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments clearPackageData: 'true' diff --git a/core/src/main/cpp/CMakeLists.txt b/core/src/main/cpp/CMakeLists.txt index b744aeb6e..dc2ae17e4 100644 --- a/core/src/main/cpp/CMakeLists.txt +++ b/core/src/main/cpp/CMakeLists.txt @@ -269,3 +269,8 @@ add_custom_command(TARGET pie_openvpn.${ANDROID_ABI} POST_BUILD add_dependencies(opvpnutil pie_openvpn.${ANDROID_ABI} nopie_openvpn.${ANDROID_ABI}) add_dependencies(pie_openvpn.${ANDROID_ABI} makeassetdir) add_dependencies(nopie_openvpn.${ANDROID_ABI} makeassetdir) + +# For blur +add_library(blurlib SHARED blur/blurlib.cpp) + +target_link_libraries(blurlib -ljnigraphics) diff --git a/core/src/main/cpp/blur/blurlib.cpp b/core/src/main/cpp/blur/blurlib.cpp new file mode 100644 index 000000000..b914a1eb3 --- /dev/null +++ b/core/src/main/cpp/blur/blurlib.cpp @@ -0,0 +1,330 @@ +// +// Created by Tamim Hossain on 2/12/25. +// + + + +#include "blurlib.h" + + +/* + IVPN Android app + https://github.com/ivpn/android-app + + Created by Tamim Hossain. + Copyright (c) 2025 IVPN Limited. + + This file is part of the IVPN Android app. + + The IVPN Android app is free software: you can redistribute it and/or + modify it under the terms of the GNU General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) any later version. + + The IVPN Android app is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + details. + + You should have received a copy of the GNU General Public License + along with the IVPN Android app. If not, see . +*/ + +static jint performBlur(JNIEnv *env, jobject bitmap, jint radius) { + if (radius < 1) { + return INVALID_RADIUS; + } + + AndroidBitmapInfo info; + if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) { + return CAN_NOT_GET_BITMAP_INFO; + } else if (info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) { + return INVALID_BITMAP_FORMAT; + } + + int w = info.width; + int h = info.height; + int stride = info.stride; + + unsigned char *pixels = nullptr; + AndroidBitmap_lockPixels(env, bitmap, (void **) &pixels); + if (!pixels) { + return BITMAP_CONCURRENCY_ERROR; + } + + const int wm = w - 1; + const int hm = h - 1; + const int wh = w * h; + const int r1 = radius + 1; + const int div = radius + r1; + const int div_sum = SQUARE((div + 1) >> 1); + + int stack[div * 4]; + int vmin[MAX(w, h)]; + int *pRed = new int[wh]; + int *pGreen = new int[wh]; + int *pBlue = new int[wh]; + int *pAlpha = new int[wh]; + int *sir; + int x, y, rbs, stackpointer, stackstart; + int routsum, goutsum, boutsum, aoutsum; + int rinsum, ginsum, binsum, ainsum; + int rsum, gsum, bsum, asum, p, yp; + int yw = 0, yi = 0; + + zeroClearInt(stack, div * 4); + zeroClearInt(vmin, MAX(w, h)); + zeroClearInt(pRed, wh); + zeroClearInt(pGreen, wh); + zeroClearInt(pBlue, wh); + zeroClearInt(pAlpha, wh); + + const size_t dvcount = 256 * div_sum; + int *dv = new int[dvcount]; + int i; + for (i = 0; (size_t) i < dvcount; i++) { + dv[i] = (i / div_sum); + } + + // horiizantal pass + for (y = 0; y < h; y++) { + ainsum = aoutsum = asum = rinsum = ginsum = binsum = routsum = goutsum = boutsum = rsum = gsum = bsum = 0; + for (i = -radius; i <= radius; i++) { + sir = &stack[(i + radius) * 4]; + int offset = (y * stride + (MIN(wm, MAX(i, 0))) * 4); + sir[0] = pixels[offset]; + sir[1] = pixels[offset + 1]; + sir[2] = pixels[offset + 2]; + sir[3] = pixels[offset + 3]; + + rbs = r1 - abs(i); + rsum += sir[0] * rbs; + gsum += sir[1] * rbs; + bsum += sir[2] * rbs; + asum += sir[3] * rbs; + if (i > 0) { + rinsum += sir[0]; + ginsum += sir[1]; + binsum += sir[2]; + ainsum += sir[3]; + } else { + routsum += sir[0]; + goutsum += sir[1]; + boutsum += sir[2]; + aoutsum += sir[3]; + } + } + stackpointer = radius; + + for (x = 0; x < w; x++) { + pRed[yi] = dv[rsum]; + pGreen[yi] = dv[gsum]; + pBlue[yi] = dv[bsum]; + pAlpha[yi] = dv[asum]; + + rsum -= routsum; + gsum -= goutsum; + bsum -= boutsum; + asum -= aoutsum; + + stackstart = stackpointer - radius + div; + sir = &stack[(stackstart % div) * 4]; + + routsum -= sir[0]; + goutsum -= sir[1]; + boutsum -= sir[2]; + aoutsum -= sir[3]; + + if (y == 0) { + vmin[x] = MIN(x + radius + 1, wm); + } + + int offset = (y * stride + vmin[x] * 4); + sir[0] = pixels[offset]; + sir[1] = pixels[offset + 1]; + sir[2] = pixels[offset + 2]; + sir[3] = pixels[offset + 3]; + rinsum += sir[0]; + ginsum += sir[1]; + binsum += sir[2]; + ainsum += sir[3]; + + rsum += rinsum; + gsum += ginsum; + bsum += binsum; + asum += ainsum; + + stackpointer = (stackpointer + 1) % div; + sir = &stack[(stackpointer % div) * 4]; + + routsum += sir[0]; + goutsum += sir[1]; + boutsum += sir[2]; + aoutsum += sir[3]; + + rinsum -= sir[0]; + ginsum -= sir[1]; + binsum -= sir[2]; + ainsum -= sir[3]; + + yi++; + } + yw += w; + } + + // vertical pass + for (x = 0; x < w; x++) { + ainsum = aoutsum = asum = rinsum = ginsum = binsum = routsum = goutsum = boutsum = rsum = gsum = bsum = 0; + yp = -radius * w; + for (i = -radius; i <= radius; i++) { + yi = MAX(0, yp) + x; + + sir = &stack[(i + radius) * 4]; + + sir[0] = pRed[yi]; + sir[1] = pGreen[yi]; + sir[2] = pBlue[yi]; + sir[3] = pAlpha[yi]; + + rbs = r1 - abs(i); + + rsum += pRed[yi] * rbs; + gsum += pGreen[yi] * rbs; + bsum += pBlue[yi] * rbs; + asum += pAlpha[yi] * rbs; + + if (i > 0) { + rinsum += sir[0]; + ginsum += sir[1]; + binsum += sir[2]; + ainsum += sir[3]; + } else { + routsum += sir[0]; + goutsum += sir[1]; + boutsum += sir[2]; + aoutsum += sir[3]; + } + + if (i < hm) { + yp += w; + } + } + stackpointer = radius; + for (y = 0; y < h; y++) { + int offset = stride * y + x * 4; + pixels[offset] = dv[rsum]; + pixels[offset + 1] = dv[gsum]; + pixels[offset + 2] = dv[bsum]; + pixels[offset + 3] = dv[asum]; + rsum -= routsum; + gsum -= goutsum; + bsum -= boutsum; + asum -= aoutsum; + + stackstart = stackpointer - radius + div; + sir = &stack[(stackstart % div) * 4]; + + routsum -= sir[0]; + goutsum -= sir[1]; + boutsum -= sir[2]; + aoutsum -= sir[3]; + + if (x == 0) { + vmin[y] = (MIN(y + r1, hm)) * w; + } + p = x + vmin[y]; + + sir[0] = pRed[p]; + sir[1] = pGreen[p]; + sir[2] = pBlue[p]; + sir[3] = pAlpha[p]; + + rinsum += sir[0]; + ginsum += sir[1]; + binsum += sir[2]; + ainsum += sir[3]; + + rsum += rinsum; + gsum += ginsum; + bsum += binsum; + asum += ainsum; + + stackpointer = (stackpointer + 1) % div; + sir = &stack[stackpointer * 4]; + + routsum += sir[0]; + goutsum += sir[1]; + boutsum += sir[2]; + aoutsum += sir[3]; + + rinsum -= sir[0]; + ginsum -= sir[1]; + binsum -= sir[2]; + ainsum -= sir[3]; + + yi += w; + } + } + + delete[] pRed; + delete[] pGreen; + delete[] pBlue; + delete[] pAlpha; + delete[] dv; + AndroidBitmap_unlockPixels(env, bitmap); + + return SUCCESS; +} + +extern "C" { + +JNIEXPORT jobject JNICALL +Java_net_ivpn_core_v2_account_widget_MaskedImageView_blur(JNIEnv *env, jobject clazz, jobject bitmap, jint radius) { + // Create a copy of the original bitmap + jclass bitmapClass = env->FindClass("android/graphics/Bitmap"); + jmethodID copyMethod = env->GetMethodID(bitmapClass, "copy", "(Landroid/graphics/Bitmap$Config;Z)Landroid/graphics/Bitmap;"); + + jclass configClass = env->FindClass("android/graphics/Bitmap$Config"); + jfieldID argb8888Field = env->GetStaticFieldID(configClass, "ARGB_8888", "Landroid/graphics/Bitmap$Config;"); + jobject argb8888Config = env->GetStaticObjectField(configClass, argb8888Field); + + jobject blurredBitmap = env->CallObjectMethod(bitmap, copyMethod, argb8888Config, JNI_TRUE); + + if (blurredBitmap == nullptr) { + return nullptr; + } + + jint result = performBlur(env, blurredBitmap, radius); + + if (result != SUCCESS) { + return nullptr; + } + + return blurredBitmap; +} + +JNIEXPORT jobject JNICALL +Java_net_ivpn_core_v2_account_widget_MaskedTextView_blur(JNIEnv *env, jobject clazz, jobject bitmap, jint radius) { + // Create a copy of the original bitmap + jclass bitmapClass = env->FindClass("android/graphics/Bitmap"); + jmethodID copyMethod = env->GetMethodID(bitmapClass, "copy", "(Landroid/graphics/Bitmap$Config;Z)Landroid/graphics/Bitmap;"); + + jclass configClass = env->FindClass("android/graphics/Bitmap$Config"); + jfieldID argb8888Field = env->GetStaticFieldID(configClass, "ARGB_8888", "Landroid/graphics/Bitmap$Config;"); + jobject argb8888Config = env->GetStaticObjectField(configClass, argb8888Field); + + jobject blurredBitmap = env->CallObjectMethod(bitmap, copyMethod, argb8888Config, JNI_TRUE); + + if (blurredBitmap == nullptr) { + return nullptr; + } + + jint result = performBlur(env, blurredBitmap, radius); + + if (result != SUCCESS) { + return nullptr; + } + + return blurredBitmap; +} + +} \ No newline at end of file diff --git a/core/src/main/cpp/blur/blurlib.h b/core/src/main/cpp/blur/blurlib.h new file mode 100644 index 000000000..0ad6d2e7f --- /dev/null +++ b/core/src/main/cpp/blur/blurlib.h @@ -0,0 +1,54 @@ +// +// Created by Tamim Hossain on 2/12/25. +// + +/* + IVPN Android app + https://github.com/ivpn/android-app + + Created by Tamim Hossain. + Copyright (c) 2025 IVPN Limited. + + This file is part of the IVPN Android app. + + The IVPN Android app is free software: you can redistribute it and/or + modify it under the terms of the GNU General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) any later version. + + The IVPN Android app is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + details. + + You should have received a copy of the GNU General Public License + along with the IVPN Android app. If not, see . +*/ +#ifndef IVPN_ANDROID_APP_BLURLIB_H +#define IVPN_ANDROID_APP_BLURLIB_H + +#include +#include +#include +#include + +#define SQUARE(i) ((i)*(i)) +#define MAX(x, y) ((x) > (y)) ? (x) : (y) +#define MIN(x, y) ((x) < (y)) ? (x) : (y) + +#define SUCCESS 1 +#define INVALID_RADIUS -1 +#define CAN_NOT_GET_BITMAP_INFO -2 +#define INVALID_BITMAP_FORMAT -3 +#define BITMAP_CONCURRENCY_ERROR -4 + +inline static void zeroClearInt(int *p, size_t count) { + memset(p, 0, sizeof(int) * count); +} + +extern "C" { +JNIEXPORT jobject JNICALL Java_net_ivpn_core_v2_account_widget_MaskedImageView_blur(JNIEnv *env, jobject clazz, jobject bitmap, jint radius); +JNIEXPORT jobject JNICALL Java_net_ivpn_core_v2_account_widget_MaskedTextView_blur(JNIEnv *env, jobject clazz, jobject bitmap, jint radius); +} + +#endif //IVPN_ANDROID_APP_BLURLIB_H + diff --git a/core/src/main/java/net/ivpn/core/IVPNApplication.kt b/core/src/main/java/net/ivpn/core/IVPNApplication.kt index cd0e45e96..b8c4aba25 100644 --- a/core/src/main/java/net/ivpn/core/IVPNApplication.kt +++ b/core/src/main/java/net/ivpn/core/IVPNApplication.kt @@ -43,6 +43,13 @@ object IVPNApplication { lateinit var moduleNavGraph: NavGraph lateinit var config: FeatureConfig + + init { + try { + System.loadLibrary("blurlib") + } catch (_: UnsatisfiedLinkError) {} + } + fun initBy(application: Application): ApplicationComponent{ this.application = application appComponent = DaggerApplicationComponent.factory().create(application) diff --git a/core/src/main/java/net/ivpn/core/v2/account/AccountFragment.kt b/core/src/main/java/net/ivpn/core/v2/account/AccountFragment.kt index f02905805..899fb1818 100644 --- a/core/src/main/java/net/ivpn/core/v2/account/AccountFragment.kt +++ b/core/src/main/java/net/ivpn/core/v2/account/AccountFragment.kt @@ -122,6 +122,20 @@ class AccountFragment : Fragment(), AccountViewModel.AccountNavigator { binding.contentLayout.qr.width ) } + + syncMaskingBehavior() + } + + private fun syncMaskingBehavior() { + binding.contentLayout.qr.setOnClickListener { + binding.contentLayout.qr.toggleMask() + binding.contentLayout.username.toggleMask() + } + + binding.contentLayout.username.setOnClickListener { + binding.contentLayout.username.toggleMask() + binding.contentLayout.qr.toggleMask() + } } override fun onLogOut() { diff --git a/core/src/main/java/net/ivpn/core/v2/account/widget/MaskedImageView.kt b/core/src/main/java/net/ivpn/core/v2/account/widget/MaskedImageView.kt new file mode 100644 index 000000000..49188da51 --- /dev/null +++ b/core/src/main/java/net/ivpn/core/v2/account/widget/MaskedImageView.kt @@ -0,0 +1,281 @@ +package net.ivpn.core.v2.account.widget + +/* + IVPN Android app + https://github.com/ivpn/android-app + + Created by Tamim Hossain. + Copyright (c) 2025 IVPN Limited. + + This file is part of the IVPN Android app. + + The IVPN Android app is free software: you can redistribute it and/or + modify it under the terms of the GNU General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) any later version. + + The IVPN Android app is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + details. + + You should have received a copy of the GNU General Public License + along with the IVPN Android app. If not, see . +*/ +import android.animation.Animator +import android.animation.ValueAnimator +import android.content.Context +import android.content.res.Configuration +import android.graphics.* +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.animation.DecelerateInterpolator +import androidx.appcompat.widget.AppCompatImageView +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toDrawable +import androidx.core.graphics.createBitmap +import androidx.core.graphics.scale +import net.ivpn.core.R + +class MaskedImageView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatImageView(context, attrs, defStyleAttr) { + + private var originalDrawable: Drawable? = null + private var isMasked: Boolean = true + private var blurredDrawable: Drawable? = null + private var isAnimating = false + private var isInitialized = false + private var hasShownFirstImage = false + + private var visibilityOffIcon: Drawable? = null + private val iconSize = (32 * resources.displayMetrics.density).toInt() + private val circleRadius = (iconSize / 2f) + 12f + private var iconAlpha = 1f + + private var compositeBitmap: Bitmap? = null + private var compositeCanvas: Canvas? = null + + init { + loadIcon() + alpha = 0f + } + + private fun loadIcon() { + val isDarkTheme = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES + val iconColor = if (isDarkTheme) Color.WHITE else Color.BLACK + val colorFilter = PorterDuffColorFilter(iconColor, PorterDuff.Mode.SRC_IN) + + visibilityOffIcon = ContextCompat.getDrawable(context, R.drawable.outline_visibility_off_24)?.mutate()?.apply { + this.colorFilter = colorFilter + } + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + if (w > 0 && h > 0 && !isInitialized) { + isInitialized = true + if (originalDrawable != null && isMasked) { + prepareBlur() + updateDisplay() + } + } + } + + private fun prepareBlur() { + if (blurredDrawable == null && originalDrawable != null) { + val bitmap = drawableToBitmap(originalDrawable!!) ?: return + blurredDrawable = try { + blur(bitmap, 25)?.toDrawable(resources) + } catch (e: UnsatisfiedLinkError) { + createFallbackBlur(bitmap)?.toDrawable(resources) + } + } + } + + private fun createFallbackBlur(bitmap: Bitmap): Bitmap? { + return try { + val scaledDown = bitmap.scale(bitmap.width / 8, bitmap.height / 8) + val result = scaledDown.scale(bitmap.width, bitmap.height) + scaledDown.recycle() + result + } catch (e: Exception) { + null + } + } + + override fun onDraw(canvas: Canvas) { + if (!isMasked || isAnimating) { + super.onDraw(canvas) + } else { + blurredDrawable?.let { blurred -> + blurred.setBounds(paddingLeft, paddingTop, width - paddingRight, height - paddingBottom) + blurred.draw(canvas) + } + } + if (isMasked) drawCenterIcon(canvas) + } + + private fun drawCenterIcon(canvas: Canvas) { + visibilityOffIcon?.let { icon -> + val centerX = (width - iconSize) / 2 + val centerY = (height - iconSize) / 2 + + icon.setBounds(centerX, centerY, centerX + iconSize, centerY + iconSize) + icon.alpha = (iconAlpha * 255).toInt() + + val isDarkTheme = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES + val backgroundColor = if (isDarkTheme) Color.BLACK else Color.WHITE + val backgroundPaint = Paint().apply { + color = backgroundColor + alpha = (iconAlpha * 200).toInt() + isAntiAlias = true + } + + canvas.drawCircle( + centerX + iconSize / 2f, + centerY + iconSize / 2f, + circleRadius, + backgroundPaint + ) + + icon.draw(canvas) + } + } + + override fun setImageDrawable(drawable: Drawable?) { + if (originalDrawable === drawable) return + originalDrawable = drawable + blurredDrawable = null + if (isInitialized && isMasked) prepareBlur() + updateDisplay() + } + + override fun setImageBitmap(bm: Bitmap?) { + originalDrawable = bm?.toDrawable(resources) + blurredDrawable = null + if (isInitialized && isMasked) prepareBlur() + updateDisplay() + } + + private fun updateDisplay() { + val displayDrawable = if (isMasked) blurredDrawable else originalDrawable + setImageDrawableInternal(displayDrawable) + + if (!hasShownFirstImage && displayDrawable != null) { + hasShownFirstImage = true + animate() + .alpha(1f) + .setDuration(200) + .setInterpolator(DecelerateInterpolator()) + .start() + } + } + + private fun setImageDrawableInternal(drawable: Drawable?) { + super.setImageDrawable(drawable) + } + + fun toggleMask() { + if (isAnimating) return + isMasked = !isMasked + if (isMasked && blurredDrawable == null) prepareBlur() + animateCrossfade() + } + + private fun animateCrossfade() { + isAnimating = true + val startDrawable = if (isMasked) originalDrawable else blurredDrawable + val endDrawable = if (isMasked) blurredDrawable else originalDrawable + + ValueAnimator.ofFloat(0f, 1f).apply { + duration = 300 + interpolator = DecelerateInterpolator() + addUpdateListener { animation -> + val progress = animation.animatedValue as Float + iconAlpha = if (isMasked) progress else (1f - progress) + setImageDrawableInternal(createCompositeDrawable(startDrawable, endDrawable, progress)) + invalidate() + } + addListener(object : Animator.AnimatorListener { + override fun onAnimationStart(animation: Animator) {} + override fun onAnimationEnd(animation: Animator) { + setImageDrawableInternal(endDrawable) + isAnimating = false + iconAlpha = 1f + invalidate() + } + override fun onAnimationCancel(animation: Animator) { isAnimating = false } + override fun onAnimationRepeat(animation: Animator) {} + }) + }.start() + } + + private fun createCompositeDrawable(startDrawable: Drawable?, endDrawable: Drawable?, progress: Float): Drawable? { + if (startDrawable == null || endDrawable == null) return endDrawable ?: startDrawable + + val w = width.takeIf { it > 0 } ?: 1 + val h = height.takeIf { it > 0 } ?: 1 + + if (compositeBitmap == null || compositeBitmap?.width != w || compositeBitmap?.height != h) { + compositeBitmap?.recycle() + compositeBitmap = createBitmap(w, h) + compositeCanvas = Canvas(compositeBitmap!!) + } + + val canvas = compositeCanvas!! + canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) + + startDrawable.setBounds(0, 0, w, h) + startDrawable.alpha = ((1f - progress) * 255).toInt() + startDrawable.draw(canvas) + + endDrawable.setBounds(0, 0, w, h) + endDrawable.alpha = (progress * 255).toInt() + endDrawable.draw(canvas) + + startDrawable.alpha = 255 + endDrawable.alpha = 255 + + return compositeBitmap!!.toDrawable(resources) + } + + fun setMasked(mask: Boolean, animate: Boolean = false) { + if (isAnimating || isMasked == mask) return + isMasked = mask + if (animate) toggleMask() + else { + if (isMasked && blurredDrawable == null) prepareBlur() + updateDisplay() + invalidate() + } + } + + fun isImageMasked(): Boolean = isMasked + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + (blurredDrawable as? BitmapDrawable)?.bitmap?.recycle() + blurredDrawable = null + compositeBitmap?.recycle() + compositeBitmap = null + compositeCanvas = null + } + + private fun drawableToBitmap(drawable: Drawable): Bitmap? { + if (drawable is BitmapDrawable) return drawable.bitmap + + val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: 1 + val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: 1 + + val bitmap = createBitmap(width, height) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, width, height) + drawable.draw(canvas) + return bitmap + } + + private external fun blur(bitmap: Bitmap, radius: Int): Bitmap? +} \ No newline at end of file diff --git a/core/src/main/java/net/ivpn/core/v2/account/widget/MaskedTextView.kt b/core/src/main/java/net/ivpn/core/v2/account/widget/MaskedTextView.kt new file mode 100644 index 000000000..66fa8ee37 --- /dev/null +++ b/core/src/main/java/net/ivpn/core/v2/account/widget/MaskedTextView.kt @@ -0,0 +1,252 @@ +package net.ivpn.core.v2.account.widget + +/* + IVPN Android app + https://github.com/ivpn/android-app + + Created by Tamim Hossain. + Copyright (c) 2025 IVPN Limited. + + This file is part of the IVPN Android app. + + The IVPN Android app is free software: you can redistribute it and/or + modify it under the terms of the GNU General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) any later version. + + The IVPN Android app is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + details. + + You should have received a copy of the GNU General Public License + along with the IVPN Android app. If not, see . +*/ +import android.animation.ValueAnimator +import android.content.Context +import android.content.res.Configuration +import android.graphics.* +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.ViewTreeObserver +import android.view.animation.DecelerateInterpolator +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.content.ContextCompat +import androidx.core.graphics.createBitmap +import androidx.core.graphics.scale +import net.ivpn.core.R + +class MaskedTextView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = android.R.attr.textViewStyle +) : AppCompatTextView(context, attrs, defStyleAttr) { + + private var isMasked = true + private var blurredBitmap: Bitmap? = null + private var isAnimating = false + private var currentBlurAlpha = 1f + private var isCapturingForBlur = false + + private var visibilityIcon: Drawable? = null + private var visibilityOffIcon: Drawable? = null + + private val iconSize = (20 * resources.displayMetrics.density).toInt() + private val iconMargin = (12 * resources.displayMetrics.density).toInt() + private val iconPadding = (8 * resources.displayMetrics.density).toInt() + + private var transitionBitmap: Bitmap? = null + private var transitionCanvas: Canvas? = null + + init { + loadIcons() + setPadding( + paddingLeft, + paddingTop, + paddingRight + iconSize + iconMargin + iconPadding, + paddingBottom + ) + + viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + if (isMasked && blurredBitmap == null && width > 0 && height > 0) { + prepareBlur() + invalidate() + } + viewTreeObserver.removeOnPreDrawListener(this) + return true + } + }) + } + + private fun loadIcons() { + val isDarkTheme = + resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES + val iconColor = if (isDarkTheme) Color.WHITE else Color.BLACK + val colorFilter = PorterDuffColorFilter(iconColor, PorterDuff.Mode.SRC_IN) + + visibilityIcon = ContextCompat.getDrawable(context, R.drawable.outline_visibility_24)?.mutate()?.apply { + this.colorFilter = colorFilter + } + visibilityOffIcon = ContextCompat.getDrawable(context, R.drawable.outline_visibility_off_24)?.mutate()?.apply { + this.colorFilter = colorFilter + } + } + + private fun ensureTransitionCanvas() { + if (transitionBitmap == null || transitionBitmap?.width != width || transitionBitmap?.height != height) { + transitionBitmap?.recycle() + transitionBitmap = createBitmap(width, height) + transitionCanvas = Canvas(transitionBitmap!!) + } + } + + private fun prepareBlur() { + if (!isMasked || blurredBitmap != null || width == 0 || height == 0) return + + val original = createBitmap(width, height) + val tempCanvas = Canvas(original) + + isCapturingForBlur = true + val savedMasked = isMasked + isMasked = false + draw(tempCanvas) + isMasked = savedMasked + isCapturingForBlur = false + + blurredBitmap = try { + blur(original, 25) + } catch (e: UnsatisfiedLinkError) { + createFallbackBlur(original) + } finally { + original.recycle() + } + } + + private fun createFallbackBlur(bitmap: Bitmap): Bitmap? { + return try { + val scaled = bitmap.scale(bitmap.width / 8, bitmap.height / 8) + val result = scaled.scale(bitmap.width, bitmap.height) + scaled.recycle() + result + } catch (e: Exception) { + null + } + } + + override fun onDraw(canvas: Canvas) { + if (!isMasked) { + super.onDraw(canvas) + drawIcon(canvas, visibilityIcon) + return + } + + blurredBitmap?.let { blurred -> + val paint = if (isAnimating && currentBlurAlpha < 1f) { + Paint().apply { + alpha = (currentBlurAlpha * 255).toInt() + isAntiAlias = true + } + } else null + + canvas.drawBitmap(blurred, 0f, 0f, paint) + } + + if (isAnimating && currentBlurAlpha < 1f) { + ensureTransitionCanvas() + val tempCanvas = transitionCanvas!! + tempCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) + + isCapturingForBlur = true + val savedMasked = isMasked + isMasked = false + draw(tempCanvas) + isMasked = savedMasked + isCapturingForBlur = false + + val alphaPaint = Paint().apply { + alpha = ((1f - currentBlurAlpha) * 255).toInt() + } + canvas.drawBitmap(transitionBitmap!!, 0f, 0f, alphaPaint) + } + + drawIcon(canvas, visibilityOffIcon) + } + + private fun drawIcon(canvas: Canvas, icon: Drawable?) { + if (isCapturingForBlur) return + + icon?.let { + val fontMetrics = paint.fontMetrics + val textTop = baseline + fontMetrics.top + val textBottom = baseline + fontMetrics.bottom + val textCenterY = (textTop + textBottom) / 2 + + val iconTop = (textCenterY - iconSize / 2).toInt() + val iconLeft = width - iconSize - iconPadding + it.setBounds(iconLeft, iconTop, iconLeft + iconSize, iconTop + iconSize) + it.alpha = when { + isAnimating && isMasked -> (currentBlurAlpha * 255).toInt() + isAnimating && !isMasked -> ((1f - currentBlurAlpha) * 255).toInt() + else -> 255 + } + it.draw(canvas) + } + } + + fun toggleMask() { + if (isAnimating) return + isMasked = !isMasked + if (isMasked) prepareBlur() + animateBlurAlpha() + } + + private fun animateBlurAlpha() { + isAnimating = true + val startAlpha = if (isMasked) 0f else 1f + val endAlpha = if (isMasked) 1f else 0f + + ValueAnimator.ofFloat(startAlpha, endAlpha).apply { + duration = 300 + interpolator = DecelerateInterpolator() + addUpdateListener { + currentBlurAlpha = it.animatedValue as Float + invalidate() + } + addListener(object : android.animation.Animator.AnimatorListener { + override fun onAnimationStart(animation: android.animation.Animator) {} + override fun onAnimationEnd(animation: android.animation.Animator) { + isAnimating = false + currentBlurAlpha = 1f + if (!isMasked) { + blurredBitmap?.recycle() + blurredBitmap = null + } + invalidate() + } + + override fun onAnimationCancel(animation: android.animation.Animator) { + isAnimating = false + } + + override fun onAnimationRepeat(animation: android.animation.Animator) {} + }) + }.start() + } + + fun setMasked(mask: Boolean, animate: Boolean = false) { + if (isAnimating) return + isMasked = mask + if (animate) toggleMask() else invalidate() + } + + fun isTextMasked(): Boolean = isMasked + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + blurredBitmap?.recycle() + blurredBitmap = null + transitionBitmap?.recycle() + transitionBitmap = null + transitionCanvas = null + } + + private external fun blur(bitmap: Bitmap, radius: Int): Bitmap? +} \ No newline at end of file diff --git a/core/src/main/res/drawable/outline_visibility_24.xml b/core/src/main/res/drawable/outline_visibility_24.xml new file mode 100644 index 000000000..134bc44df --- /dev/null +++ b/core/src/main/res/drawable/outline_visibility_24.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/outline_visibility_off_24.xml b/core/src/main/res/drawable/outline_visibility_off_24.xml new file mode 100644 index 000000000..021285893 --- /dev/null +++ b/core/src/main/res/drawable/outline_visibility_off_24.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/layout/content_account.xml b/core/src/main/res/layout/content_account.xml index c0d78891f..80a91450e 100644 --- a/core/src/main/res/layout/content_account.xml +++ b/core/src/main/res/layout/content_account.xml @@ -29,7 +29,7 @@ android:layout_height="match_parent" android:background="@color/account_background"> - -