Skip to content

Commit 3abfdae

Browse files
committed
Show a warning if the VPN was killed by battery optimization
1 parent 5e0f944 commit 3abfdae

File tree

3 files changed

+140
-17
lines changed

3 files changed

+140
-17
lines changed

app/src/main/java/tech/httptoolkit/android/HttpToolkitApplication.kt

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
package tech.httptoolkit.android
22

33
import android.app.Application
4-
import android.content.Context
4+
import android.content.*
5+
import android.os.Build
56
import android.util.Log
67
import com.android.installreferrer.api.InstallReferrerClient
78
import com.android.installreferrer.api.InstallReferrerClient.InstallReferrerResponse
@@ -12,7 +13,8 @@ import com.google.android.gms.analytics.HitBuilders
1213
import com.google.android.gms.analytics.Tracker
1314
import io.sentry.Sentry
1415
import io.sentry.android.AndroidSentryClientFactory
15-
import kotlinx.coroutines.*
16+
import kotlinx.coroutines.Dispatchers
17+
import kotlinx.coroutines.withContext
1618
import net.swiftzer.semver.SemVer
1719
import okhttp3.OkHttpClient
1820
import okhttp3.Request
@@ -22,14 +24,51 @@ import java.util.concurrent.atomic.AtomicBoolean
2224
import kotlin.coroutines.resume
2325
import kotlin.coroutines.suspendCoroutine
2426

27+
private const val VPN_START_TIME_PREF = "vpn-start-time"
28+
private const val APP_CRASHED_PREF = "app-crashed"
29+
private const val FIRST_RUN_PREF = "is-first-run"
30+
31+
private val isProbablyEmulator =
32+
Build.FINGERPRINT.startsWith("generic")
33+
|| Build.FINGERPRINT.startsWith("unknown")
34+
|| Build.MODEL.contains("google_sdk")
35+
|| Build.MODEL.contains("Emulator")
36+
|| Build.MODEL.contains("Android SDK built for x86")
37+
|| Build.BOARD == "QC_Reference_Phone"
38+
|| Build.MANUFACTURER.contains("Genymotion")
39+
|| Build.HOST.startsWith("Build")
40+
|| (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic"))
41+
|| Build.PRODUCT == "google_sdk"
42+
43+
private val bootTime = (System.currentTimeMillis() - android.os.SystemClock.elapsedRealtime())
2544

2645
class HttpToolkitApplication : Application() {
2746

2847
private var analytics: GoogleAnalytics? = null
2948
private var ga: Tracker? = null
3049

50+
private lateinit var prefs: SharedPreferences
51+
private var vpnWasKilled: Boolean = false
52+
53+
var vpnShouldBeRunning: Boolean
54+
get() {
55+
return prefs.getLong(VPN_START_TIME_PREF, -1) > bootTime
56+
}
57+
set(value) {
58+
if (value) {
59+
prefs.edit().putLong(VPN_START_TIME_PREF, System.currentTimeMillis()).apply()
60+
} else {
61+
prefs.edit().putLong(VPN_START_TIME_PREF, -1).apply()
62+
}
63+
}
64+
3165
override fun onCreate() {
3266
super.onCreate()
67+
prefs = getSharedPreferences("tech.httptoolkit.android", MODE_PRIVATE)
68+
69+
Thread.setDefaultUncaughtExceptionHandler { _, _ ->
70+
prefs.edit().putBoolean(APP_CRASHED_PREF, true).apply()
71+
}
3372

3473
if (BuildConfig.SENTRY_DSN != null) {
3574
Sentry.init(BuildConfig.SENTRY_DSN, AndroidSentryClientFactory(this))
@@ -41,18 +80,35 @@ class HttpToolkitApplication : Application() {
4180
resumeEvents() // Resume events on app startup, in case they were paused and we crashed
4281
}
4382

83+
// Check if we've been recreated unexpectedly, with no crashes in the meantime:
84+
val appCrashed = prefs.getBoolean(APP_CRASHED_PREF, false)
85+
prefs.edit().putBoolean(APP_CRASHED_PREF, false).apply()
86+
87+
vpnWasKilled = vpnShouldBeRunning && !isVpnActive() && !appCrashed && !isProbablyEmulator
88+
if (vpnWasKilled) {
89+
Sentry.capture("VPN killed in the background")
90+
// The UI will show an alert next time the MainActivity is created.
91+
}
92+
4493
Log.i(TAG, "App created")
4594
}
4695

96+
/**
97+
* Check whether the VPN was killed as a sleeping background process, and then
98+
* reset that state so that future checks (until it's next killed) return false
99+
*/
100+
fun popVpnKilledState(): Boolean {
101+
return vpnWasKilled
102+
.also { this.vpnWasKilled = false }
103+
}
104+
47105
/**
48106
* Grab any first run params, drop them for future usage, and return them.
49107
* This will return first-run params at most once (per install).
50108
*/
51109
suspend fun popFirstRunParams(): String? {
52-
val prefs = getSharedPreferences("tech.httptoolkit.android", MODE_PRIVATE)
53-
54-
val isFirstRun = prefs.getBoolean("is-first-run", true)
55-
prefs.edit().putBoolean("is-first-run", false).apply()
110+
val isFirstRun = prefs.getBoolean(FIRST_RUN_PREF, true)
111+
prefs.edit().putBoolean(FIRST_RUN_PREF, false).apply()
56112

57113
val installTime = packageManager.getPackageInfo(packageName, 0).firstInstallTime
58114
val now = System.currentTimeMillis()
@@ -96,7 +152,6 @@ class HttpToolkitApplication : Application() {
96152
}
97153
})
98154
}
99-
100155
}
101156

