Skip to content
59 changes: 53 additions & 6 deletions core/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -61,21 +61,68 @@
<activity
android:name="net.ivpn.core.v2.MainActivity"
android:screenOrientation="portrait"
android:exported="true"
android:exported="false"
android:theme="@style/AppTheme.NoActionBar"
android:windowSoftInputMode="adjustNothing"
android:launchMode="singleInstance">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>

<!-- Activity aliases for app icon switching -->
<activity-alias
android:name="net.ivpn.client.MainActivity"
android:targetActivity="net.ivpn.core.v2.MainActivity"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>

<activity-alias
android:name="net.ivpn.client.MainActivityWeather"
android:targetActivity="net.ivpn.core.v2.MainActivity"
android:label="@string/app_icon_name_weather"
android:icon="@mipmap/ic_launcher_weather"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>

<activity-alias
android:name="net.ivpn.client.MainActivityNotes"
android:targetActivity="net.ivpn.core.v2.MainActivity"
android:label="@string/app_icon_name_notes"
android:icon="@mipmap/ic_launcher_notes"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>

<activity-alias
android:name="net.ivpn.client.MainActivityCalculator"
android:targetActivity="net.ivpn.core.v2.MainActivity"
android:label="@string/app_icon_name_calculator"
android:icon="@mipmap/ic_launcher_calculator"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>

<activity
android:name="net.ivpn.core.vpn.local.PermissionActivity"
android:exported="false"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ private void setListenPortString(@Nullable final String port) {
setListenPort(0);
}

