From aee7440b7ee3f68885adee33b3cdd9f39830d4d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0emsudin=20Tafilovi=C4=87?= <25798677+semsudin-tafilovic@users.noreply.github.com> Date: Wed, 27 Nov 2024 11:27:53 +0100 Subject: [PATCH 1/2] Fix worker limit exceeded (#33) * Updated SchedulerImpl to better handle scheduling of workers; * Optimized Wokrer scheduling tasks. --- android-sdk/build.gradle | 63 ++-- .../webtrekk/android/sdk/core/Scheduler.kt | 13 +- .../android/sdk/core/SchedulerImpl.kt | 145 ++++----- .../webtrekk/android/sdk/core/WebtrekkImpl.kt | 8 +- .../android/sdk/domain/external/Optout.kt | 35 +-- .../sdk/domain/external/SendAndClean.kt | 8 +- .../sdk/domain/worker/CleanUpWorker.kt | 84 +++--- .../sdk/domain/worker/SendRequestsWorker.kt | 23 +- build.gradle | 69 +---- sample/build.gradle | 26 +- sample/google-services.json | 277 +++++++++++++++++- sample/src/main/AndroidManifest.xml | 7 +- .../webtrekk/androidsdk/MainActivity.kt | 8 +- .../webtrekk/androidsdk/WorkSchedulerTest.kt | 134 +++++++++ sample/src/main/res/layout/activity_main.xml | 4 +- .../layout/activity_work_scheduler_test.xml | 26 ++ 16 files changed, 652 insertions(+), 278 deletions(-) create mode 100644 sample/src/main/java/com/example/webtrekk/androidsdk/WorkSchedulerTest.kt create mode 100644 sample/src/main/res/layout/activity_work_scheduler_test.xml diff --git a/android-sdk/build.gradle b/android-sdk/build.gradle index f679998f..7e16d4a3 100644 --- a/android-sdk/build.gradle +++ b/android-sdk/build.gradle @@ -96,39 +96,36 @@ android { dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation libs.kotlinStdlib - implementation libs.appCompat - - implementation libs.coroutinesCore - implementation libs.coroutinesAndroid - - implementation libs.workmanager - - implementation libs.okHttp - - implementation libs.room - implementation libs.roomKtx - testImplementation 'junit:junit:4.13.2' - kapt libs.roomCompiler - - testImplementation libs.junit - testImplementation libs.androidTestCore - testImplementation libs.mockk - testImplementation libs.coroutinesTest - testImplementation libs.kotlinTest - - androidTestImplementation libs.androidTestJunit - androidTestImplementation libs.androidTestCore - androidTestImplementation libs.runner - androidTestImplementation libs.androidMockk - androidTestImplementation libs.androidArchCoreTest - - androidTestImplementation libs.androidRoomTest - androidTestImplementation libs.androidWorkmanagerTest - androidTestImplementation libs.truth - testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.3' - //testImplementation 'org.junit.jupiter:junit-jupiter:5.8.2' - //api group: 'org.slf4j', name: 'slf4j-simple', version: '1.7.25' + implementation ("androidx.appcompat:appcompat:1.7.0") + implementation ("org.jetbrains.kotlin:kotlin-stdlib:1.9.24") + + implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") + implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + + implementation ("androidx.work:work-runtime-ktx:2.9.1") + + implementation ("com.squareup.okhttp3:okhttp:4.12.0") + + implementation ("androidx.room:room-runtime:2.6.1") + implementation ("androidx.room:room-ktx:2.6.1") + kapt ("androidx.room:room-compiler:2.6.1") + + testImplementation ("junit:junit:4.13.2") + testImplementation ("androidx.test:core:1.6.1") + testImplementation ("io.mockk:mockk:1.13.12") + testImplementation ("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") + testImplementation ("io.kotlintest:kotlintest-runner-junit5:3.4.2") + testImplementation ("org.junit.jupiter:junit-jupiter-engine:5.10.3") + + androidTestImplementation ("androidx.test.ext:junit:1.2.1") + androidTestImplementation ("androidx.test:core:1.6.1") + androidTestImplementation ("androidx.test:runner:1.6.2") + androidTestImplementation ("io.mockk:mockk-android:1.13.12") + //androidTestImplementation ("androidx.arch.core:core-testing:2.2.0") + + androidTestImplementation ("androidx.room:room-testing:2.6.1") + androidTestImplementation ("androidx.work:work-testing:2.9.1") + androidTestImplementation ("com.google.truth:truth:1.4.4") } /** diff --git a/android-sdk/src/main/java/webtrekk/android/sdk/core/Scheduler.kt b/android-sdk/src/main/java/webtrekk/android/sdk/core/Scheduler.kt index 6072c8f0..1268593c 100644 --- a/android-sdk/src/main/java/webtrekk/android/sdk/core/Scheduler.kt +++ b/android-sdk/src/main/java/webtrekk/android/sdk/core/Scheduler.kt @@ -40,20 +40,25 @@ internal interface Scheduler { * @param repeatInterval the periodic time that will be used by [WorkManager] to send the requests from the cache to the server. * @param constraints the [WorkManager] constraints that will be applied on that worker. */ - fun scheduleSendRequests(repeatInterval: Long, constraints: Constraints) + suspend fun scheduleSendRequests(repeatInterval: Long, constraints: Constraints) /** * A one time worker that will be used to send all available requests in the cache to the server, then cleaning up the cache. Used for Opt out. */ - fun sendRequestsThenCleanUp() + suspend fun sendRequestsThenCleanUp() /** * A worker that is scheduled to clean up the requests in the cache that are already sent to the server. */ - fun scheduleCleanUp() + suspend fun scheduleCleanUp() /** * Cancel current periodic worker that is used to send the request every n times. Used for Opt out. */ - fun cancelScheduleSendRequests() + suspend fun cancelScheduleSendRequests() + + /** + * Delete records about completed or canceled works + */ + suspend fun pruneWorks() } \ No newline at end of file diff --git a/android-sdk/src/main/java/webtrekk/android/sdk/core/SchedulerImpl.kt b/android-sdk/src/main/java/webtrekk/android/sdk/core/SchedulerImpl.kt index 8e432bb0..6a572496 100644 --- a/android-sdk/src/main/java/webtrekk/android/sdk/core/SchedulerImpl.kt +++ b/android-sdk/src/main/java/webtrekk/android/sdk/core/SchedulerImpl.kt @@ -32,13 +32,18 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.OutOfQuotaPolicy -import androidx.work.PeriodicWorkRequest +import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager +import androidx.work.WorkQuery +import androidx.work.await import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import webtrekk.android.sdk.Config import webtrekk.android.sdk.domain.worker.CleanUpWorker import webtrekk.android.sdk.domain.worker.SendRequestsWorker +import webtrekk.android.sdk.module.AppModule import webtrekk.android.sdk.util.webtrekkLogger import java.util.concurrent.TimeUnit @@ -50,48 +55,72 @@ internal class SchedulerImpl( private val config: Config, ) : Scheduler { - private val mutex = Mutex() - - override fun scheduleSendRequests( + override suspend fun scheduleSendRequests( repeatInterval: Long, constraints: Constraints, ) { - webtrekkLogger.debug("SEND WORKER - scheduleSendRequests") - synchronized(mutex) { - val data = Data.Builder().apply { - putStringArray("trackIds", config.trackIds.toTypedArray()) - putString("trackDomain", config.trackDomain) - }.build() - - val workBuilder = PeriodicWorkRequest.Builder( - SendRequestsWorker::class.java, - repeatInterval, - TimeUnit.MINUTES - ).setConstraints(constraints) - .setInitialDelay(0, TimeUnit.MILLISECONDS) - .setInputData(data) - .addTag(SendRequestsWorker.TAG) - - val sendRequestsWorker = workBuilder.build() - - workManager.enqueueUniquePeriodicWork( - SEND_REQUESTS_WORKER, - ExistingPeriodicWorkPolicy.UPDATE, - sendRequestsWorker - ) + withContext(AppModule.dispatchers.mainDispatcher) { + webtrekkLogger.debug("SEND WORKER - scheduleSendRequests") + mutex.withLock { + val data = Data.Builder().apply { + putStringArray("trackIds", config.trackIds.toTypedArray()) + putString("trackDomain", config.trackDomain) + }.build() + + val workBuilder = PeriodicWorkRequestBuilder( + repeatInterval, + TimeUnit.MINUTES + ).setConstraints(constraints) + .setInitialDelay(0, TimeUnit.MILLISECONDS) + .setInputData(data) + + val sendRequestsWorker = workBuilder.build() + + workManager.enqueueUniquePeriodicWork( + SEND_REQUESTS_WORKER, + ExistingPeriodicWorkPolicy.UPDATE, + sendRequestsWorker + ) + } + } + } + + // To be changed to clean up after executing the requests + override suspend fun scheduleCleanUp() { + withContext(AppModule.dispatchers.mainDispatcher) { + mutex.withLock { + val data = Data.Builder().apply { + putStringArray("trackIds", config.trackIds.toTypedArray()) + putString("trackDomain", config.trackDomain) + }.build() + + val cleanWorkBuilder = + PeriodicWorkRequestBuilder( + 60, + TimeUnit.MINUTES + ).setInitialDelay(5, TimeUnit.MINUTES) + .setInputData(data) + + workManager.enqueueUniquePeriodicWork( + CLEAN_UP_WORKER, + ExistingPeriodicWorkPolicy.UPDATE, + cleanWorkBuilder.build() + ) + } } } - override fun sendRequestsThenCleanUp() { - webtrekkLogger.debug("SEND WORKER - sendRequestsThenCleanUp") - synchronized(mutex) { - // check if SendRequestsWorker already running as periodic work request -// val future = workManager.getWorkInfosByTag(SendRequestsWorker.TAG) -// val workers = future.get() -// if (workers.none { it.state in listOf(WorkInfo.State.RUNNING) }) { -// scheduleSendAndCleanWorkers() -// } - scheduleSendAndCleanWorkers() + override suspend fun sendRequestsThenCleanUp() { + withContext(AppModule.dispatchers.mainDispatcher) { + webtrekkLogger.debug("SEND WORKER - sendRequestsThenCleanUp") + mutex.withLock { + // check if SendRequestsWorker already running as periodic work request + val query = WorkQuery.fromTags(SendRequestsWorker::class.java.name) + val workers = workManager.getWorkInfos(query).get() + if (workers.none { it.state in listOf(WorkInfo.State.RUNNING) }) { + scheduleSendAndCleanWorkers() + } + } } } @@ -102,54 +131,36 @@ internal class SchedulerImpl( }.build() val sendWorkBuilder = OneTimeWorkRequest.Builder(SendRequestsWorker::class.java) - .addTag(SendRequestsWorker.TAG) .setInputData(data) val cleanWorkBuilder = OneTimeWorkRequest.Builder(CleanUpWorker::class.java) .setInputData(data) - .addTag(CleanUpWorker.TAG) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { sendWorkBuilder.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) cleanWorkBuilder.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) } - workManager.enqueueUniqueWork( - ONE_TIME_REQUEST, - ExistingWorkPolicy.APPEND_OR_REPLACE, - listOf(sendWorkBuilder.build(), cleanWorkBuilder.build()) - ) + workManager + .beginUniqueWork(ONE_TIME_REQUEST, ExistingWorkPolicy.REPLACE, sendWorkBuilder.build()) + .then(cleanWorkBuilder.build()) + .enqueue() } - // To be changed to clean up after executing the requests - override fun scheduleCleanUp() { - val data = Data.Builder().apply { - putStringArray("trackIds", config.trackIds.toTypedArray()) - putString("trackDomain", config.trackDomain) - }.build() - - val cleanWorkBuilder = OneTimeWorkRequest.Builder(CleanUpWorker::class.java) - .addTag(CleanUpWorker.TAG) - .setInputData(data) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - cleanWorkBuilder.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - } - - workManager.enqueueUniqueWork( - "CleanUpWorker", - ExistingWorkPolicy.APPEND_OR_REPLACE, - cleanWorkBuilder.build() - ) + override suspend fun cancelScheduleSendRequests() { + workManager.cancelUniqueWork(CLEAN_UP_WORKER) + workManager.cancelUniqueWork(ONE_TIME_REQUEST) + workManager.cancelUniqueWork(SEND_REQUESTS_WORKER) } - override fun cancelScheduleSendRequests() { - workManager.cancelAllWorkByTag(CleanUpWorker.TAG) - workManager.cancelAllWorkByTag(SendRequestsWorker.TAG) + override suspend fun pruneWorks() { + workManager.pruneWork().await() } companion object { + private val mutex = Mutex() const val SEND_REQUESTS_WORKER = "send_requests_worker" const val ONE_TIME_REQUEST = "one_time_request_send_and_clean" + const val CLEAN_UP_WORKER = "CleanUpWorker" } } diff --git a/android-sdk/src/main/java/webtrekk/android/sdk/core/WebtrekkImpl.kt b/android-sdk/src/main/java/webtrekk/android/sdk/core/WebtrekkImpl.kt index d2238502..f8cae2d3 100644 --- a/android-sdk/src/main/java/webtrekk/android/sdk/core/WebtrekkImpl.kt +++ b/android-sdk/src/main/java/webtrekk/android/sdk/core/WebtrekkImpl.kt @@ -546,8 +546,14 @@ constructor() : Webtrekk(), if (config.exceptionLogLevel.isUncaughtAllowed()) { initUncaughtExceptionTracking() } - // Scheduling the workers for cleaning up the current cache, and setting up the periodic worker for sending the requests. + + // Clean all finished works (SUCCEEDED, FAILED, CANCELED) + scheduler.pruneWorks() + + // Scheduling the workers for clearing sent requests. scheduler.scheduleCleanUp() + + // Scheduling the periodic worker for sending requests scheduler.scheduleSendRequests( repeatInterval = config.requestsInterval, constraints = config.workManagerConstraints diff --git a/android-sdk/src/main/java/webtrekk/android/sdk/domain/external/Optout.kt b/android-sdk/src/main/java/webtrekk/android/sdk/domain/external/Optout.kt index b22a5e8e..c508c650 100644 --- a/android-sdk/src/main/java/webtrekk/android/sdk/domain/external/Optout.kt +++ b/android-sdk/src/main/java/webtrekk/android/sdk/domain/external/Optout.kt @@ -29,6 +29,7 @@ import android.content.Context import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import webtrekk.android.sdk.core.AppState import webtrekk.android.sdk.core.Scheduler @@ -52,7 +53,7 @@ internal class Optout( private val clearTrackRequests: ClearTrackRequests ) : ExternalInteractor { - private val _job = Job() + private val _job = SupervisorJob() override val scope = CoroutineScope(_job + coroutineContext) // Starting a new job with context of the parent. @@ -62,23 +63,23 @@ internal class Optout( private val logger by lazy { AppModule.logger } override fun invoke(invokeParams: Params, coroutineDispatchers: CoroutineDispatchers) { - // Store the opt out value in the shared preferences. - sessions.optOut(invokeParams.optOutValue) + scope.launch( + context = coroutineDispatchers.ioDispatcher + coroutineExceptionHandler( + logger + ), + start = CoroutineStart.DEFAULT + ) { + // Store the opt out value in the shared preferences. + sessions.optOut(invokeParams.optOutValue) - // If opt out value is set to true, then disable tracking data, cancel all work manager workers and delete or send then delete current data in the data base. - if (invokeParams.optOutValue) { - appState.disable(invokeParams.context) // Disable the auto track - scheduler.cancelScheduleSendRequests() // Cancel the work manager workers - // If sendCurrentData is true, then one time worker will send current data requests to the server, then clean up the data base. - if (invokeParams.sendCurrentData) { - scheduler.sendRequestsThenCleanUp() - } else { - scope.launch( - context = coroutineDispatchers.ioDispatcher + coroutineExceptionHandler( - logger - ), - start = CoroutineStart.DEFAULT - ) { + // If opt out value is set to true, then disable tracking data, cancel all work manager workers and delete or send then delete current data in the data base. + if (invokeParams.optOutValue) { + appState.disable(invokeParams.context) // Disable the auto track + scheduler.cancelScheduleSendRequests() // Cancel the work manager workers + // If sendCurrentData is true, then one time worker will send current data requests to the server, then clean up the data base. + if (invokeParams.sendCurrentData) { + scheduler.sendRequestsThenCleanUp() + } else { clearTrackRequests(ClearTrackRequests.Params(trackRequests = emptyList())) .onSuccess { logger.debug("Cleared all track requests, opt out is active") } .onFailure { logger.error("Failed to clear the track requests while opting out") } diff --git a/android-sdk/src/main/java/webtrekk/android/sdk/domain/external/SendAndClean.kt b/android-sdk/src/main/java/webtrekk/android/sdk/domain/external/SendAndClean.kt index 5cc3ea32..767279ce 100644 --- a/android-sdk/src/main/java/webtrekk/android/sdk/domain/external/SendAndClean.kt +++ b/android-sdk/src/main/java/webtrekk/android/sdk/domain/external/SendAndClean.kt @@ -28,6 +28,8 @@ package webtrekk.android.sdk.domain.external import android.content.Context import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import webtrekk.android.sdk.Config import webtrekk.android.sdk.core.Scheduler import webtrekk.android.sdk.domain.ExternalInteractor @@ -42,12 +44,14 @@ internal class SendAndClean( private val scheduler: Scheduler ) : ExternalInteractor { - private val _job = Job() + private val _job = SupervisorJob() override val scope = CoroutineScope(_job + coroutineContext) // Starting a new job with context of the parent. override fun invoke(invokeParams: Params, coroutineDispatchers: CoroutineDispatchers) { - scheduler.sendRequestsThenCleanUp() + scope.launch { + scheduler.sendRequestsThenCleanUp() + } } /** diff --git a/android-sdk/src/main/java/webtrekk/android/sdk/domain/worker/CleanUpWorker.kt b/android-sdk/src/main/java/webtrekk/android/sdk/domain/worker/CleanUpWorker.kt index 6bd013e2..4d909208 100644 --- a/android-sdk/src/main/java/webtrekk/android/sdk/domain/worker/CleanUpWorker.kt +++ b/android-sdk/src/main/java/webtrekk/android/sdk/domain/worker/CleanUpWorker.kt @@ -27,8 +27,11 @@ package webtrekk.android.sdk.domain.worker import android.content.Context import androidx.work.CoroutineWorker +import androidx.work.WorkManager import androidx.work.WorkerParameters import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import webtrekk.android.sdk.Webtrekk import webtrekk.android.sdk.WebtrekkConfiguration @@ -48,51 +51,46 @@ internal class CleanUpWorker( workerParameters: WorkerParameters ) : CoroutineWorker(context, workerParameters) { + /** + * [coroutineDispatchers] the injected coroutine dispatchers. + */ + val coroutineDispatchers: CoroutineDispatchers = AppModule.dispatchers override suspend fun doWork(): Result = coroutineScope { - // this check and initialization is needed for cross platform solutions - if (!Webtrekk.getInstance().isInitialized()) { - val configJson = WebtrekkSharedPrefs(applicationContext).configJson - val config = WebtrekkConfiguration.fromJson(configJson) - Webtrekk.getInstance().init(applicationContext, config) - } - - /** - * [coroutineDispatchers] the injected coroutine dispatchers. - */ - /** - * [coroutineDispatchers] the injected coroutine dispatchers. - */ - val coroutineDispatchers: CoroutineDispatchers = AppModule.dispatchers + withContext(coroutineDispatchers.ioDispatcher) { + mutex.withLock { + /** + * [logger] the injected logger from Webtrekk. + */ + val logger by lazy { AppModule.logger } - /** - * [getCachedDataTracks] the injected internal interactor for getting the data from the data base. - */ - /** - * [getCachedDataTracks] the injected internal interactor for getting the data from the data base. - */ - val getCachedDataTracks: GetCachedDataTracks = InteractorModule.getCachedDataTracks() + logger.debug("doWork - starting... ${tags.joinToString(separator = ", ")}") + // this check and initialization is needed for cross platform solutions + if (!Webtrekk.getInstance().isInitialized()) { + val configJson = WebtrekkSharedPrefs(applicationContext).configJson + val config = WebtrekkConfiguration.fromJson(configJson) + Webtrekk.getInstance().init(applicationContext, config) + } - /** - * [clearTrackRequests] the injected internal interactor for deleting the data in the data base. - */ - /** - * [clearTrackRequests] the injected internal interactor for deleting the data in the data base. - */ - val clearTrackRequests: ClearTrackRequests = InteractorModule.clearTrackRequest() + /** + * [getCachedDataTracks] the injected internal interactor for getting the data from the data base. + */ + val getCachedDataTracks: GetCachedDataTracks = + InteractorModule.getCachedDataTracks() - /** - * [logger] the injected logger from Webtrekk. - */ - /** - * [logger] the injected logger from Webtrekk. - */ - val logger by lazy { AppModule.logger } + /** + * [clearTrackRequests] the injected internal interactor for deleting the data in the data base. + */ + val clearTrackRequests: ClearTrackRequests = InteractorModule.clearTrackRequest() - // get the data from the data base with state DONE only. - withContext(coroutineDispatchers.ioDispatcher) { - getCachedDataTracks(GetCachedDataTracks.Params(requestStates = listOf(TrackRequest.RequestState.DONE))) - .onSuccess { dataTracks -> + // get the data from the data base with state DONE only. + getCachedDataTracks( + GetCachedDataTracks.Params( + requestStates = listOf( + TrackRequest.RequestState.DONE + ) + ) + ).onSuccess { dataTracks -> if (dataTracks.isNotEmpty()) { logger.info("Cleaning up the completed requests") @@ -102,13 +100,15 @@ internal class CleanUpWorker( logger.error("Failed while cleaning up the completed requests: $it") } } - } - .onFailure { logger.error("Error getting the cached completed requests: $it") } + }.onFailure { logger.error("Error getting the cached completed requests: $it") } + + return@withContext Result.success() + } } - Result.success() } companion object { + val mutex = Mutex() const val TAG = "clean_up" } } diff --git a/android-sdk/src/main/java/webtrekk/android/sdk/domain/worker/SendRequestsWorker.kt b/android-sdk/src/main/java/webtrekk/android/sdk/domain/worker/SendRequestsWorker.kt index 433f1c6f..f78be202 100644 --- a/android-sdk/src/main/java/webtrekk/android/sdk/domain/worker/SendRequestsWorker.kt +++ b/android-sdk/src/main/java/webtrekk/android/sdk/domain/worker/SendRequestsWorker.kt @@ -26,15 +26,14 @@ package webtrekk.android.sdk.domain.worker import android.content.Context -import android.util.Log import androidx.work.CoroutineWorker import androidx.work.WorkManager import androidx.work.WorkerParameters import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import webtrekk.android.sdk.BuildConfig import webtrekk.android.sdk.Webtrekk import webtrekk.android.sdk.WebtrekkConfiguration import webtrekk.android.sdk.data.WebtrekkSharedPrefs @@ -60,15 +59,20 @@ internal class SendRequestsWorker( ) : CoroutineWorker(context, workerParameters) { override suspend fun doWork(): Result = coroutineScope { + /** + * [logger] the injected logger from Webtrekk. + */ + val logger by lazy { AppModule.logger } + withContext(AppModule.dispatchers.ioDispatcher) { mutex.withLock { - Log.d(TAG, "doWork - starting... ${tags.joinToString(separator = ", ")}") + logger.debug("doWork - starting... ${tags.joinToString(separator = ", ")}") // this check and initialization is needed for cross platform solutions if (!Webtrekk.getInstance().isInitialized()) { val configJson = WebtrekkSharedPrefs(applicationContext).configJson val config = WebtrekkConfiguration.fromJson(configJson) Webtrekk.getInstance().init(applicationContext, config) - Log.d(TAG, "doWork - initialized!") + logger.debug("doWork - initialized!") } /** @@ -88,11 +92,6 @@ internal class SendRequestsWorker( val executePostRequest: ExecutePostRequest = InteractorModule.executePostRequest() - /** - * [logger] the injected logger from Webtrekk. - */ - val logger by lazy { AppModule.logger } - val activeConfig = Webtrekk.getInstance().getCurrentConfiguration() // retrieves the data in the data base with state of NEW or FAILED only. @@ -143,15 +142,11 @@ internal class SendRequestsWorker( } } } - - if (BuildConfig.DEBUG) { - logger.debug(activeConfig.printUsageStatisticCalculation()) - } } .onFailure { logger.error("Error getting cached data tracks: $it") } logger.debug("SEND WORKER - END ${this@SendRequestsWorker}") + return@withContext Result.success() } - return@withContext Result.success() } } diff --git a/build.gradle b/build.gradle index fba5fb61..eb255c14 100644 --- a/build.gradle +++ b/build.gradle @@ -32,7 +32,7 @@ buildscript { ext.versions = [ 'webtrekkVersion' : '5.0.0', 'gradleVersion' : '3.6.4', - 'kotlinVersion' : '1.9.24',//'1.4.32', + 'kotlinVersion' : '1.9.24', 'gradleVersionsPlugin' : '0.21.0', 'mavenPluginVersion' : '2.1', 'bintrayPluginVersion' : '1.8.4', @@ -41,61 +41,6 @@ buildscript { 'minSdkVersion' : 21, 'targetSdkVersion' : 34, 'buildTools' : '34.0.0', - - 'appCompatVersion' : '1.7.0', - 'constraintLayoutVersion': '2.1.4', - - 'kotlinCoroutines' : '1.8.1', - 'coreKtxVersion' : '1.13.1', - - 'okHttpVersion' : '4.12.0', - - 'workManagerVersion' : '2.9.0', - 'roomVersion' : '2.6.1', - - 'testCoreVersion' : '1.6.1', - 'testCoreVersionJunit' : '1.2.1', - 'junitVersion' : '4.13.2', - 'runnerVersion' : '1.6.1', - 'espressoCoreVersion' : '3.5.1', - //Version 2.1.0 depends on androidx.arch.core:core-runtime:2.1.0 which must be explicitly added in android-sdk gradle - 'coreTestingVersion' : '2.2.0', - //Downgraded version from 1.9.3 to 1.9.2 while this issue is not resolved - //https://github.com/mockk/mockk/issues/281 - 'mockkVersion' : '1.13.12', - 'kotlinTestVersion' : '3.4.2', - - 'ktlintVersion' : '0.31.0', - 'dokkaVersion' : '0.9.17', - 'googleServices' : '4.3.14', - 'firebaseAppDistribution': '2.1.1' - ] - - ext.libs = [ - kotlinStdlib : "org.jetbrains.kotlin:kotlin-stdlib:$versions.kotlinVersion", - appCompat : "androidx.appcompat:appcompat:$versions.appCompatVersion", - coreKtx : "androidx.core:core-ktx:$versions.coreKtxVersion", - constraintLayout : "androidx.constraintlayout:constraintlayout:$versions.constraintLayoutVersion", - coroutinesCore : "org.jetbrains.kotlinx:kotlinx-coroutines-core:$versions.kotlinCoroutines", - coroutinesAndroid : "org.jetbrains.kotlinx:kotlinx-coroutines-android:$versions.kotlinCoroutines", - okHttp : "com.squareup.okhttp3:okhttp:$versions.okHttpVersion", - room : "androidx.room:room-runtime:$versions.roomVersion", - roomKtx : "androidx.room:room-ktx:$versions.roomVersion", - roomCompiler : "androidx.room:room-compiler:$versions.roomVersion", - workmanager : "androidx.work:work-runtime-ktx:$versions.workManagerVersion", - junit : "junit:junit:$versions.junitVersion", - runner : "androidx.test:runner:$versions.runnerVersion", - espresso : "androidx.test.espresso:espresso-core:$versions.espressoCoreVersion", - androidTestCore : "androidx.test:core:$versions.testCoreVersion", - mockk : "io.mockk:mockk:$versions.mockkVersion", - coroutinesTest : "org.jetbrains.kotlinx:kotlinx-coroutines-test:$versions.kotlinCoroutines", - kotlinTest : "io.kotlintest:kotlintest-runner-junit5:$versions.kotlinTestVersion", - androidTestJunit : "androidx.test.ext:junit:$versions.testCoreVersionJunit", - androidMockk : "io.mockk:mockk-android:$versions.mockkVersion", - androidArchCoreTest : "androidx.arch.core:core-testing:$versions.coreTestingVersion", - androidRoomTest : "androidx.room:room-testing:$versions.roomVersion", - androidWorkmanagerTest: "androidx.work:work-testing:$versions.workManagerVersion", - truth : "com.google.truth:truth:1.4.4" ] repositories { @@ -106,12 +51,12 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:8.2.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlinVersion}" - classpath "com.github.ben-manes:gradle-versions-plugin:${versions.gradleVersionsPlugin}" - classpath "org.jetbrains.dokka:dokka-android-gradle-plugin:${versions.dokkaVersion}" - classpath "com.github.dcendents:android-maven-gradle-plugin:${versions.mavenPluginVersion}" - classpath "com.google.gms:google-services:${versions.googleServices}" - classpath "com.google.firebase:firebase-appdistribution-gradle:${versions.firebaseAppDistribution}" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24" + classpath "com.github.ben-manes:gradle-versions-plugin:0.21.0" + classpath "org.jetbrains.dokka:dokka-android-gradle-plugin:0.9.17" + classpath "com.github.dcendents:android-maven-gradle-plugin:2.1" + classpath "com.google.gms:google-services:4.4.2" + classpath "com.google.firebase:firebase-appdistribution-gradle:5.0.0" classpath 'com.google.firebase:firebase-crashlytics-gradle:3.0.2' classpath 'io.codearte.gradle.nexus:gradle-nexus-staging-plugin:0.30.0' } diff --git a/sample/build.gradle b/sample/build.gradle index c4b3fd5e..efba56a7 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -131,7 +131,8 @@ dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$versions.kotlinVersion" //implementation 'androidx.recyclerview:recyclerview:1.3.2' - implementation('com.google.android.material:material:1.10.0') + implementation ("androidx.activity:activity:1.9.2") + implementation('com.google.android.material:material:1.12.0') implementation "androidx.constraintlayout:constraintlayout:$versions.constraintLayoutVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$versions.kotlinCoroutines" implementation "androidx.work:work-runtime-ktx:$versions.workManagerVersion" @@ -145,30 +146,19 @@ dependencies { implementation 'androidx.media3:media3-exoplayer-dash:1.4.1' implementation 'androidx.media3:media3-exoplayer-hls:1.4.1' - implementation platform('com.google.firebase:firebase-bom:33.3.0') + implementation platform('com.google.firebase:firebase-bom:33.4.0') implementation('com.google.firebase:firebase-messaging') implementation("com.google.firebase:firebase-crashlytics") - implementation('com.mapp.sdk:mapp-android:6.0.24') + implementation('com.mapp.sdk:mapp-android:6.0.25') - implementation(project(':android-sdk')) { - exclude(group: "androidx.appcompat", module: "appcompat") - } -// implementation("com.mapp.sdk:intelligence-android:5.1.10") { -// exclude(group: "androidx.appcompat", module: "appcompat") -// } + implementation(project(':android-sdk')) + + //implementation("com.mapp.sdk:intelligence-android:5.1.10") //debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1' testImplementation "junit:junit:$versions.junitVersion" androidTestImplementation "androidx.test:runner:$versions.runnerVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$versions.espressoCoreVersion" -} - -//configurations.configureEach { -// resolutionStrategy.eachDependency { details -> -// if (details.requested.group == 'androidx.appcompat') { -// details.useVersion '1.7.0' -// } -// } -//} \ No newline at end of file +} \ No newline at end of file diff --git a/sample/google-services.json b/sample/google-services.json index 763ab9d1..36751fb4 100644 --- a/sample/google-services.json +++ b/sample/google-services.json @@ -1,34 +1,295 @@ { "project_info": { - "project_number": "357810879619", - "firebase_url": "https://webtrekk-mobile.firebaseio.com", - "project_id": "webtrekk-mobile", - "storage_bucket": "webtrekk-mobile.appspot.com" + "project_number": "1028993954364", + "firebase_url": "https://test-52c43.firebaseio.com", + "project_id": "test-52c43", + "storage_bucket": "test-52c43.appspot.com" }, "client": [ { "client_info": { - "mobilesdk_app_id": "1:357810879619:android:472bc0eee7bfaf90d4afb6", + "mobilesdk_app_id": "1:1028993954364:android:b7e199762c30a4f4", + "android_client_info": { + "package_name": "com.appoxee.example" + } + }, + "oauth_client": [ + { + "client_id": "1028993954364-cggcnd9g225dftb71eeqik5ribahjf1j.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyCGr1nIm1vMo3iWZvKHYjAU9h-jLPL5Uyg" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "1028993954364-cggcnd9g225dftb71eeqik5ribahjf1j.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1028993954364:android:3e13d76c79b42e32", + "android_client_info": { + "package_name": "com.example" + } + }, + "oauth_client": [ + { + "client_id": "1028993954364-cggcnd9g225dftb71eeqik5ribahjf1j.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyCGr1nIm1vMo3iWZvKHYjAU9h-jLPL5Uyg" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "1028993954364-cggcnd9g225dftb71eeqik5ribahjf1j.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1028993954364:android:78142f013641efa7164939", "android_client_info": { "package_name": "com.example.webtrekk.androidsdk" } }, "oauth_client": [ { - "client_id": "357810879619-kvto1fdm1gct8n3s0m73qndlelvufpl9.apps.googleusercontent.com", + "client_id": "1028993954364-cggcnd9g225dftb71eeqik5ribahjf1j.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyCGr1nIm1vMo3iWZvKHYjAU9h-jLPL5Uyg" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "1028993954364-cggcnd9g225dftb71eeqik5ribahjf1j.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1028993954364:android:4d2db66f8c189413164939", + "android_client_info": { + "package_name": "com.mapp.engagesample" + } + }, + "oauth_client": [ + { + "client_id": "1028993954364-cggcnd9g225dftb71eeqik5ribahjf1j.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyCGr1nIm1vMo3iWZvKHYjAU9h-jLPL5Uyg" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "1028993954364-cggcnd9g225dftb71eeqik5ribahjf1j.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1028993954364:android:4932cf9ad7039b61164939", + "android_client_info": { + "package_name": "com.mapp.flutter.example" + } + }, + "oauth_client": [ + { + "client_id": "1028993954364-cggcnd9g225dftb71eeqik5ribahjf1j.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyCGr1nIm1vMo3iWZvKHYjAU9h-jLPL5Uyg" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "1028993954364-cggcnd9g225dftb71eeqik5ribahjf1j.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1028993954364:android:8ea4e52b021064f8164939", + "android_client_info": { + "package_name": "com.mapp.flutter.sdk" + } + }, + "oauth_client": [ + { + "client_id": "1028993954364-cggcnd9g225dftb71eeqik5ribahjf1j.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyCGr1nIm1vMo3iWZvKHYjAU9h-jLPL5Uyg" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "1028993954364-cggcnd9g225dftb71eeqik5ribahjf1j.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1028993954364:android:6235ef07862e0b91164939", + "android_client_info": { + "package_name": "com.mapp.rn" + } + }, + "oauth_client": [ + { + "client_id": "1028993954364-cggcnd9g225dftb71eeqik5ribahjf1j.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyCGr1nIm1vMo3iWZvKHYjAU9h-jLPL5Uyg" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "1028993954364-cggcnd9g225dftb71eeqik5ribahjf1j.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1028993954364:android:3505d4dbd547c6a4", + "android_client_info": { + "package_name": "com.mappp.MappAndroidSDKTest" + } + }, + "oauth_client": [ + { + "client_id": "1028993954364-cggcnd9g225dftb71eeqik5ribahjf1j.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyCGr1nIm1vMo3iWZvKHYjAU9h-jLPL5Uyg" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "1028993954364-cggcnd9g225dftb71eeqik5ribahjf1j.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1028993954364:android:720b0c3abbaac128", + "android_client_info": { + "package_name": "example.mapp.com.androidexample" + } + }, + "oauth_client": [ + { + "client_id": "1028993954364-cggcnd9g225dftb71eeqik5ribahjf1j.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyCGr1nIm1vMo3iWZvKHYjAU9h-jLPL5Uyg" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "1028993954364-cggcnd9g225dftb71eeqik5ribahjf1j.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1028993954364:android:5312ec78e9232c91", + "android_client_info": { + "package_name": "mapp.cordova.cep" + } + }, + "oauth_client": [ + { + "client_id": "1028993954364-cggcnd9g225dftb71eeqik5ribahjf1j.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyDyuH5fywqzRI-oKl1GWDkO6v8aJJQuRCk" + "current_key": "AIzaSyCGr1nIm1vMo3iWZvKHYjAU9h-jLPL5Uyg" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "357810879619-kvto1fdm1gct8n3s0m73qndlelvufpl9.apps.googleusercontent.com", + "client_id": "1028993954364-cggcnd9g225dftb71eeqik5ribahjf1j.apps.googleusercontent.com", "client_type": 3 } ] diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index d1fed227..3ad00d56 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -15,9 +15,12 @@ android:usesCleartextTraffic="true" tools:ignore="GoogleAppIndexingWarning" tools:targetApi="m"> + + android:exported="false" /> @@ -76,7 +79,7 @@ android:exported="false" /> diff --git a/sample/src/main/java/com/example/webtrekk/androidsdk/MainActivity.kt b/sample/src/main/java/com/example/webtrekk/androidsdk/MainActivity.kt index f81d7552..104a8fb9 100644 --- a/sample/src/main/java/com/example/webtrekk/androidsdk/MainActivity.kt +++ b/sample/src/main/java/com/example/webtrekk/androidsdk/MainActivity.kt @@ -94,8 +94,8 @@ class MainActivity : AppCompatActivity() { startActivity(Intent(this, CampaignActivity::class.java)) } - binding.formActivity.setOnClickListener { - val intent = Intent(this, FormActivity::class.java) + binding.btnWorkSchedulerTest.setOnClickListener { + val intent = Intent(this, WorkSchedulerTest::class.java) startActivity(intent) } @@ -267,8 +267,4 @@ class MainActivity : AppCompatActivity() { Webtrekk.getInstance().sendRequestsNowAndClean() } } - - override fun onNewIntent(intent: Intent?) { - super.onNewIntent(intent) - } } diff --git a/sample/src/main/java/com/example/webtrekk/androidsdk/WorkSchedulerTest.kt b/sample/src/main/java/com/example/webtrekk/androidsdk/WorkSchedulerTest.kt new file mode 100644 index 00000000..a2740f49 --- /dev/null +++ b/sample/src/main/java/com/example/webtrekk/androidsdk/WorkSchedulerTest.kt @@ -0,0 +1,134 @@ +package com.example.webtrekk.androidsdk + +import android.content.Context +import android.os.Bundle +import android.util.Log +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.lifecycleScope +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.example.webtrekk.androidsdk.databinding.ActivityWorkSchedulerTestBinding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.util.concurrent.TimeUnit + +class WorkSchedulerTest : AppCompatActivity() { + + private val TAG = this::class.java.simpleName + private lateinit var binding: ActivityWorkSchedulerTestBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + binding = ActivityWorkSchedulerTestBinding.inflate(layoutInflater) + setContentView(binding.root) + ViewCompat.setOnApplyWindowInsetsListener(binding.main) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + + binding.btnScheduleWorks.setOnClickListener { + val workCount = binding.etWorkCount.text.toString().toIntOrNull() ?: 0 + if (workCount > 0) { + lifecycleScope.launch(Dispatchers.IO) { + for (i in 0 until workCount) { + //Webtrekk.getInstance().sendRequestsNowAndClean() + MyWorker.enqueue(this@WorkSchedulerTest, i + 1) + //MyWorker.enqueuePeriodic(this@WorkSchedulerTest, i + 1) + Log.d(TAG, "SCHEDULED WORK(s): ${i + 1}") + } + } + } + } + } +} + +internal class MyWorker(context: Context, workerParameters: WorkerParameters) : + CoroutineWorker(context, workerParameters) { + private val TAG = this::class.java.simpleName + private val dispatcher = Dispatchers.IO + override suspend fun doWork(): Result = coroutineScope { + return@coroutineScope withContext(dispatcher) { + mutex.withLock { + val workNumber = inputData.getInt("workNumber", 0) + Log.d(TAG, "WORK STARTED - $id") + delay(5000) + val result = if (workNumber % 3 == 0) Result.failure() else Result.success() + Log.d(TAG, "WORK FINISHED - $id - Returning $result") + result + } + } + } + + companion object { + val mutex = Mutex() + val workName = "my-worker" + val periodicWorkName = "my-worker-periodic" + fun enqueue(context: Context, workNumber: Int) { + val workManager = WorkManager.getInstance(context) + val currentWork = + workManager.getWorkInfosForUniqueWork(workName).get().firstOrNull() + if (currentWork == null || currentWork.state != WorkInfo.State.RUNNING) { + val constraints = Constraints.Builder() + .setRequiresCharging(true) + .build() + val inputData = Data.Builder() + .putInt("workNumber", workNumber) + .build() + val work = OneTimeWorkRequestBuilder() + .setInitialDelay(0, TimeUnit.SECONDS) + .setConstraints(constraints) + .setInputData(inputData) + .build() + workManager + .enqueueUniqueWork( + workName, + ExistingWorkPolicy.APPEND_OR_REPLACE, + work + ) + } + } + + fun enqueuePeriodic(context: Context, workNumber: Int) { + val workManager = WorkManager.getInstance(context) + val currentWork = + workManager.getWorkInfosForUniqueWork(periodicWorkName).get().firstOrNull() + if (currentWork == null || currentWork.state != WorkInfo.State.RUNNING) { + val constraints = Constraints.Builder() + .setRequiresCharging(true) + .build() + val inputData = Data.Builder() + .putInt("workNumber", workNumber) + .build() + val work = PeriodicWorkRequestBuilder(15, TimeUnit.SECONDS) + .setInitialDelay(0, TimeUnit.SECONDS) + .setConstraints(constraints) + .setInputData(inputData) + .build() + workManager + .enqueueUniquePeriodicWork( + periodicWorkName, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + work + ) + } + } + } +} \ No newline at end of file diff --git a/sample/src/main/res/layout/activity_main.xml b/sample/src/main/res/layout/activity_main.xml index 86b03523..4bb00d2d 100644 --- a/sample/src/main/res/layout/activity_main.xml +++ b/sample/src/main/res/layout/activity_main.xml @@ -112,10 +112,10 @@ android:text="@string/web_view_activity" />