Skip to content

Commit

Permalink
feat: offline support (#171)
Browse files Browse the repository at this point in the history
  • Loading branch information
Mercy811 authored Jan 24, 2024
1 parent 2df8267 commit 669eead
Show file tree
Hide file tree
Showing 17 changed files with 471 additions and 4 deletions.
4 changes: 4 additions & 0 deletions android/src/main/java/com/amplitude/android/Amplitude.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.amplitude.android.plugins.AnalyticsConnectorIdentityPlugin
import com.amplitude.android.plugins.AnalyticsConnectorPlugin
import com.amplitude.android.plugins.AndroidContextPlugin
import com.amplitude.android.plugins.AndroidLifecyclePlugin
import com.amplitude.android.plugins.AndroidNetworkConnectivityCheckerPlugin
import com.amplitude.core.Amplitude
import com.amplitude.core.events.BaseEvent
import com.amplitude.core.platform.plugins.AmplitudeDestination
Expand Down Expand Up @@ -56,6 +57,9 @@ open class Amplitude(
}
this.createIdentityContainer(identityConfiguration)

if (this.configuration.offline != AndroidNetworkConnectivityCheckerPlugin.Disabled) {
add(AndroidNetworkConnectivityCheckerPlugin())
}
androidContextPlugin = AndroidContextPlugin()
add(androidContextPlugin)
add(GetAmpliExtrasPlugin())
Expand Down
3 changes: 2 additions & 1 deletion android/src/main/java/com/amplitude/android/Configuration.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ open class Configuration @JvmOverloads constructor(
override var identifyInterceptStorageProvider: StorageProvider = AndroidStorageProvider(),
override var identityStorageProvider: IdentityStorageProvider = FileIdentityStorageProvider(),
var migrateLegacyData: Boolean = true,
) : Configuration(apiKey, flushQueueSize, flushIntervalMillis, instanceName, optOut, storageProvider, loggerProvider, minIdLength, partnerId, callback, flushMaxRetries, useBatch, serverZone, serverUrl, plan, ingestionMetadata, identifyBatchIntervalMillis, identifyInterceptStorageProvider, identityStorageProvider) {
override var offline: Boolean? = false,
) : Configuration(apiKey, flushQueueSize, flushIntervalMillis, instanceName, optOut, storageProvider, loggerProvider, minIdLength, partnerId, callback, flushMaxRetries, useBatch, serverZone, serverUrl, plan, ingestionMetadata, identifyBatchIntervalMillis, identifyInterceptStorageProvider, identityStorageProvider, offline) {
companion object {
const val MIN_TIME_BETWEEN_SESSIONS_MILLIS: Long = 300000
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.amplitude.android.plugins

import com.amplitude.android.Configuration
import com.amplitude.android.utilities.AndroidNetworkConnectivityChecker
import com.amplitude.android.utilities.AndroidNetworkListener
import com.amplitude.core.Amplitude
import com.amplitude.core.platform.Plugin
import kotlinx.coroutines.launch

class AndroidNetworkConnectivityCheckerPlugin : Plugin {
override val type: Plugin.Type = Plugin.Type.Before
override lateinit var amplitude: Amplitude
internal lateinit var networkConnectivityChecker: AndroidNetworkConnectivityChecker
internal lateinit var networkListener: AndroidNetworkListener

companion object {
val Disabled = null
}

override fun setup(amplitude: Amplitude) {
super.setup(amplitude)
amplitude.logger.debug("Installing AndroidNetworkConnectivityPlugin, offline feature should be supported.")
networkConnectivityChecker = AndroidNetworkConnectivityChecker((amplitude.configuration as Configuration).context, amplitude.logger)
amplitude.amplitudeScope.launch(amplitude.storageIODispatcher) {
amplitude.configuration.offline = !networkConnectivityChecker.isConnected()
}
val networkChangeHandler =
object : AndroidNetworkListener.NetworkChangeCallback {
override fun onNetworkAvailable() {
amplitude.logger.debug("AndroidNetworkListener, onNetworkAvailable.")
amplitude.configuration.offline = false
amplitude.flush()
}

override fun onNetworkUnavailable() {
amplitude.logger.debug("AndroidNetworkListener, onNetworkUnavailable.")
amplitude.configuration.offline = true
}
}
networkListener = AndroidNetworkListener((amplitude.configuration as Configuration).context)
networkListener.setNetworkChangeCallback(networkChangeHandler)
networkListener.startListening()
}

override fun teardown() {
networkListener.stopListening()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.amplitude.android.utilities

import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import com.amplitude.common.Logger

class AndroidNetworkConnectivityChecker(private val context: Context, private val logger: Logger) {
companion object {
private const val ACCESS_NETWORK_STATE = "android.permission.ACCESS_NETWORK_STATE"
}

private val hasPermission: Boolean
internal var isMarshmallowAndAbove: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M

init {
hasPermission = hasPermission(context, ACCESS_NETWORK_STATE)
if (!hasPermission) {
logger.warn(
@Suppress("ktlint:standard:max-line-length")
"No ACCESS_NETWORK_STATE permission, offline mode is not supported. To enable, add <uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\" /> to your AndroidManifest.xml. Learn more at https://www.docs.developers.amplitude.com/data/sdks/android-kotlin/#offline-mode",
)
}
}

@SuppressLint("MissingPermission", "NewApi")
fun isConnected(): Boolean {
// Assume connection and proceed.
// Events will be treated like online
// regardless network connectivity
if (!hasPermission) {
return true
}

val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE)
if (cm is ConnectivityManager) {
if (isMarshmallowAndAbove) {
val network = cm.activeNetwork ?: return false
val capabilities = cm.getNetworkCapabilities(network) ?: return false

return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
} else {
@SuppressLint("MissingPermission")
val networkInfo = cm.activeNetworkInfo
return networkInfo != null && networkInfo.isConnectedOrConnecting
}
} else {
logger.debug("Service is not an instance of ConnectivityManager. Offline mode is not supported")
return true
}
}

private fun hasPermission(
context: Context,
permission: String,
): Boolean {
return context.checkCallingOrSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package com.amplitude.android.utilities

import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import java.lang.IllegalArgumentException

class AndroidNetworkListener(private val context: Context) {
private var networkCallback: NetworkChangeCallback? = null
private var networkCallbackForLowerApiLevels: BroadcastReceiver? = null
private var networkCallbackForHigherApiLevels: ConnectivityManager.NetworkCallback? = null

interface NetworkChangeCallback {
fun onNetworkAvailable()

fun onNetworkUnavailable()
}

fun setNetworkChangeCallback(callback: NetworkChangeCallback) {
this.networkCallback = callback
}

fun startListening() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
setupNetworkCallback()
} else {
setupBroadcastReceiver()
}
}

@SuppressLint("NewApi", "MissingPermission")
// startListening() checks API level
// ACCESS_NETWORK_STATE permission should be added manually by users to enable this feature
private fun setupNetworkCallback() {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val networkRequest =
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()

networkCallbackForHigherApiLevels =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
networkCallback?.onNetworkAvailable()
}

override fun onLost(network: Network) {
networkCallback?.onNetworkUnavailable()
}
}

connectivityManager.registerNetworkCallback(networkRequest, networkCallbackForHigherApiLevels!!)
}

private fun setupBroadcastReceiver() {
networkCallbackForLowerApiLevels =
object : BroadcastReceiver() {
@SuppressLint("MissingPermission")
override fun onReceive(
context: Context,
intent: Intent,
) {
if (ConnectivityManager.CONNECTIVITY_ACTION == intent.action) {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork = connectivityManager.activeNetworkInfo
val isConnected = activeNetwork?.isConnectedOrConnecting == true

if (isConnected) {
networkCallback?.onNetworkAvailable()
} else {
networkCallback?.onNetworkUnavailable()
}
}
}
}

val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
context.registerReceiver(networkCallbackForLowerApiLevels, filter)
}

fun stopListening() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
networkCallbackForHigherApiLevels?.let { connectivityManager.unregisterNetworkCallback(it) }
} else {
networkCallbackForLowerApiLevels?.let { context.unregisterReceiver(it) }
}
} catch (e: IllegalArgumentException) {
// callback was already unregistered.
} catch (e: IllegalStateException) {
// shutdown process is in progress and certain operations are not allowed.
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.amplitude.android

import android.app.Application
import android.content.Context
import android.net.ConnectivityManager
import com.amplitude.core.events.BaseEvent
import com.amplitude.core.utilities.ConsoleLoggerProvider
import com.amplitude.id.IMIdentityStorageProvider
Expand All @@ -23,15 +24,17 @@ import kotlin.io.path.absolutePathString
class AmplitudeRobolectricTests {
private lateinit var amplitude: Amplitude
private var context: Context? = null
private lateinit var connectivityManager: ConnectivityManager

var tempDir = TempDirectory()

@ExperimentalCoroutinesApi
@Before
fun setup() {
context = mockk<Application>(relaxed = true)
connectivityManager = mockk<ConnectivityManager>(relaxed = true)
every { context!!.getDir(any(), any()) } returns File(tempDir.create("data").absolutePathString())

every { context!!.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager
amplitude = Amplitude(createConfiguration())
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.amplitude.android

import android.app.Application
import android.content.Context
import android.net.ConnectivityManager
import com.amplitude.android.plugins.AndroidLifecyclePlugin
import com.amplitude.common.android.AndroidContextProvider
import com.amplitude.core.Storage
Expand Down Expand Up @@ -64,6 +66,8 @@ class AmplitudeSessionTest {

private fun createConfiguration(storageProvider: StorageProvider? = null): Configuration {
val context = mockk<Application>(relaxed = true)
var connectivityManager = mockk<ConnectivityManager>(relaxed = true)
every { context!!.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager

return Configuration(
apiKey = "api-key",
Expand Down
5 changes: 5 additions & 0 deletions android/src/test/java/com/amplitude/android/AmplitudeTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.amplitude.android

import android.app.Application
import android.content.Context
import android.net.ConnectivityManager
import com.amplitude.analytics.connector.AnalyticsConnector
import com.amplitude.analytics.connector.Identity
import com.amplitude.android.plugins.AndroidLifecyclePlugin
Expand Down Expand Up @@ -38,10 +39,14 @@ open class StubPlugin : EventPlugin {
class AmplitudeTest {
private var context: Context? = null
private var amplitude: Amplitude? = null
private lateinit var connectivityManager: ConnectivityManager

@BeforeEach
fun setUp() {
context = mockk<Application>(relaxed = true)
connectivityManager = mockk<ConnectivityManager>(relaxed = true)
every { context!!.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager

mockkStatic(AndroidLifecyclePlugin::class)

mockkConstructor(AndroidContextProvider::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package com.amplitude.android.plugins

import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.Uri
import android.os.Bundle
import com.amplitude.android.Amplitude
Expand Down Expand Up @@ -44,6 +46,7 @@ class AndroidLifecyclePluginTest {

private val mockedContext = mockk<Application>(relaxed = true)
private var mockedPackageManager: PackageManager
private lateinit var connectivityManager: ConnectivityManager

init {
val packageInfo = PackageInfo()
Expand Down Expand Up @@ -82,6 +85,9 @@ class AndroidLifecyclePluginTest {
every { anyConstructed<AndroidContextProvider>().mostRecentLocation } returns null
every { anyConstructed<AndroidContextProvider>().appSetId } returns ""

connectivityManager = mockk<ConnectivityManager>(relaxed = true)
every { mockedContext!!.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager

configuration = Configuration(
apiKey = "api-key",
context = mockedContext,
Expand Down
Loading

0 comments on commit 669eead

Please sign in to comment.