private void setMtu(final int mtu) {
public void setMtu(final int mtu) {
this.mtu = mtu;
}

Expand Down
97 changes: 97 additions & 0 deletions core/src/main/java/net/ivpn/core/common/appicon/AppIconManager.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package net.ivpn.core.common.appicon

/*
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 <https://www.gnu.org/licenses/>.
*/

import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import net.ivpn.core.R
import net.ivpn.core.common.dagger.ApplicationScope
import javax.inject.Inject

private const val ACTIVITY_ALIAS_PREFIX = "net.ivpn.client"

@ApplicationScope
class AppIconManager @Inject constructor(
private val context: Context
) {

private var currentIcon: CustomAppIconData? = null

fun setNewAppIcon(desiredAppIcon: CustomAppIconData) {
val currentIconData = getCurrentIconData()

// Disable current icon
context.packageManager.setComponentEnabledSetting(
currentIconData.getComponentName(context),
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP
)

// Enable new icon
context.packageManager.setComponentEnabledSetting(
desiredAppIcon.getComponentName(context),
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
PackageManager.DONT_KILL_APP
)

currentIcon = desiredAppIcon
}

fun getCurrentIconData(): CustomAppIconData {
currentIcon?.let { return it }

val activeIcon = CustomAppIconData.entries.firstOrNull {
context.packageManager.getComponentEnabledSetting(it.getComponentName(context)) ==
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
}

currentIcon = activeIcon ?: CustomAppIconData.DEFAULT
return currentIcon!!
}
}

enum class CustomAppIconData(
private val componentName: String,
@DrawableRes val iconPreviewResId: Int,
@StringRes val labelResId: Int,
val category: IconCategory
) {
DEFAULT(".MainActivity", R.mipmap.ic_launcher, R.string.app_icon_name_default, IconCategory.IVPN),
WEATHER(".MainActivityWeather", R.mipmap.ic_launcher_weather, R.string.app_icon_name_weather, IconCategory.Discreet),
NOTES(".MainActivityNotes", R.mipmap.ic_launcher_notes, R.string.app_icon_name_notes, IconCategory.Discreet),
CALCULATOR(".MainActivityCalculator", R.mipmap.ic_launcher_calculator, R.string.app_icon_name_calculator, IconCategory.Discreet);

fun getComponentName(context: Context): ComponentName {
val applicationContext = context.applicationContext
return ComponentName(applicationContext, ACTIVITY_ALIAS_PREFIX + componentName)
}

enum class IconCategory {
IVPN,
Discreet
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import net.ivpn.core.v2.account.AccountFragment;
import net.ivpn.core.v2.account.LogOutFragment;
import net.ivpn.core.v2.alwaysonvpn.AlwaysOnVPNFragment;
import net.ivpn.core.v2.appicon.AppIconFragment;
import net.ivpn.core.v2.antitracker.AntiTrackerFragment;
import net.ivpn.core.v2.antitracker.AntiTrackerListFragment;
import net.ivpn.core.v2.captcha.CaptchaFragment;
Expand Down Expand Up @@ -156,6 +157,8 @@ interface Factory {

void inject(KillSwitchFragment fragment);

void inject(AppIconFragment fragment);

void inject(MockLocationFragment fragment);

void inject(MockLocationStep1Fragment fragment);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,20 @@ import net.ivpn.core.R
enum class NightMode(val id: Int, val systemId: Int, val stringId: Int) {
LIGHT(R.id.light_mode, AppCompatDelegate.MODE_NIGHT_NO, R.string.settings_color_theme_light),
DARK(R.id.dark_mode, AppCompatDelegate.MODE_NIGHT_YES, R.string.settings_color_theme_dark),
AMOLED_BLACK(R.id.amoled_black_mode, AppCompatDelegate.MODE_NIGHT_YES, R.string.settings_color_theme_amoled_black),
SYSTEM_DEFAULT(R.id.system_default_mode, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, R.string.settings_color_theme_system_default),
BY_BATTERY_SAVER(R.id.set_by_battery_mode, AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY, R.string.settings_color_theme_system_by_battery);

val isOledBlack: Boolean
get() = this == AMOLED_BLACK

companion object {
fun getById(id: Int) : NightMode {
for (mode in values()) {
fun getById(id: Int): NightMode {
for (mode in entries) {
if (mode.id == id) {
return mode
}
}

return LIGHT
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package net.ivpn.core.common.nightmode

/*
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 <https://www.gnu.org/licenses/>.
*/

import android.app.Activity
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.LayerDrawable
import android.os.Build
import android.view.View
import android.view.ViewGroup
import android.view.Window
import androidx.appcompat.widget.Toolbar
import androidx.cardview.widget.CardView
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.tabs.TabLayout
import net.ivpn.core.IVPNApplication
import net.ivpn.core.R

object OledModeController {

const val OLED_BLACK = Color.BLACK
const val OLED_CARD = 0xFF0A0A0A.toInt()
const val OLED_HANDLE = 0xFF666666.toInt()

private val darkGrayColors = setOf(
0xFF202020.toInt(),
0xFF1C1C1C.toInt(),
0xFF1C1C1E.toInt(),
0xFF121212.toInt(),
0xFF323232.toInt(),
0xFF383838.toInt(),
0xFF292929.toInt(),
0xFF181818.toInt(),
0xFF343332.toInt(),
0xFF060606.toInt()
)

private val handleColor = 0xFF49494B.toInt()

@JvmStatic
fun applyOledTheme(activity: Activity) {
if (isOledModeEnabled()) {
activity.setTheme(R.style.AppTheme_OLED)
}
}

fun applyOledColors(window: Window, rootView: View?) {
if (!isOledModeEnabled()) return

window.statusBarColor = OLED_BLACK
window.navigationBarColor = OLED_BLACK
window.decorView.setBackgroundColor(OLED_BLACK)
rootView?.let { applyOledToViewTree(it) }
}

fun applyOledToViewTree(view: View) {
if (!isOledModeEnabled()) return

val background = view.background?.mutate()
when (background) {
is ColorDrawable -> {
if (background.color in darkGrayColors) {
view.setBackgroundColor(OLED_BLACK)
}
}
is GradientDrawable -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
background.color?.defaultColor?.let { gradientColor ->
when (gradientColor) {
handleColor -> background.setColor(OLED_HANDLE)
in darkGrayColors -> background.setColor(OLED_BLACK)
}
}
}
}
is LayerDrawable -> {
for (i in 0 until background.numberOfLayers) {
val layer = background.getDrawable(i)?.mutate()
if (layer is GradientDrawable && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
layer.color?.defaultColor?.let { layerColor ->
when (layerColor) {
handleColor -> layer.setColor(OLED_HANDLE)
in darkGrayColors -> layer.setColor(OLED_BLACK)
}
}
}
}
}
}

if (view is CardView) {
val cardColor = view.cardBackgroundColor.defaultColor
if (cardColor in darkGrayColors) {
view.setCardBackgroundColor(OLED_CARD)
}
}

if (view is FloatingActionButton) {
val fabColor = view.backgroundTintList?.defaultColor ?: 0
if (fabColor in darkGrayColors) {
view.backgroundTintList = ColorStateList.valueOf(OLED_CARD)
}
}

if (view is Toolbar) {
view.setBackgroundColor(OLED_BLACK)
}

if (view is AppBarLayout) {
view.setBackgroundColor(OLED_BLACK)
}

if (view is TabLayout) {
view.setBackgroundColor(OLED_BLACK)
}

view.backgroundTintList?.let { tintList ->
val tintColor = tintList.defaultColor
if (tintColor in darkGrayColors) {
view.backgroundTintList = ColorStateList.valueOf(OLED_CARD)
}
}

if (view is ViewGroup) {
for (i in 0 until view.childCount) {
applyOledToViewTree(view.getChildAt(i))
}
}
}

fun getBackgroundColor(): Int {
return if (isOledModeEnabled()) OLED_BLACK else 0
}

fun getCardColor(): Int {
return if (isOledModeEnabled()) OLED_CARD else 0
}

fun isOledModeEnabled(): Boolean {
return try {
val settings = IVPNApplication.appComponent.provideSettings()
settings?.nightMode?.isOledBlack == true
} catch (e: Exception) {
false
}
}
}

Loading