102157
var lastProxy: ProxyConfig?

app/src/main/java/tech/httptoolkit/android/MainActivity.kt

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
package tech.httptoolkit.android
22

3-
import android.content.BroadcastReceiver
4-
import android.content.Context
5-
import android.content.Intent
6-
import android.content.IntentFilter
3+
import android.content.*
74
import android.content.pm.PackageManager
85
import android.content.res.Configuration
96
import android.net.Uri
107
import android.net.VpnService
8+
import android.os.Build
119
import android.os.Bundle
10+
import android.os.PowerManager
11+
import android.provider.Settings
1212
import android.security.KeyChain
1313
import android.security.KeyChain.EXTRA_CERTIFICATE
1414
import android.security.KeyChain.EXTRA_NAME
@@ -99,10 +99,23 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
9999
}
100100
}
101101

102-
// Async check for updates, and maybe prompt the user if necessary (if using play store)
103-
launch {
104-
supervisorScope {
105-
if (isStoreAvailable(this@MainActivity) && app.isUpdateRequired()) promptToUpdate()
102+
val batteryOptimizationsDisabled =
103+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
104+
(getSystemService(Context.POWER_SERVICE) as PowerManager)
105+
.isIgnoringBatteryOptimizations(packageName)
106+
} else {
107+
false // We can't check, so assume not
108+
}
109+
110+
if (app.popVpnKilledState() && !batteryOptimizationsDisabled) {
111+
// The app was killed last run, probably by battery optimizations: show a warning
112+
showVpnKilledAlert()
113+
} else {
114+
// Async check for updates, and maybe prompt the user if necessary (if using play store)
115+
launch {
116+
supervisorScope {
117+
if (isStoreAvailable(this@MainActivity) && app.isUpdateRequired()) promptToUpdate()
118+
}
106119
}
107120
}
108121
}
@@ -515,6 +528,58 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
515528
.show()
516529
}
517530
}
531+
532+
private fun showVpnKilledAlert() {
533+
MaterialAlertDialogBuilder(this)
534+
.setTitle("HTTP Toolkit was killed")
535+
.setIcon(R.drawable.ic_exclamation_triangle)
536+
.setMessage(
537+
"HTTP Toolkit interception was shut down automatically by Android. " +
538+
"This is usually caused by overly strict power management of background processes. " +
539+
"To fix this, disable battery optimization for HTTP Toolkit in your settings."
540+
)
541+
.setNegativeButton("Ignore") { _, _ -> }
542+
.setPositiveButton("Go to settings") { _, _ ->
543+
val batterySettingIntents = listOf(
544+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
545+
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
546+
} else null,
547+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
548+
Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS)
549+
} else null,
550+
Intent().apply {
551+
this.component = ComponentName(
552+
"com.samsung.android.lool",
553+
"com.samsung.android.sm.ui.battery.BatteryActivity"
554+
)
555+
},
556+
Intent().apply {
557+
this.component = ComponentName(
558+
"com.samsung.android.sm",
559+
"com.samsung.android.sm.ui.battery.BatteryActivity"
560+
)
561+
},
562+
Intent(Settings.ACTION_SETTINGS)
563+
)
564+
565+
// Try the intents in order until one of them works
566+
for (intent in batterySettingIntents) {
567+
if (intent != null && tryStartActivity(intent)) break
568+
}
569+
}
570+
.show()
571+
}
572+
573+
private fun tryStartActivity(intent: Intent): Boolean {
574+
return try {
575+
startActivity(intent)
576+
true
577+
} catch (e: ActivityNotFoundException) {
578+
false
579+
} catch (e: SecurityException) {
580+
false
581+
}
582+
}
518583
}
519584

520585
private fun isPackageAvailable(context: Context, packageName: String) = try {

app/src/main/java/tech/httptoolkit/android/ProxyVpnService.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ fun activeVpnConfig(): ProxyConfig? {
4444

4545
class ProxyVpnService : VpnService(), IProtectSocket {
4646

47-
private var app: HttpToolkitApplication? = null
47+
private lateinit var app: HttpToolkitApplication
4848

4949
private var localBroadcastManager: LocalBroadcastManager? = null
5050

@@ -180,7 +180,7 @@ class ProxyVpnService : VpnService(), IProtectSocket {
180180
.setSession(getString(R.string.app_name))
181181
.establish()
182182

183-
(this.application as HttpToolkitApplication).lastProxy = proxyConfig
183+
app.lastProxy = proxyConfig
184184
showServiceNotification()
185185
localBroadcastManager!!.sendBroadcast(
186186
Intent(VPN_STARTED_BROADCAST).apply {
@@ -201,6 +201,8 @@ class ProxyVpnService : VpnService(), IProtectSocket {
201201
)
202202
)
203203
Thread(vpnRunnable, "Vpn thread").start()
204+
205+
app.vpnShouldBeRunning = true
204206
}
205207
}
206208

@@ -228,6 +230,7 @@ class ProxyVpnService : VpnService(), IProtectSocket {
228230

229231
currentService = null
230232
this.proxyConfig = null
233+
app.vpnShouldBeRunning = false
231234
}
232235

233236
fun isActive(): Boolean {

0 commit comments

Comments
 (0)