tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 67f1ef2c886778ba10236b2e240067a29f2990f5
parent 74c4ec96ed828ab4749aab4f187f86b37bf557a0
Author: Marc Leclair <mleclair@mozilla.com>
Date:   Mon,  8 Dec 2025 16:20:48 +0000

Bug 1956859: Enable profiler control via adb and sync profiler state r=kaya,profiler-reviewers,mstange

Add ProfilerProvider ContentProvider to allow the profiler to be stopped
  through adb. Add Java state notifications to match native ones to better the
  ProfilerService lifecycle.

Differential Revision: https://phabricator.services.mozilla.com/D257523

Diffstat:
Mmobile/android/fenix/app/src/main/AndroidManifest.xml | 22++++++++++++++++------
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/perf/ProfilerProvider.kt | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/perf/ProfilerService.kt | 139++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/perf/ProfilerStartDialogFragment.kt | 1-
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/perf/ProfilerStopDialogFragment.kt | 1-
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/perf/ProfilerViewModel.kt | 180++++++++++++++++++++-----------------------------------------------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt | 1-
Mmobile/android/fenix/app/src/main/res/values/static_strings.xml | 2++
Mmobile/android/fenix/app/src/nightly/AndroidManifest.xml | 9+++++++++
Amobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/ProfilerProviderTest.kt | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/ProfilerServiceTest.kt | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/ProfilerViewModelTest.kt | 51+++------------------------------------------------
Mmobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoJavaSampler.java | 38++++++++++++++++++++++++++++++++++++--
Mtools/profiler/core/platform.cpp | 13+++++++++++++
14 files changed, 605 insertions(+), 275 deletions(-)

