Skip to content

Commit

Permalink
feat: offline support
Browse files Browse the repository at this point in the history
Save events on offline and send out when back to online automatically
  • Loading branch information
Mercy811 committed Dec 30, 2023
1 parent 0315e8e commit 7d97bb0
Show file tree
Hide file tree
Showing 11 changed files with 1,219 additions and 879 deletions.
1 change: 1 addition & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.amplitude.android">

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>
61 changes: 49 additions & 12 deletions android/src/main/java/com/amplitude/android/Amplitude.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package com.amplitude.android

import AndroidNetworkListener
import android.content.Context
import com.amplitude.android.migration.ApiKeyStorageMigration
import com.amplitude.android.migration.RemnantDataMigration
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.utilities.AndroidNetworkConnectivityChecker
import com.amplitude.core.Amplitude
import com.amplitude.core.events.BaseEvent
import com.amplitude.core.platform.plugins.AmplitudeDestination
Expand All @@ -16,11 +18,21 @@ import com.amplitude.id.IdentityConfiguration
import kotlinx.coroutines.launch

open class Amplitude(
configuration: Configuration
) : Amplitude(configuration) {

configuration: Configuration,
) : Amplitude(configuration), AndroidNetworkListener.NetworkChangeCallback {
internal var inForeground = false
private lateinit var androidContextPlugin: AndroidContextPlugin
private var networkListener: AndroidNetworkListener
private val networkChangeHandler =
object : AndroidNetworkListener.NetworkChangeCallback {
override fun onNetworkAvailable() {
flush()
}

override fun onNetworkUnavailable() {
// Nothing to do so far
}
}

val sessionId: Long
get() {
Expand All @@ -29,6 +41,9 @@ open class Amplitude(

init {
registerShutdownHook()
networkListener = AndroidNetworkListener((this.configuration as Configuration).context)
networkListener.setNetworkChangeCallback(networkChangeHandler)
networkListener.startListening()
}

override fun createTimeline(): Timeline {
Expand All @@ -37,14 +52,18 @@ open class Amplitude(

override fun createIdentityConfiguration(): IdentityConfiguration {
val configuration = configuration as Configuration
val storageDirectory = configuration.context.getDir("${FileStorage.STORAGE_PREFIX}-${configuration.instanceName}", Context.MODE_PRIVATE)
val storageDirectory =
configuration.context.getDir(
"${FileStorage.STORAGE_PREFIX}-${configuration.instanceName}",
Context.MODE_PRIVATE,
)

return IdentityConfiguration(
instanceName = configuration.instanceName,
apiKey = configuration.apiKey,
identityStorageProvider = configuration.identityStorageProvider,
storageDirectory = storageDirectory,
logger = configuration.loggerProvider.getLogger(this)
logger = configuration.loggerProvider.getLogger(this),
)
}

Expand All @@ -62,7 +81,7 @@ open class Amplitude(
add(AndroidLifecyclePlugin())
add(AnalyticsConnectorIdentityPlugin())
add(AnalyticsConnectorPlugin())
add(AmplitudeDestination())
add(AmplitudeDestination(AndroidNetworkConnectivityChecker(this.configuration.context, this.logger)))

(timeline as Timeline).start()
}
Expand Down Expand Up @@ -113,18 +132,22 @@ open class Amplitude(
}

private fun registerShutdownHook() {
Runtime.getRuntime().addShutdownHook(object : Thread() {
override fun run() {
(this@Amplitude.timeline as Timeline).stop()
}
})
Runtime.getRuntime().addShutdownHook(
object : Thread() {
override fun run() {
(this@Amplitude.timeline as Timeline).stop()
(this@Amplitude.networkListener as AndroidNetworkListener).stopListening()
}
},
)
}

companion object {
/**
* The event type for start session events.
*/
const val START_SESSION_EVENT = "session_start"

/**
* The event type for end session events.
*/
Expand All @@ -134,12 +157,22 @@ open class Amplitude(
* The event type for dummy enter foreground events.
*/
internal const val DUMMY_ENTER_FOREGROUND_EVENT = "dummy_enter_foreground"

/**
* The event type for dummy exit foreground events.
*/
internal const val DUMMY_EXIT_FOREGROUND_EVENT = "dummy_exit_foreground"
}

override fun onNetworkAvailable() {
networkChangeHandler.onNetworkAvailable()
}

override fun onNetworkUnavailable() {
networkChangeHandler.onNetworkUnavailable()
}
}

/**
* constructor function to build amplitude in dsl format with config options
* Usage: Amplitude("123", context) {
Expand All @@ -153,7 +186,11 @@ open class Amplitude(
* @param configs Configuration
* @return Amplitude Android Instance
*/
fun Amplitude(apiKey: String, context: Context, configs: Configuration.() -> Unit): com.amplitude.android.Amplitude {
fun Amplitude(
apiKey: String,
context: Context,
configs: Configuration.() -> Unit,
): com.amplitude.android.Amplitude {
val config = Configuration(apiKey, context)
configs.invoke(config)
return com.amplitude.android.Amplitude(config)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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
import com.amplitude.core.platform.NetworkConnectivityChecker

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

override suspend fun isConnected(): Boolean {
// Assume connection and proceed.
// Events will be treated like online
// regardless network connectivity
if (!hasPermission(context, ACCESS_NETWORK_STATE)) {
logger.warn("No ACCESS_NETWORK_STATE permission, offline mode is not supported.")
return true
}

val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
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
}
}

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,97 @@
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")
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() {
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
Loading

0 comments on commit 7d97bb0

Please sign in to comment.