Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: offline support #171

Merged
merged 19 commits into from
Jan 24, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 != null) {
justin-fiedler marked this conversation as resolved.
Show resolved Hide resolved
Mercy811 marked this conversation as resolved.
Show resolved Hide resolved
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()
justin-fiedler marked this conversation as resolved.
Show resolved Hide resolved
}

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) {
justin-fiedler marked this conversation as resolved.
Show resolved Hide resolved
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()
justin-fiedler marked this conversation as resolved.
Show resolved Hide resolved
}

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
Loading