diff --git a/mobile/android/fenix/app/src/main/AndroidManifest.xml b/mobile/android/fenix/app/src/main/AndroidManifest.xml @@ -54,6 +54,12 @@ This is NOT required for the adjust plugin. --> <uses-permission android:name="com.adjust.preinstall.READ_PERMISSION"/> + <uses-permission android:name="${applicationId}.permission.PROFILER_INTERNAL" /> + + <permission + android:name="${applicationId}.permission.PROFILER_INTERNAL" + android:protectionLevel="signature" /> + <application android:name=".FenixApplication" android:allowBackup="false" @@ -762,15 +768,19 @@ <service android:name=".perf.ProfilerService" android:foregroundServiceType="specialUse" - android:exported="true" - android:permission="android.permission.DUMP"> + android:exported="false"> <property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" - android:value="This foreground service allows the profiler to stop through adb." /> - <intent-filter> - <action android:name="mozilla.perf.action.STOP_PROFILING" /> - </intent-filter> + android:value="Developer tool: Shows notification during performance profiling" /> </service> + <provider + android:name=".perf.ProfilerProvider" + android:authorities="${applicationId}.profiler" + android:exported="false" + android:enabled="false" + android:readPermission="android.permission.DUMP" + android:grantUriPermissions="false" /> + <meta-data android:name="firebase_messaging_auto_init_enabled" android:value="true" /> diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/perf/ProfilerProvider.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/perf/ProfilerProvider.kt @@ -0,0 +1,152 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.perf + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context +import android.content.UriMatcher +import android.database.Cursor +import android.net.Uri +import android.os.Binder +import android.os.ParcelFileDescriptor +import android.os.Process +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.mozilla.fenix.ext.components +import java.io.IOException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +/** + * Content Provider that enables stopping the Firefox Profiler and retrieving profile data via ADB. + * The caller will receive the profiler data as a streams as a raw gzip-compressed profile data. + * + * Usage: adb shell content read --uri content://<applicationId>.profiler/stop-and-upload > profile.gz + * + * Note: Access is restricted to the ADB process (shell UID) through DUMP permission in the Manifest. + */ +class ProfilerProvider : ContentProvider() { + + companion object { + private const val PATH_STOP_AND_UPLOAD = "stop-and-upload" + private const val CODE_STOP_AND_UPLOAD = 1 + } + + // Needed to inject dispatcher for tests + internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO + + // Needed to inject ProfilerUtils for tests + @androidx.annotation.VisibleForTesting + internal var saveProfileUrl: (ByteArray, Context) -> String = { data, context -> + ProfilerUtils.saveProfileUrlToClipboard(data, context) + } + + private lateinit var userDictionary: String + private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH) + + override fun onCreate(): Boolean { + // Use a per-build user_dictionary (authority) since there is multiple variants: <applicationId>.profiler + userDictionary = context!!.packageName + ".profiler" + // Creates the uri to stop the profiler from adb (content://userDictionary/stop-and-upload) + uriMatcher.addURI(userDictionary, PATH_STOP_AND_UPLOAD, CODE_STOP_AND_UPLOAD) + return true + } + + override fun getType(uri: Uri): String? = when (match(uri)) { + CODE_STOP_AND_UPLOAD -> "application/octet-stream" + else -> null + } + + override fun query( + uri: Uri, + projection: Array<out String>?, + selection: String?, + selectionArgs: Array<out String>?, + sortOrder: String?, + ): Cursor? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array<out String>?, + ): Int = 0 + + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? { + enforceShellCaller() + + return when (match(uri)) { + CODE_STOP_AND_UPLOAD -> openStopAndUploadPipe() + else -> throw UnsupportedOperationException("Unknown URI: $uri") + } + } + + /** + * Creates a pipe to handle profiler stop operations and stream raw profile data asynchronously. + */ + private fun openStopAndUploadPipe(): ParcelFileDescriptor { + val pipe = ParcelFileDescriptor.createPipe() + val appContext = context!!.applicationContext + + CoroutineScope(ioDispatcher).launch { + ParcelFileDescriptor.AutoCloseOutputStream(pipe[1]).use { os -> + val profiler = appContext.components.core.engine.profiler + if (profiler == null || !profiler.isProfilerActive()) { + throw IllegalStateException("Profiler is not active") + } + + val data = withContext(Dispatchers.Main) { + suspendCoroutine { continuation -> + profiler.stopProfiler( + onSuccess = { data -> continuation.resume(data) }, + onError = { throwable -> + continuation.resumeWithException(throwable) + }, + ) + } + } + + if (data == null) { + throw IOException("Profiler returned empty data") + } + + // Stream raw profile data directly through the pipe + os.write(data) + os.flush() + } + } + return pipe[0] + } + + /** + * Enforces that the caller is ADB shell or system. + * + * @throws SecurityException if the caller's UID is not SHELL_UID, SYSTEM_UID, or root (0) + */ + private fun enforceShellCaller() { + val uid = Binder.getCallingUid() + if (uid != Process.SHELL_UID && uid != Process.SYSTEM_UID && uid != 0) { + throw SecurityException("Caller not allowed: uid=$uid") + } + } + + /** + * Matches a URI against the registered pattern CODE_STOP_AND_UPLOAD + * to determine the operation code. + */ + private fun match(uri: Uri): Int = when (uriMatcher.match(uri)) { + CODE_STOP_AND_UPLOAD -> CODE_STOP_AND_UPLOAD + else -> -1 + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/perf/ProfilerService.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/perf/ProfilerService.kt @@ -9,12 +9,31 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager import android.os.Build import android.os.IBinder +import androidx.annotation.VisibleForTesting import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import kotlinx.coroutines.SupervisorJob +import mozilla.components.support.base.log.Log +import mozilla.components.support.utils.ext.stopForegroundCompat import org.mozilla.fenix.R import org.mozilla.fenix.ext.components +import org.mozilla.gecko.GeckoJavaSampler.INTENT_PROFILER_STATE_CHANGED + +@VisibleForTesting +internal const val PROFILING_CHANNEL_ID = "mozilla.perf.profiling" + +@VisibleForTesting +internal const val PROFILING_NOTIFICATION_ID = 99 + +private const val REQUEST_CODE = 3 /** * A foreground service that manages profiling notifications in the Firefox Android app. @@ -33,68 +52,85 @@ class ProfilerService : Service() { * profiling operations. */ companion object { - const val ACTION_START_PROFILING = "mozilla.perf.action.START_PROFILING" - const val ACTION_STOP_PROFILING = "mozilla.perf.action.STOP_PROFILING" - const val PROFILING_CHANNEL_ID = "mozilla.perf.profiling" - const val PROFILING_NOTIFICATION_ID = 99 - private const val REQUEST_CODE = 3 + const val PROFILER_SERVICE_LOG = "ProfilerService" + const val IS_PROFILER_ACTIVE = "isActive" } + private val serviceJob = SupervisorJob() private val notificationsDelegate by lazy { components.notificationsDelegate } + private var stateReceiver: BroadcastReceiver? = null override fun onCreate() { super.onCreate() createNotificationChannel() + stateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == INTENT_PROFILER_STATE_CHANGED) { + val active = intent.getBooleanExtra(IS_PROFILER_ACTIVE, false) + if (!active) { + disableProfilerProvider() + stopForegroundCompat(true) + stopSelf() + } + } + } + } + val filter = IntentFilter(INTENT_PROFILER_STATE_CHANGED) + val permission = "${applicationContext.packageName}.permission.PROFILER_INTERNAL" + ContextCompat.registerReceiver( + applicationContext, + stateReceiver!!, + filter, + permission, + null, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - when (intent?.action) { - ACTION_START_PROFILING -> { - startProfilingWithNotification() - } - ACTION_STOP_PROFILING -> { - stopForegroundCompat() - stopSelf() - } - else -> { - stopForegroundCompat() - stopSelf() - } + override fun onDestroy() { + serviceJob.cancel() + stateReceiver?.let { receiver -> + applicationContext.unregisterReceiver(receiver) + stateReceiver = null } + super.onDestroy() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val notification = createNotification() + startForeground(PROFILING_NOTIFICATION_ID, notification) + requestNotificationPermissionIfNeeded() + enableProfilerProvider() return START_NOT_STICKY } /** - * Starts profiling with notification using NotificationsDelegate. - * The delegate handles permission requests and notification display. + * Request permission for notification using NotificationsDelegate and update if needed */ - private fun startProfilingWithNotification() { - val notification = createNotification() + private fun requestNotificationPermissionIfNeeded() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { notificationsDelegate.requestNotificationPermission( onPermissionGranted = { - startForeground(PROFILING_NOTIFICATION_ID, notification) + val updated = createNotification() + startForeground(PROFILING_NOTIFICATION_ID, updated) }, showPermissionRationale = false, ) - } else { - startForeground(PROFILING_NOTIFICATION_ID, notification) } } - private fun stopForegroundCompat() { - stopForeground(STOP_FOREGROUND_REMOVE) - } - + /** + * Creates the notification channel for profiler status notifications. + */ private fun createNotificationChannel() { val profilingChannel = NotificationChannel( PROFILING_CHANNEL_ID, - "App Profiling Status", + getString(R.string.profiler_service_notification_channel_name), NotificationManager.IMPORTANCE_DEFAULT, ).apply { - description = "Shows when app profiling is active" + description = getString(R.string.profiler_service_description) setShowBadge(false) enableLights(false) enableVibration(false) @@ -108,8 +144,7 @@ class ProfilerService : Service() { val notificationIntent = Intent(this, StopProfilerActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } - val pendingIntentFlags = - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + val pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE val pendingIntent = PendingIntent.getActivity( this, REQUEST_CODE, @@ -136,4 +171,42 @@ class ProfilerService : Service() { override fun onBind(p0: Intent?): IBinder? { return null } + + /** + * The Profiler Content Provider is set to false in the manifest to make sure it does not + * initialize during startup. So, it has to instantiated whenever the Service starts. + */ + private fun enableProfilerProvider() { + try { + val componentName = ComponentName(this, ProfilerProvider::class.java) + packageManager.setComponentEnabledSetting( + componentName, + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP, + ) + } catch (e: IllegalArgumentException) { + Log.log(Log.Priority.WARN, PROFILER_SERVICE_LOG, e, "Failed to enable ProfilerProvider") + } catch (e: SecurityException) { + Log.log(Log.Priority.WARN, PROFILER_SERVICE_LOG, e, "Permission denied to enable ProfilerProvider") + } + } + + /** + * Since it the provider isn't managed by the manifest, it also has to be manually disabled. + */ + private fun disableProfilerProvider() { + try { + val componentName = ComponentName(this, ProfilerProvider::class.java) + packageManager.setComponentEnabledSetting( + componentName, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP, + ) + Log.log(Log.Priority.DEBUG, PROFILER_SERVICE_LOG, message = "ProfilerProvider disabled") + } catch (e: IllegalArgumentException) { + Log.log(Log.Priority.WARN, PROFILER_SERVICE_LOG, e, "Failed to disable ProfilerProvider") + } catch (e: SecurityException) { + Log.log(Log.Priority.WARN, PROFILER_SERVICE_LOG, e, "Permission denied to disable ProfilerProvider") + } + } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/perf/ProfilerStartDialogFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/perf/ProfilerStartDialogFragment.kt @@ -58,7 +58,6 @@ class ProfilerStartDialogFragment : AppCompatDialogFragment() { override fun onDismiss(dialog: DialogInterface) { profilerViewModel.resetUiState() - profilerViewModel.updateProfilerActiveStatus() super.onDismiss(dialog) } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/perf/ProfilerStopDialogFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/perf/ProfilerStopDialogFragment.kt @@ -43,7 +43,6 @@ class ProfilerStopDialogFragment : DialogFragment() { override fun onDismiss(dialog: DialogInterface) { profilerViewModel.resetUiState() - profilerViewModel.updateProfilerActiveStatus() super.onDismiss(dialog) if (activity is StopProfilerActivity) { activity?.finish() diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/perf/ProfilerViewModel.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/perf/ProfilerViewModel.kt @@ -5,7 +5,10 @@ package org.mozilla.fenix.perf import android.app.Application +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter import androidx.annotation.StringRes import androidx.core.content.ContextCompat import androidx.lifecycle.AndroidViewModel @@ -14,21 +17,18 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import mozilla.components.concept.base.profiler.Profiler -import mozilla.components.support.base.log.Log import org.json.JSONException import org.mozilla.fenix.R import org.mozilla.fenix.ext.components +import org.mozilla.gecko.GeckoJavaSampler import java.io.IOException -import kotlin.coroutines.cancellation.CancellationException /** * Represents the various states of the profiler UI. @@ -110,27 +110,44 @@ class ProfilerViewModel( private val profilerUtils: ProfilerUtils = ProfilerUtils, ) : AndroidViewModel(application) { - private val maxPollingAttempts = 50 private val delayToUpdateStatus = 50L private val profiler: Profiler? = application.components.core.engine.profiler - - private val _isProfilerActive = MutableStateFlow(profiler?.isProfilerActive() ?: false) - val isProfilerActive: StateFlow<Boolean> = _isProfilerActive.asStateFlow() + private val _isActive = MutableStateFlow(profiler?.isProfilerActive() ?: false) + val isProfilerActive: StateFlow<Boolean> = _isActive.asStateFlow() private val _uiState = MutableStateFlow<ProfilerUiState>(ProfilerUiState.Idle) val uiState: StateFlow<ProfilerUiState> = _uiState.asStateFlow() - private var pollingJob: Job? = null - private val delayToPollProfilerForStatus = 100L - - /** - * Updates the profiler active status by checking the current state. - */ - fun updateProfilerActiveStatus() { - val currentlyActive = profiler?.isProfilerActive() ?: false - if (_isProfilerActive.value != currentlyActive) { - _isProfilerActive.value = currentlyActive + private var stateReceiver: BroadcastReceiver? = null + + init { + stateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == GeckoJavaSampler.INTENT_PROFILER_STATE_CHANGED) { + val active = intent.getBooleanExtra(ProfilerService.IS_PROFILER_ACTIVE, false) + _isActive.value = active + if (active && _uiState.value is ProfilerUiState.Starting) { + _uiState.value = ProfilerUiState.ShowToast(R.string.profiler_start_dialog_started) + _uiState.value = ProfilerUiState.Running + } else if (!active) { + val currentState = _uiState.value + if (currentState is ProfilerUiState.Running || currentState is ProfilerUiState.Stopping) { + _uiState.value = ProfilerUiState.Finished(null) + } + } + } + } } + val filter = IntentFilter(GeckoJavaSampler.INTENT_PROFILER_STATE_CHANGED) + val permission = "${application.packageName}.permission.PROFILER_INTERNAL" + ContextCompat.registerReceiver( + application, + stateReceiver!!, + filter, + permission, + null, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) } /** @@ -149,99 +166,23 @@ class ProfilerViewModel( _uiState.value = ProfilerUiState.Starting profiler.startProfiler(settings.threads, settings.features) - - pollUntilProfilerActiveAndThen( - onActive = { startProfilerService() }, - onPollFail = { handleProfilerStartFailure() }, - ) - } - - /** - * Starts the ProfilerService which handles notifications via NotificationsDelegate. - */ - private fun startProfilerService() { - val startIntent = Intent(application, ProfilerService::class.java).apply { - action = ProfilerService.ACTION_START_PROFILING - } - ContextCompat.startForegroundService(application, startIntent) - _uiState.value = ProfilerUiState.ShowToast(R.string.profiler_start_dialog_started) - - viewModelScope.launch(mainDispatcher) { - delay(delayToPollProfilerForStatus) - if (isProfilerActive.value) { - _uiState.value = ProfilerUiState.Running - } - } - } - - /** - * Handles profiler start failure after polling. - */ - private fun handleProfilerStartFailure() { - _uiState.value = ProfilerUiState.Error( - R.string.profiler_error, - "Polling for active profiler failed", - ) - updateProfilerActiveStatus() - } - - /** - * Polls the profiler status until it becomes active or the operation is cancelled. - */ - @Suppress("CognitiveComplexMethod") - private fun pollUntilProfilerActiveAndThen(onActive: () -> Unit, onPollFail: () -> Unit) { - pollingJob?.cancel() - pollingJob = viewModelScope.launch(ioDispatcher) { - try { - var pollingAttempts = 0 - while (isActive && pollingAttempts < maxPollingAttempts) { - if (profiler?.isProfilerActive() == true) { - withContext(mainDispatcher) { - if (!_isProfilerActive.value) { - _isProfilerActive.value = true - } - onActive() - } - return@launch - } - pollingAttempts++ - delay(delayToPollProfilerForStatus) - } - withContext(mainDispatcher) { - onPollFail() - } - } catch (e: CancellationException) { - withContext(mainDispatcher) { - if (_uiState.value == ProfilerUiState.Starting) { - _uiState.value = ProfilerUiState.Idle - } - } - throw e - } catch (e: IOException) { - handleViewModelError(e, R.string.profiler_error, "Polling failed") - } catch (e: SecurityException) { - handleViewModelError(e, R.string.profiler_error, "Permission denied") - } catch (e: IllegalStateException) { - handleViewModelError(e, R.string.profiler_error, "Invalid profiler state") - } - } } /** * Stops the profiler and saves the collected profile data. + * This is for UI-initiated stops, so it should NOT create files via ProfilerService. */ fun stopProfilerAndSave() { if (profiler == null || !isProfilerActive.value) { - updateProfilerActiveStatus() _uiState.value = ProfilerUiState.Finished(null) return } + _uiState.value = ProfilerUiState.Gathering - _isProfilerActive.value = false + profiler.stopProfiler( onSuccess = { profileData -> viewModelScope.launch(mainDispatcher) { - stopProfilerService() if (profileData != null) { handleProfileSaveInternal(profileData) } else { @@ -251,8 +192,6 @@ class ProfilerViewModel( }, onError = { error -> viewModelScope.launch(mainDispatcher) { - stopProfilerService() - updateProfilerActiveStatus() val errorMessage = error.message ?: "Unknown stop error" _uiState.value = ProfilerUiState.Error(R.string.profiler_error, errorMessage) } @@ -262,26 +201,24 @@ class ProfilerViewModel( /** * Stops the profiler without saving the collected data. + * This is for UI-initiated stops, so it should NOT create files via ProfilerService. */ fun stopProfilerWithoutSaving() { if (profiler == null || !isProfilerActive.value) { - updateProfilerActiveStatus() _uiState.value = ProfilerUiState.Finished(null) return } + _uiState.value = ProfilerUiState.Stopping - _isProfilerActive.value = false + profiler.stopProfiler( onSuccess = { viewModelScope.launch(mainDispatcher) { - stopProfilerService() _uiState.value = ProfilerUiState.Finished(null) } }, onError = { error -> viewModelScope.launch(mainDispatcher) { - stopProfilerService() - updateProfilerActiveStatus() val errorMessage = error.message ?: "Unknown stop error" _uiState.value = ProfilerUiState.Error(R.string.profiler_error, errorMessage) } @@ -315,24 +252,6 @@ class ProfilerViewModel( } /** - * Stops the ProfilerService. - */ - private fun stopProfilerService() { - try { - val stopIntent = Intent(application, ProfilerService::class.java).apply { - action = ProfilerService.ACTION_STOP_PROFILING - } - application.startService(stopIntent) - } catch (e: IllegalStateException) { - Log.log( - priority = Log.Priority.ERROR, - tag = "ProfilerViewModel", - message = "Error sending stop intent: ${e.message}", - ) - } - } - - /** * Resets the UI state to idle if it isn't already. */ fun resetUiState() { @@ -343,24 +262,17 @@ class ProfilerViewModel( override fun onCleared() { super.onCleared() - pollingJob?.cancel() + stateReceiver?.let { application.unregisterReceiver(it) } } - private suspend fun handleViewModelError( + private fun handleViewModelError( exception: Exception, @StringRes errorMessageRes: Int, fallbackMessage: String = "Operation failed", ) { - Log.log( - priority = Log.Priority.ERROR, - tag = "ProfilerViewModel", - message = "Error: ${exception.message}", + _uiState.value = ProfilerUiState.Error( + errorMessageRes, + exception.message ?: fallbackMessage, ) - withContext(mainDispatcher) { - _uiState.value = ProfilerUiState.Error( - errorMessageRes, - exception.message ?: fallbackMessage, - ) - } } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt @@ -247,7 +247,6 @@ class SettingsFragment : PreferenceFragmentCompat() { args.preferenceToScrollTo?.let { scrollToPreference(it) } - profilerViewModel.updateProfilerActiveStatus() // Consider finish of `onResume` to be the point at which we consider this fragment as 'created'. creatingFragment = false } diff --git a/mobile/android/fenix/app/src/main/res/values/static_strings.xml b/mobile/android/fenix/app/src/main/res/values/static_strings.xml @@ -184,6 +184,8 @@ <string name="profiler_error">Something went wrong with the profiler</string> <string name="profiler_io_error">Something went wrong contacting the Profiler server.</string> <string name="profiler_uploaded_url_to_clipboard">URL copied to clipboard successfully</string> + <string name="profiler_service_notification_channel_name" translatable="false">App Profiling Status</string> + <string name="profiler_service_description" translatable="false">App Profiling Status</string> <!-- Debug drawer "contextual feature recommendation" (CFR) tools --> <!-- The description of the reset CFR section in CFR Tools --> diff --git a/mobile/android/fenix/app/src/nightly/AndroidManifest.xml b/mobile/android/fenix/app/src/nightly/AndroidManifest.xml @@ -15,6 +15,15 @@ </intent-filter> </service> + <!-- ProfilerProvider is only exposed in nightly builds to allow profiler control through adb. + Access is restricted to shell / system UID via enforceShellCaller(). --> + <provider + android:name=".perf.ProfilerProvider" + android:authorities="${applicationId}.profiler" + android:exported="true" + android:enabled="true" + tools:replace="android:exported,android:enabled" /> + </application> </manifest> diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/ProfilerProviderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/ProfilerProviderTest.kt @@ -0,0 +1,121 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.perf + +import android.content.pm.ProviderInfo +import android.net.Uri +import android.os.Looper +import android.os.ParcelFileDescriptor +import androidx.test.core.app.ApplicationProvider +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import mozilla.components.browser.engine.gecko.profiler.Profiler +import mozilla.components.concept.engine.Engine +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.components.Core +import org.mozilla.fenix.helpers.FenixRobolectricTestApplication +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowBinder + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(application = FenixRobolectricTestApplication::class) +@org.robolectric.annotation.LooperMode(org.robolectric.annotation.LooperMode.Mode.PAUSED) +class ProfilerProviderTest { + + private val testDispatcher = StandardTestDispatcher() + + private lateinit var app: FenixRobolectricTestApplication + private lateinit var provider: ProfilerProvider + + @RelaxedMockK + lateinit var mockCore: Core + + @RelaxedMockK + lateinit var mockEngine: Engine + + @MockK + lateinit var mockProfiler: Profiler + + @Before + fun setup() { + MockKAnnotations.init(this, relaxUnitFun = true) + app = ApplicationProvider.getApplicationContext() + + // Simulate shell caller for provider access restriction. + ShadowBinder.setCallingUid(android.os.Process.SHELL_UID) + + // Wire up application.components -> core -> engine -> profiler + every { app.components.core } returns mockCore + every { mockCore.engine } returns mockEngine + every { mockEngine.profiler } returns mockProfiler + + provider = ProfilerProvider() + provider.ioDispatcher = testDispatcher + val info = ProviderInfo().apply { + authority = app.packageName + ".profiler" + } + provider.attachInfo(app, info) + } + + @After + fun tearDown() { + ShadowBinder.reset() + unmockkAll() + } + + @Test + fun `WHEN profiler active THEN provider invokes stopProfiler on main and returns a pipe`() = runTest(testDispatcher) { + every { mockProfiler.isProfilerActive() } returns true + every { mockProfiler.stopProfiler(any(), any()) } answers { + val onSuccess = firstArg<(ByteArray?) -> Unit>() + onSuccess("dummy".toByteArray()) + } + + provider.saveProfileUrl = { _, _ -> "https://profiler.firefox.com/test-token" } + + val uri = Uri.parse("content://${app.packageName}.profiler/stop-and-upload") + val pfd: ParcelFileDescriptor? = provider.openFile(uri, "r") + assertTrue("Provider should return a pipe file descriptor", pfd != null) + + advanceUntilIdle() + Shadows.shadowOf(Looper.getMainLooper()).idle() + + io.mockk.verify { mockProfiler.stopProfiler(any(), any()) } + } + + @Test + fun `WHEN profiler not active THEN provider throws IllegalStateException`() { + every { mockProfiler.isProfilerActive() } returns false + + val uri = Uri.parse("content://${app.packageName}.profiler/stop-and-upload") + val pfd: ParcelFileDescriptor? = provider.openFile(uri, "r") + assertTrue("Provider should return a pipe file descriptor", pfd != null) + + val exception = assertThrows(IllegalStateException::class.java) { + runTest(testDispatcher) { + advanceUntilIdle() + } + } + assertEquals("Profiler is not active", exception.message) + + io.mockk.verify(exactly = 0) { mockProfiler.stopProfiler(any(), any()) } + } +} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/ProfilerServiceTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/ProfilerServiceTest.kt @@ -9,13 +9,20 @@ import android.app.NotificationManager import android.content.Context import android.content.Intent import android.os.Build +import android.os.Looper import androidx.test.core.app.ApplicationProvider import io.mockk.MockKAnnotations import io.mockk.every +import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.RelaxedMockK import io.mockk.mockk import io.mockk.unmockkAll +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import mozilla.components.browser.engine.gecko.profiler.Profiler +import mozilla.components.concept.engine.Engine import mozilla.components.support.base.android.NotificationsDelegate +import mozilla.components.support.test.rule.MainCoroutineRule import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -24,10 +31,12 @@ import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.mozilla.fenix.components.Components +import org.mozilla.fenix.components.Core import org.mozilla.fenix.helpers.FenixRobolectricTestApplication +import org.mozilla.gecko.GeckoJavaSampler.INTENT_PROFILER_STATE_CHANGED import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows @@ -37,9 +46,12 @@ import org.robolectric.shadows.ShadowNotificationManager import org.robolectric.shadows.ShadowService @RunWith(RobolectricTestRunner::class) -@Config(application = FenixRobolectricTestApplication::class) +@Config(application = FenixRobolectricTestApplication::class, sdk = [Build.VERSION_CODES.TIRAMISU]) class ProfilerServiceTest { + @get:Rule + val coroutineRule = MainCoroutineRule(StandardTestDispatcher()) + private lateinit var context: Context private lateinit var notificationManager: NotificationManager private lateinit var shadowNotificationManager: ShadowNotificationManager @@ -48,17 +60,26 @@ class ProfilerServiceTest { private lateinit var shadowService: ShadowService @RelaxedMockK - lateinit var mockComponents: Components + lateinit var mockCore: Core + + @RelaxedMockK + lateinit var mockEngine: Engine + + @MockK + lateinit var mockProfiler: Profiler @Before fun setup() { MockKAnnotations.init(this, relaxUnitFun = true) context = ApplicationProvider.getApplicationContext() + + val shadowApp = Shadows.shadowOf(context as FenixRobolectricTestApplication) + shadowApp.grantPermissions("org.mozilla.fenix.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION") + notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager shadowNotificationManager = Shadows.shadowOf(notificationManager) val fenixApp = context as FenixRobolectricTestApplication - mockComponents = fenixApp.components val mockNotificationsDelegate = mockk<NotificationsDelegate>(relaxed = true) every { @@ -71,43 +92,62 @@ class ProfilerServiceTest { onPermissionGranted.invoke() } - every { mockComponents.notificationsDelegate } returns mockNotificationsDelegate + every { fenixApp.components.notificationsDelegate } returns mockNotificationsDelegate + every { fenixApp.components.core } returns mockCore + every { mockCore.engine } returns mockEngine + every { mockEngine.profiler } returns mockProfiler - serviceController = Robolectric.buildService(ProfilerService::class.java) - serviceController.create() - service = serviceController.get() - shadowService = Shadows.shadowOf(service) + // Mock profiler methods + every { mockProfiler.isProfilerActive() } returns true + every { mockProfiler.stopProfiler(any(), any()) } answers { + val onError = secondArg<(Throwable) -> Unit>() + onError(Exception("Test error")) + } } @After fun tearDown() { + if (::serviceController.isInitialized) { + serviceController.destroy() + } unmockkAll() } @Test @Config(sdk = [Build.VERSION_CODES.O]) fun `GIVEN SDK is O+ WHEN service is created THEN notification channel is created`() { + val shadowApp = Shadows.shadowOf(context as FenixRobolectricTestApplication) + shadowApp.grantPermissions("org.mozilla.fenix.debug.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION") + + serviceController = Robolectric.buildService(ProfilerService::class.java) + serviceController.create() + service = serviceController.get() + shadowService = Shadows.shadowOf(service) + val createdChannels = shadowNotificationManager.notificationChannels - val channel = createdChannels.find { it.id == ProfilerService.PROFILING_CHANNEL_ID } + val channel = createdChannels.find { it.id == PROFILING_CHANNEL_ID } assertNotNull( - "Channel with ID '${ProfilerService.PROFILING_CHANNEL_ID}' should be created on Oreo+", + "Channel with ID '${PROFILING_CHANNEL_ID}' should be created on Oreo+", channel, ) - assertEquals("Channel ID mismatch", ProfilerService.PROFILING_CHANNEL_ID, channel?.id) + assertEquals("Channel ID mismatch", PROFILING_CHANNEL_ID, channel?.id) assertEquals("Channel name mismatch", "App Profiling Status", channel?.name.toString()) assertEquals("Channel importance mismatch", NotificationManager.IMPORTANCE_DEFAULT, channel?.importance) } @Test - fun `WHEN onStartCommand receives START action THEN service starts foreground and posts notification`() { - val startIntent = Intent(context, ProfilerService::class.java).apply { - action = ProfilerService.ACTION_START_PROFILING - } + fun `WHEN onStartCommand is called THEN service starts foreground and posts notification`() { + serviceController = Robolectric.buildService(ProfilerService::class.java) + serviceController.create() + service = serviceController.get() + shadowService = Shadows.shadowOf(service) + + val startIntent = Intent(context, ProfilerService::class.java) service.onStartCommand(startIntent, 0, 1) - val postedNotification: Notification? = shadowNotificationManager.getNotification(ProfilerService.PROFILING_NOTIFICATION_ID) + val postedNotification: Notification? = shadowNotificationManager.getNotification(PROFILING_NOTIFICATION_ID) assertNotNull("Notification should be posted after start action", postedNotification) val shadowNotification = Shadows.shadowOf(postedNotification) @@ -120,39 +160,48 @@ class ProfilerServiceTest { } @Test - fun `GIVEN profiler service is running WHEN onStartCommand receives 'stop action' THEN the profiler service stops`() { - val startIntent = Intent(context, ProfilerService::class.java).apply { - action = ProfilerService.ACTION_START_PROFILING - } + fun `GIVEN profiler service is running WHEN receiving inactive broadcast THEN the service stops`() = runTest(coroutineRule.testDispatcher) { + serviceController = Robolectric.buildService(ProfilerService::class.java) + serviceController.create() + service = serviceController.get() + shadowService = Shadows.shadowOf(service) + + val startIntent = Intent(context, ProfilerService::class.java) service.onStartCommand(startIntent, 0, 1) assertNotNull( "Notification should be present after starting", - shadowNotificationManager.getNotification(ProfilerService.PROFILING_NOTIFICATION_ID), + shadowNotificationManager.getNotification(PROFILING_NOTIFICATION_ID), ) - val stopIntent = Intent(context, ProfilerService::class.java).apply { - action = ProfilerService.ACTION_STOP_PROFILING + val broadcast = Intent(INTENT_PROFILER_STATE_CHANGED).apply { + putExtra(ProfilerService.IS_PROFILER_ACTIVE, false) + setPackage(context.packageName) } - service.onStartCommand(stopIntent, 0, 2) + context.sendBroadcast(broadcast) + + Shadows.shadowOf(Looper.getMainLooper()).idle() assertNull( - "Notification should be removed after stop action", - shadowNotificationManager.getNotification(ProfilerService.PROFILING_NOTIFICATION_ID), + "Notification should be removed after inactive broadcast", + shadowNotificationManager.getNotification(PROFILING_NOTIFICATION_ID), ) - assertTrue("Service should be foreground-stopped after stop action", shadowService.isForegroundStopped) - assertTrue("Service should be self-stopped after stop action", shadowService.isStoppedBySelf) + assertTrue("Service should be foreground-stopped after inactive broadcast", shadowService.isForegroundStopped) + assertTrue("Service should be self-stopped after inactive broadcast", shadowService.isStoppedBySelf) } @Test - fun `GIVEN the profiler service is running WHEN onStartCommand receives an unknown action THEN the profiler service stops`() { - val startIntent = Intent(context, ProfilerService::class.java).apply { - action = ProfilerService.ACTION_START_PROFILING - } + fun `GIVEN the profiler service is running WHEN onStartCommand receives an unknown action THEN the profiler service stays running`() { + serviceController = Robolectric.buildService(ProfilerService::class.java) + serviceController.create() + service = serviceController.get() + shadowService = Shadows.shadowOf(service) + + val startIntent = Intent(context, ProfilerService::class.java) service.onStartCommand(startIntent, 0, 1) assertNotNull( "Notification should be present after starting", - shadowNotificationManager.getNotification(ProfilerService.PROFILING_NOTIFICATION_ID), + shadowNotificationManager.getNotification(PROFILING_NOTIFICATION_ID), ) val unknownIntent = Intent(context, ProfilerService::class.java).apply { @@ -161,34 +210,37 @@ class ProfilerServiceTest { service.onStartCommand(unknownIntent, 0, 2) - assertNull( - "Notification should be removed after unknown action", - shadowNotificationManager.getNotification(ProfilerService.PROFILING_NOTIFICATION_ID), + assertNotNull( + "Notification should remain after unknown action", + shadowNotificationManager.getNotification(PROFILING_NOTIFICATION_ID), ) - assertTrue("Service should be foreground-stopped after unknown action", shadowService.isForegroundStopped) - assertTrue("Service should be self-stopped after unknown action", shadowService.isStoppedBySelf) + assertFalse("Service should not be foreground-stopped after unknown action", shadowService.isForegroundStopped) + assertFalse("Service should not be self-stopped after unknown action", shadowService.isStoppedBySelf) } @Test - fun `GIVEN the profiler service is running WHEN onStartCommand receives a null action THEN the profiler service stops`() { - val startIntent = Intent(context, ProfilerService::class.java).apply { - action = ProfilerService.ACTION_START_PROFILING - } + fun `GIVEN the profiler service is running WHEN onStartCommand receives a null action THEN the profiler service stays running`() { + serviceController = Robolectric.buildService(ProfilerService::class.java) + serviceController.create() + service = serviceController.get() + shadowService = Shadows.shadowOf(service) + + val startIntent = Intent(context, ProfilerService::class.java) service.onStartCommand(startIntent, 0, 1) assertNotNull( "Notification should be present after starting", - shadowNotificationManager.getNotification(ProfilerService.PROFILING_NOTIFICATION_ID), + shadowNotificationManager.getNotification(PROFILING_NOTIFICATION_ID), ) val nullActionIntent = Intent(context, ProfilerService::class.java) service.onStartCommand(nullActionIntent, 0, 2) - assertNull( - "Notification should be removed after null action", - shadowNotificationManager.getNotification(ProfilerService.PROFILING_NOTIFICATION_ID), + assertNotNull( + "Notification should remain after null action", + shadowNotificationManager.getNotification(PROFILING_NOTIFICATION_ID), ) - assertTrue("Service should be foreground-stopped after null action", shadowService.isForegroundStopped) - assertTrue("Service should be self-stopped after null action", shadowService.isStoppedBySelf) + assertFalse("Service should not be foreground-stopped after null action", shadowService.isForegroundStopped) + assertFalse("Service should not be self-stopped after null action", shadowService.isStoppedBySelf) } } diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/ProfilerViewModelTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/ProfilerViewModelTest.kt @@ -39,7 +39,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf import org.robolectric.shadows.ShadowApplication -@OptIn(ExperimentalCoroutinesApi::class) // advanceUntilIdle +@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) class ProfilerViewModelTest { @@ -213,6 +213,7 @@ class ProfilerViewModelTest { viewModel.initiateProfilerStartProcess(settings) every { mockProfiler.isProfilerActive() } returns true + advanceUntilIdle() collectionJob.cancel() @@ -220,17 +221,12 @@ class ProfilerViewModelTest { val expectedSequence = listOf( ProfilerUiState.Idle::class, ProfilerUiState.Starting::class, - ProfilerUiState.ShowToast::class, - ProfilerUiState.Running::class, ) + val actualSequence = collectedStates.map { it::class } assertEquals("The sequence of UI states was not as expected", expectedSequence, actualSequence) verify { mockProfiler.startProfiler(settings.threads, settings.features) } - - val startedServiceIntent = shadowApplication.nextStartedService - assertNotNull("A service should have been started", startedServiceIntent) - assertEquals(ProfilerService.ACTION_START_PROFILING, startedServiceIntent.action) } @Test @@ -250,8 +246,6 @@ class ProfilerViewModelTest { assertNull((lastState as ProfilerUiState.Finished).profileUrl) verify(exactly = 0) { mockProfiler.stopProfiler(any(), any()) } - val startedServiceIntent = shadowApplication.nextStartedService - assertNull("No service should have been started", startedServiceIntent) } @Test @@ -294,9 +288,6 @@ class ProfilerViewModelTest { verify { mockProfiler.stopProfiler(any(), any()) } verify { mockProfilerUtils.saveProfileUrlToClipboard(fakeProfileData, mockApplication) } verify { mockProfilerUtils.finishProfileSave(mockApplication, expectedUrl, any()) } - val startedServiceIntent = shadowApplication.nextStartedService - assertNotNull("A service should have been started to stop profiling", startedServiceIntent) - assertEquals(ProfilerService.ACTION_STOP_PROFILING, startedServiceIntent.action) } @Test @@ -335,9 +326,6 @@ class ProfilerViewModelTest { verify { mockProfiler.stopProfiler(any(), any()) } verify(exactly = 0) { mockProfilerUtils.saveProfileUrlToClipboard(any(), any()) } - val startedServiceIntent = shadowApplication.nextStartedService - assertNotNull("Intent for stopping service was not captured", startedServiceIntent) - assertEquals(ProfilerService.ACTION_STOP_PROFILING, startedServiceIntent.action) } @Test @@ -387,9 +375,6 @@ class ProfilerViewModelTest { verify { mockProfiler.stopProfiler(any(), any()) } verify { mockProfilerUtils.saveProfileUrlToClipboard(fakeProfileData, mockApplication) } verify(exactly = 0) { mockProfilerUtils.finishProfileSave(any(), any(), any()) } - val startedServiceIntent = shadowApplication.nextStartedService - assertNotNull("A service should have been started", startedServiceIntent) - assertEquals(ProfilerService.ACTION_STOP_PROFILING, startedServiceIntent.action) } @Test @@ -426,36 +411,6 @@ class ProfilerViewModelTest { verify { mockProfiler.stopProfiler(any(), any()) } verify(exactly = 0) { mockProfilerUtils.saveProfileUrlToClipboard(any(), any()) } - val startedServiceIntent = shadowApplication.nextStartedService - assertNotNull("Intent for stopping service was not captured", startedServiceIntent) - assertEquals(ProfilerService.ACTION_STOP_PROFILING, startedServiceIntent.action) - } - - @Test - fun `WHEN the profiler's active status changes THEN the ViewModel's status updates accordingly`() = runTest(testDispatcher) { - initializeViewModel( - isInitiallyActive = false, - mainDispatcher = testDispatcher, - ioDispatcher = testDispatcher, - ) - - val collectedStates = mutableListOf<Boolean>() - val collectJob = launch { - viewModel.isProfilerActive.toList(collectedStates) - } - advanceUntilIdle() - - every { mockProfiler.isProfilerActive() } returns true - viewModel.updateProfilerActiveStatus() - advanceUntilIdle() - - every { mockProfiler.isProfilerActive() } returns false - viewModel.updateProfilerActiveStatus() - advanceUntilIdle() - - collectJob.cancel() - - assertEquals(listOf(false, true, false), collectedStates) } @Test diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoJavaSampler.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoJavaSampler.java @@ -5,6 +5,8 @@ package org.mozilla.gecko; +import android.content.ComponentName; +import android.content.Intent; import android.os.Build; import android.os.Looper; import android.os.Process; @@ -13,6 +15,7 @@ import android.util.Log; import androidx.annotation.GuardedBy; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -39,8 +42,15 @@ import org.mozilla.geckoview.GeckoResult; * exception is {@link #isProfilerActive()}: see the javadoc for details. */ public class GeckoJavaSampler { + private static final String LOGTAG = "GeckoJavaSampler"; + private static final String PROFILER_SERVICE_CLASS_NAME = + "org.mozilla.fenix.perf.ProfilerService"; + private static final String PROFILER_SERVICE_ACTION = "mozilla.perf.action.START_PROFILING"; + public static final String INTENT_PROFILER_STATE_CHANGED = + "org.mozilla.fenix.PROFILER_STATE_CHANGED"; + /** * The thread ID to use for the main thread instead of its true thread ID. * @@ -584,8 +594,6 @@ public class GeckoJavaSampler { return; } - Log.i(LOGTAG, "Profiler starting. Calling thread: " + Thread.currentThread().getName()); - // Setting a limit of 120000 (2 mins with 1ms interval) for samples and markers for now // to make sure we are not allocating too much. final int limitedEntryCount = Math.min(aEntryCount, 120000); @@ -780,6 +788,32 @@ public class GeckoJavaSampler { } } + /** + * Notifies Fenix layer about profiler state changes by broadcasting the new state. This is called + * from native code whenever the profiler starts or stops, ensuring that the Fenix repository is + * always synchronized with the actual native profiler state. + * + * @param isActive true if the profiler is now active, false if it stopped + */ + @WrapForJNI + public static void notifyProfilerStateChanged(final boolean isActive) { + if (isActive) { + final ComponentName componentName = + new ComponentName(GeckoAppShell.getApplicationContext(), PROFILER_SERVICE_CLASS_NAME); + final Intent serviceIntent = new Intent(); + serviceIntent.setComponent(componentName); + serviceIntent.setAction(PROFILER_SERVICE_ACTION); + ContextCompat.startForegroundService(GeckoAppShell.getApplicationContext(), serviceIntent); + } + + final Intent intent = new Intent(INTENT_PROFILER_STATE_CHANGED); + intent.putExtra("isActive", isActive); + intent.setPackage(GeckoAppShell.getApplicationContext().getPackageName()); + final String permission = + GeckoAppShell.getApplicationContext().getPackageName() + ".permission.PROFILER_INTERNAL"; + GeckoAppShell.getApplicationContext().sendBroadcast(intent, permission); + } + @WrapForJNI(dispatchTo = "gecko", stubName = "StartProfiler") private static native void startProfilerNative(String[] aFilters, String[] aFeaturesArr); diff --git a/tools/profiler/core/platform.cpp b/tools/profiler/core/platform.cpp @@ -5647,9 +5647,22 @@ static void NotifyObservers(const char* aTopic, return; } + // Notify C++ observers through the ObserverService if (nsCOMPtr<nsIObserverService> os = services::GetObserverService()) { os->NotifyObservers(aSubject, aTopic, nullptr); } + +#if defined(GP_OS_android) + // In the parent process, notify the GeckoJavaSampler when the profiler is + // started / stopped. + if (XRE_IsParentProcess()) { + if (strcmp(aTopic, "profiler-started") == 0) { + java::GeckoJavaSampler::NotifyProfilerStateChanged(true); + } else if (strcmp(aTopic, "profiler-stopped") == 0) { + java::GeckoJavaSampler::NotifyProfilerStateChanged(false); + } + } +#endif } [[nodiscard]] static RefPtr<GenericPromise> NotifyProfilerStarted(