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">
-
-