Skip to content

Commit

Permalink
Implement Snooze feature (#3869)
Browse files Browse the repository at this point in the history
<!--
Note: This checklist is a reminder of our shared engineering
expectations.
The items in Bold are required
If your PR involves UI changes:
1. Upload screenshots or screencasts that illustrate the changes before
/ after
2. Add them under the UI changes section (feel free to add more columns
if needed)
If your PR does not involve UI changes, you can remove the **UI
changes** section

At a minimum, make sure your changes are tested in API 23 and one of the
more recent API levels available.
-->

Task/Issue URL:
https://app.asana.com/0/488551667048375/1205958825930564/f

### Description
See attached task description

### Steps to test this PR
https://app.asana.com/0/0/1205989766118758/f

---------

Co-authored-by: Karl Dimla <[email protected]>
  • Loading branch information
aitorvs and karlenDimla authored Dec 4, 2023
1 parent 0325871 commit ce9405d
Show file tree
Hide file tree
Showing 39 changed files with 544 additions and 84 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,9 @@ interface Vpn {
* Disable the device VPN by stopping the VPN service
*/
suspend fun stop()

/**
* Snoozes the VPN for [triggerAtMillis] milliseconds
*/
suspend fun snooze(triggerAtMillis: Long)
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package com.duckduckgo.mobile.android.vpn.service
import android.app.PendingIntent
import android.text.SpannableStringBuilder
import androidx.core.app.NotificationCompat
import com.duckduckgo.mobile.android.vpn.service.VpnEnabledNotificationContentPlugin.NotificationActions.VPNFeatureActions
import kotlinx.coroutines.flow.Flow

interface VpnEnabledNotificationContentPlugin {
Expand Down Expand Up @@ -59,13 +60,13 @@ interface VpnEnabledNotificationContentPlugin {
data class VpnEnabledNotificationContent(
val title: SpannableStringBuilder,
val onNotificationPressIntent: PendingIntent?,
val notificationAction: NotificationCompat.Action?,
val notificationActions: NotificationActions,
) {
companion object {
val EMPTY = VpnEnabledNotificationContent(
title = SpannableStringBuilder(),
onNotificationPressIntent = null,
notificationAction = null,
notificationActions = VPNFeatureActions(emptyList()),
)
}
}
Expand All @@ -76,4 +77,9 @@ interface VpnEnabledNotificationContentPlugin {
HIGH,
VERY_HIGH,
}

sealed class NotificationActions {
object VPNActions : NotificationActions()
data class VPNFeatureActions(val actions: List<NotificationCompat.Action>) : NotificationActions()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

package com.duckduckgo.mobile.android.vpn.state

import com.duckduckgo.mobile.android.vpn.VpnFeature
import com.duckduckgo.mobile.android.vpn.state.VpnStateMonitor.AlwaysOnState.Companion.DEFAULT
import kotlinx.coroutines.flow.Flow
Expand Down Expand Up @@ -55,18 +56,18 @@ interface VpnStateMonitor {
fun isAlwaysOnLockedDown(): Boolean = enabled && lockedDown
}

enum class VpnRunningState {
ENABLING,
ENABLED,
DISABLED,
INVALID,
sealed class VpnRunningState {
data object ENABLING : VpnRunningState()
data object ENABLED : VpnRunningState()
data object DISABLED : VpnRunningState()
data object INVALID : VpnRunningState()
}

enum class VpnStopReason {
SELF_STOP,
ERROR,
REVOKED,
UNKNOWN,
RESTART,
sealed class VpnStopReason {
data class SELF_STOP(val snoozedTriggerAtMillis: Long = 0L) : VpnStopReason()
data object ERROR : VpnStopReason()
data object REVOKED : VpnStopReason()
data object UNKNOWN : VpnStopReason()
data object RESTART : VpnStopReason()
}
}
11 changes: 11 additions & 0 deletions app-tracking-protection/vpn-impl/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,17 @@
</intent-filter>
</receiver>

<receiver
android:name="com.duckduckgo.mobile.android.vpn.service.VpnActionReceiver"
android:exported="false"
android:process=":vpn">
<intent-filter>
<action android:name="com.duckduckgo.vpn.ACTION_VPN_SNOOZE_END" />
<action android:name="com.duckduckgo.vpn.ACTION_VPN_DISABLE" />
<action android:name="com.duckduckgo.vpn.ACTION_VPN_SNOOZE" />
</intent-filter>
</receiver>

</application>

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,8 @@ internal open class VpnServiceWrapper(
override suspend fun stop() = withContext(dispatcherProvider.io()) {
stopService()
}

override suspend fun snooze(triggerAtMillis: Long) {
TrackerBlockingVpnService.snoozeService(context, triggerAtMillis)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ class VpnFeatureRemoverStateListener @Inject constructor(
coroutineScope: CoroutineScope,
vpnStopReason: VpnStopReason,
) {
if (vpnStopReason == VpnStopReason.SELF_STOP) {
if (vpnStopReason is VpnStopReason.SELF_STOP) {
coroutineScope.launch(dispatcherProvider.io()) {
logcat { "FeatureRemoverVpnStateListener, new state DISABLED and it was MANUALLY. Scheduling automatic feature removal" }
scheduleFeatureRemoval()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ class VpnServiceHeartbeat @Inject constructor(
logcat { "onVpnStopped called" }
when (vpnStopReason) {
ERROR -> logcat { "HB monitor: sudden vpn stopped $vpnStopReason" }
SELF_STOP, REVOKED, RESTART, UNKNOWN -> {
is SELF_STOP, REVOKED, RESTART, UNKNOWN -> {
logcat { "HB monitor: self stopped or revoked or restart: $vpnStopReason" }
// we absolutely want this to finish before VPN is stopped to avoid race conditions reading out the state
runBlocking { storeHeartbeat(VpnServiceHeartbeatMonitor.DATA_HEART_BEAT_TYPE_STOPPED) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,5 +213,10 @@ enum class DeviceShieldPixelNames(override val pixelName: String, val enqueue: B
REPORT_NOTIFY_START_FAILURE_DAILY("m_vpn_ev_notify_start_failed_c"),

REPORT_TLS_PARSING_ERROR_CODE_DAILY("m_atp_tls_parsing_error_code_%d_d"),

VPN_SNOOZE_STARTED("m_vpn_ev_snooze_started_c", enqueue = true),
VPN_SNOOZE_STARTED_DAILY("m_vpn_ev_snooze_started_d", enqueue = true),
VPN_SNOOZE_ENDED("m_vpn_ev_snooze_ended_c", enqueue = true),
VPN_SNOOZE_ENDED_DAILY("m_vpn_ev_snooze_ended_d", enqueue = true),
;
}
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,9 @@ interface DeviceShieldPixels {
fun notifyStartFailed()

fun reportTLSParsingError(errorCode: Int)

fun reportVpnSnoozedStarted()
fun reportVpnSnoozedEnded()
}

@ContributesBinding(AppScope::class)
Expand Down Expand Up @@ -734,6 +737,16 @@ class RealDeviceShieldPixels @Inject constructor(
firePixel(DeviceShieldPixelNames.ATP_REPORT_DEVICE_CONNECTIVITY_ERROR)
}

override fun reportVpnSnoozedStarted() {
tryToFireDailyPixel(DeviceShieldPixelNames.VPN_SNOOZE_STARTED_DAILY)
firePixel(DeviceShieldPixelNames.VPN_SNOOZE_STARTED)
}

override fun reportVpnSnoozedEnded() {
tryToFireDailyPixel(DeviceShieldPixelNames.VPN_SNOOZE_ENDED_DAILY)
firePixel(DeviceShieldPixelNames.VPN_SNOOZE_ENDED)
}

private fun suddenKill() {
firePixel(DeviceShieldPixelNames.ATP_KILLED)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,11 +234,12 @@ class TrackerBlockingVpnService : VpnService(), CoroutineScope by MainScope(), V
stopSelf()
}
}
ACTION_STOP_VPN -> {
ACTION_STOP_VPN, ACTION_SNOOZE_VPN -> {
synchronized(this) {
launch(serviceDispatcher) {
async {
stopVpn(VpnStopReason.SELF_STOP)
val snoozeTriggerAtMillisExtra = intent.getLongExtra(ACTION_SNOOZE_VPN_EXTRA, 0L)
stopVpn(VpnStopReason.SELF_STOP(snoozeTriggerAtMillisExtra))
}.await()
}
}
Expand Down Expand Up @@ -575,7 +576,7 @@ class TrackerBlockingVpnService : VpnService(), CoroutineScope by MainScope(), V

private fun sendStopPixels(reason: VpnStopReason) {
when (reason) {
VpnStopReason.SELF_STOP, VpnStopReason.RESTART, VpnStopReason.UNKNOWN -> {} // no-op
is VpnStopReason.SELF_STOP, VpnStopReason.RESTART, VpnStopReason.UNKNOWN -> {} // no-op
VpnStopReason.ERROR -> deviceShieldPixels.startError()
VpnStopReason.REVOKED -> deviceShieldPixels.suddenKillByVpnRevoked()
}
Expand Down Expand Up @@ -690,7 +691,7 @@ class TrackerBlockingVpnService : VpnService(), CoroutineScope by MainScope(), V
fun VpnStopReason.asVpnStoppingReason(): VpnStoppingReason {
return when (this) {
VpnStopReason.RESTART -> VpnStoppingReason.RESTART
VpnStopReason.SELF_STOP -> VpnStoppingReason.SELF_STOP
is VpnStopReason.SELF_STOP -> VpnStoppingReason.SELF_STOP
VpnStopReason.REVOKED -> VpnStoppingReason.REVOKED
VpnStopReason.ERROR -> VpnStoppingReason.ERROR
VpnStopReason.UNKNOWN -> VpnStoppingReason.UNKNOWN
Expand Down Expand Up @@ -733,6 +734,13 @@ class TrackerBlockingVpnService : VpnService(), CoroutineScope by MainScope(), V
}
}

private fun snoozeIntent(context: Context, triggerAtMillis: Long): Intent {
return serviceIntent(context).also {
it.action = ACTION_SNOOZE_VPN
it.putExtra(ACTION_SNOOZE_VPN_EXTRA, triggerAtMillis)
}
}

// This method was deprecated in API level 26. As of Build.VERSION_CODES.O,
// this method is no longer available to third party applications.
// For backwards compatibility, it will still return the caller's own services.
Expand Down Expand Up @@ -760,6 +768,16 @@ class TrackerBlockingVpnService : VpnService(), CoroutineScope by MainScope(), V
startVpnService(context.applicationContext)
}

internal fun snoozeService(context: Context, triggerAtMillis: Long) {
val appContext = context.applicationContext

if (!isServiceRunning(appContext)) return

snoozeIntent(appContext, triggerAtMillis).run {
ContextCompat.startForegroundService(appContext, this)
}
}

// TODO commented out for now, we'll see if we need it once we enable the new networking layer
// private fun startTrampolineService(context: Context) {
// val applicationContext = context.applicationContext
Expand Down Expand Up @@ -827,6 +845,8 @@ class TrackerBlockingVpnService : VpnService(), CoroutineScope by MainScope(), V
private const val ACTION_START_VPN = "ACTION_START_VPN"
private const val ACTION_STOP_VPN = "ACTION_STOP_VPN"
private const val ACTION_RESTART_VPN = "ACTION_RESTART_VPN"
private const val ACTION_SNOOZE_VPN = "ACTION_SNOOZE_VPN"
private const val ACTION_SNOOZE_VPN_EXTRA = "triggerAtMillis"
private const val ACTION_ALWAYS_ON_START = "android.net.VpnService"
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* Copyright (c) 2023 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.mobile.android.vpn.service

import android.app.AlarmManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.SystemClock
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.ReceiverScope
import com.duckduckgo.mobile.android.vpn.Vpn
import com.duckduckgo.mobile.android.vpn.pixels.DeviceShieldPixels
import dagger.android.AndroidInjection
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlinx.coroutines.withContext
import logcat.LogPriority
import logcat.logcat

@InjectWith(ReceiverScope::class)
class VpnActionReceiver : BroadcastReceiver() {
@Inject lateinit var vpn: Vpn

@Inject lateinit var dispatcherProvider: DispatcherProvider

@Inject lateinit var context: Context

@Inject lateinit var deviceShieldPixels: DeviceShieldPixels

override fun onReceive(
context: Context,
intent: Intent,
) {
AndroidInjection.inject(this, context)

logcat { "VpnActionReceiver onReceive ${intent.action}" }
val pendingResult = goAsync()

when (intent.action) {
ACTION_VPN_SNOOZE_END -> {
logcat { "Entire VPN will be enabled because the user asked it" }
goAsync(pendingResult) {
deviceShieldPixels.reportVpnSnoozedEnded()
vpn.start()
}
}

ACTION_VPN_DISABLE -> {
logcat { "Entire VPN will disabled because the user asked it" }
goAsync(pendingResult) {
vpn.stop()
}
}

ACTION_VPN_SNOOZE -> {
logcat { "Entire VPN will snooze because the user asked it" }
goAsync(pendingResult) {
deviceShieldPixels.reportVpnSnoozedStarted()
snoozeAndScheduleWakeUp()
}
}

else -> {
logcat(LogPriority.WARN) { "VpnActionReceiver: unknown action" }
pendingResult?.finish()
}
}
}

private suspend fun snoozeAndScheduleWakeUp() = withContext(dispatcherProvider.io()) {
vpn.snooze(DEFAULT_SNOOZE_LENGTH_IN_MILLIS)

val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
val alarmIntent = PendingIntent.getBroadcast(
context,
0,
Intent(context, VpnActionReceiver::class.java).apply {
action = ACTION_VPN_SNOOZE_END
},
PendingIntent.FLAG_IMMUTABLE,
)

if (alarmIntent != null && alarmManager != null) {
alarmManager.set(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + DEFAULT_SNOOZE_LENGTH_IN_MILLIS,
alarmIntent,
)
}
}

companion object {
internal const val ACTION_VPN_SNOOZE_END = "com.duckduckgo.vpn.ACTION_VPN_SNOOZE_END"
internal const val ACTION_VPN_DISABLE = "com.duckduckgo.vpn.ACTION_VPN_DISABLE"
internal const val ACTION_VPN_SNOOZE = "com.duckduckgo.vpn.ACTION_VPN_SNOOZE"
private val DEFAULT_SNOOZE_LENGTH_IN_MILLIS = TimeUnit.MINUTES.toMillis(20)
}
}
Loading

0 comments on commit ce9405d

Please sign in to comment.