Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit a9fa2253 authored by Austin Tankiang's avatar Austin Tankiang
Browse files

Extract out job panel UI state into a view model

This is so the list is retained on app resize, etc.

Bug: 407675188
Test: atest -c 'DocumentsUIGoogleTests:com.android.documentsui.JobPanelControllerTest'
Test: atest -c 'DocumentsUIGoogleTests:com.android.documentsui.JobPanelUiTest'
Flag: com.android.documentsui.flags.visual_signals_ro
Change-Id: I079307c732b59c148b44afc9cfb8454549be78ca
parent b97d85e8
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -57,6 +57,7 @@ java_defaults {
        "androidx.legacy_legacy-support-core-ui",
        "androidx.legacy_legacy-support-v13",
        "androidx.legacy_legacy-support-v4",
        "androidx.lifecycle_lifecycle-viewmodel-ktx",
        "androidx.recyclerview_recyclerview",
        "androidx.recyclerview_recyclerview-selection",
        "androidx.transition_transition",
+30 −70
Original line number Diff line number Diff line
@@ -21,7 +21,6 @@ import android.content.Intent
import android.content.IntentFilter
import android.graphics.Rect
import android.text.format.Formatter
import android.util.Log
import android.view.Gravity
import android.view.LayoutInflater
import android.view.MenuItem
@@ -37,9 +36,10 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.android.documentsui.JobPanelViewModel.MenuIconState
import com.android.documentsui.JobPanelViewModel.ProgressViewModel
import com.android.documentsui.base.Menus
import com.android.documentsui.services.FileOperationService
import com.android.documentsui.services.FileOperationService.EXTRA_PROGRESS
import com.android.documentsui.services.FileOperations
import com.android.documentsui.services.Job
import com.android.documentsui.services.JobProgress
@@ -71,15 +71,19 @@ private class VerticalMarginItemDecoration(
/**
 * JobPanelController is responsible for receiving broadcast updates from the [FileOperationService]
 * and updating a given menu item to reflect the current progress.
 *
 * @param activityContext Context used to receive broadcasts and access resources for UI.
 * @param viewModel View model containing the state of the job panel.
 */
class JobPanelController(private val activityContext: Context) : BroadcastReceiver() {
class JobPanelController(
    private val activityContext: Context,
    private val viewModel: JobPanelViewModel,
) : BroadcastReceiver() {
    companion object {
        private const val TAG = "JobPanelController"
        private const val MAX_PROGRESS = 100
    }

    data class ProgressViewModel(val jobProgress: JobProgress, val expanded: Boolean = false)

    private class ProgressItemHolder(
        private val controller: JobPanelController,
        private val cardView: View,
@@ -261,19 +265,6 @@ class JobPanelController(private val activityContext: Context) : BroadcastReceiv
        }
    }

    private enum class MenuIconState {
        INVISIBLE, INDETERMINATE, VISIBLE
    }

    /** The current state of the menu progress item. */
    private var menuIconState = MenuIconState.INVISIBLE

    /** The total progress from 0 to MAX_PROGRESS. */
    private var totalProgress = 0

    /** List of jobs currently tracked by this class. */
    private val currentJobs = LinkedHashMap<String, ProgressViewModel>()

    /** Current menu item being controlled by this class. */
    private var menuItem: MenuItem? = null

@@ -288,21 +279,21 @@ class JobPanelController(private val activityContext: Context) : BroadcastReceiv
        activityContext.registerReceiver(this, filter, Context.RECEIVER_NOT_EXPORTED)
    }

    private fun updateMenuItem(animate: Boolean) {
        if (menuIconState == MenuIconState.INVISIBLE) {
    private fun updateMenuItem(menuIconState: MenuIconState, animate: Boolean) {
        if (menuIconState is MenuIconState.INVISIBLE) {
            popup?.dismiss()
        }

        menuItem?.let {
            Menus.setEnabledAndVisible(it, menuIconState != MenuIconState.INVISIBLE)
            Menus.setEnabledAndVisible(it, menuIconState !is MenuIconState.INVISIBLE)
            val icon = it.actionView as ProgressBar
            when (menuIconState) {
                MenuIconState.INDETERMINATE -> icon.isIndeterminate = true
                MenuIconState.VISIBLE -> icon.apply {
                is MenuIconState.INDETERMINATE -> icon.isIndeterminate = true
                is MenuIconState.VISIBLE -> icon.apply {
                    isIndeterminate = false
                    setProgress(totalProgress, animate)
                    setProgress(menuIconState.totalProgress, animate)
                }
                MenuIconState.INVISIBLE -> {}
                is MenuIconState.INVISIBLE -> {}
            }
        }
    }
@@ -320,7 +311,7 @@ class JobPanelController(private val activityContext: Context) : BroadcastReceiv
                /* root= */ null
            )
            val listAdapter = ProgressListAdapter(this)
            listAdapter.submitList(ArrayList(currentJobs.values))
            listAdapter.submitList(ArrayList(viewModel.currentJobs.values))
            panel.findViewById<RecyclerView>(getRes(R.id.job_progress_list)).apply {
                layoutManager = LinearLayoutManager(context)
                addItemDecoration(VerticalMarginItemDecoration(
@@ -352,59 +343,28 @@ class JobPanelController(private val activityContext: Context) : BroadcastReceiv
            }
        }
        menuItem = newMenuItem
        updateMenuItem(animate = false)
        // Don't animate for the initial state update.
        updateMenuItem(viewModel.getMenuState(), animate = false)
    }

    override fun onReceive(context: Context?, intent: Intent) {
        val progresses = intent.getParcelableArrayListExtra<JobProgress>(
            EXTRA_PROGRESS,
        val progresses = intent.getParcelableArrayListExtra(
            FileOperationService.EXTRA_PROGRESS,
            JobProgress::class.java
        )
        updateProgress(progresses!!)
    }

    private fun updateProgress(progresses: List<JobProgress>) {
        var currentPercent = 0f
        var allIndeterminate = true

        for (jobProgress in progresses) {
            Log.d(TAG, "Received $jobProgress")
            if (jobProgress.state == Job.STATE_CANCELED) {
                currentJobs.remove(jobProgress.id)
            } else {
                currentJobs.merge(jobProgress.id, ProgressViewModel(jobProgress)) { old, new ->
                    ProgressViewModel(new.jobProgress, old.expanded)
                }
            }
        }
        for ((jobProgress, _) in currentJobs.values) {
            if (!jobProgress.isIndeterminate) {
                allIndeterminate = false
                currentPercent += jobProgress.toPercent()
            }
        }

        if (currentJobs.isEmpty()) {
            menuIconState = MenuIconState.INVISIBLE
        } else if (allIndeterminate) {
            menuIconState = MenuIconState.INDETERMINATE
        } else {
            menuIconState = MenuIconState.VISIBLE
            totalProgress = (currentPercent / currentJobs.size).toInt()
        }
        updateMenuItem(animate = true)
        progressListAdapter?.submitList(ArrayList(currentJobs.values))
        viewModel.updateProgress(progresses!!)
        updateMenuItem(viewModel.getMenuState(), animate = true)
        progressListAdapter?.submitList(ArrayList(viewModel.currentJobs.values))
    }

    private fun dismissProgress(id: String) {
        currentJobs.remove(id)
        updateProgress(emptyList())
        viewModel.dismissProgress(id)
        updateMenuItem(viewModel.getMenuState(), animate = true)
        progressListAdapter?.submitList(ArrayList(viewModel.currentJobs.values))
    }

    private fun toggleExpanded(id: String) {
        currentJobs.computeIfPresent(id) { _, (jobProgress, expanded) ->
            ProgressViewModel(jobProgress, !expanded)
        }
        progressListAdapter?.submitList(ArrayList(currentJobs.values))
        viewModel.toggleExpanded(id)
        progressListAdapter?.submitList(ArrayList(viewModel.currentJobs.values))
    }
}
+109 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.android.documentsui

import android.util.Log
import androidx.lifecycle.ViewModel
import com.android.documentsui.base.SharedMinimal.DEBUG
import com.android.documentsui.services.Job
import com.android.documentsui.services.JobProgress

/**
 * Manages the UI state for the [JobPanelController].
 */
class JobPanelViewModel : ViewModel() {
    companion object {
        private const val TAG = "JobPanelViewModel"
    }

    /**
     * The UI state representation of a single progress item.
     *
     * @property jobProgress The progress shown by this item.
     * @property expanded Whether the UI card for this item is expanded or not.
     */
    data class ProgressViewModel(val jobProgress: JobProgress, val expanded: Boolean = false)

    /**
     * The UI state representation of the toolbar progress icon.
     */
    sealed class MenuIconState {
        data object INVISIBLE : MenuIconState()
        data object INDETERMINATE : MenuIconState()
        data class VISIBLE(val totalProgress: Int) : MenuIconState()
    }

    /** List of jobs currently tracked. */
    private val _currentJobs = LinkedHashMap<String, ProgressViewModel>()
    val currentJobs: Map<String, ProgressViewModel> get() = _currentJobs

    /**
     * Gets the state of the toolbar progress icon based off the current jobs tracked.
     */
    fun getMenuState(): MenuIconState {
        var currentPercent = 0f
        var allIndeterminate = true

        for ((jobProgress, _) in currentJobs.values) {
            if (!jobProgress.isIndeterminate) {
                allIndeterminate = false
                currentPercent += jobProgress.toPercent()
            }
        }

        var state: MenuIconState
        if (currentJobs.isEmpty()) {
            state = MenuIconState.INVISIBLE
        } else if (allIndeterminate) {
            state = MenuIconState.INDETERMINATE
        } else {
            state = MenuIconState.VISIBLE((currentPercent / currentJobs.size).toInt())
        }
        return state
    }

    /**
     * Updates the list of progresses managed by this class.
     */
    fun updateProgress(progresses: List<JobProgress>) {
        for (jobProgress in progresses) {
            if (DEBUG) Log.d(TAG, "Received $jobProgress")
            if (jobProgress.state == Job.STATE_CANCELED) {
                _currentJobs.remove(jobProgress.id)
            } else {
                _currentJobs.merge(jobProgress.id, ProgressViewModel(jobProgress)) { old, new ->
                    ProgressViewModel(new.jobProgress, old.expanded)
                }
            }
        }
    }

    /**
     * Removes a specific progress item from the list managed by this class.
     */
    fun dismissProgress(id: String) {
        _currentJobs.remove(id)
    }

    /**
     * Toggles the expanded state of a specific progress item.
     */
    fun toggleExpanded(id: String) {
        _currentJobs.computeIfPresent(id) { _, (jobProgress, expanded) ->
            ProgressViewModel(jobProgress, !expanded)
        }
    }
}
+10 −2
Original line number Diff line number Diff line
@@ -38,6 +38,7 @@ import android.view.View;

import androidx.annotation.CallSuper;
import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.ViewModelProvider;

import com.android.documentsui.AbstractActionHandler;
import com.android.documentsui.ActionModeController;
@@ -46,6 +47,8 @@ import com.android.documentsui.DocsSelectionHelper;
import com.android.documentsui.DocumentsApplication;
import com.android.documentsui.FocusManager;
import com.android.documentsui.Injector;
import com.android.documentsui.JobPanelController;
import com.android.documentsui.JobPanelViewModel;
import com.android.documentsui.MenuManager.DirectoryDetails;
import com.android.documentsui.OperationDialogFragment;
import com.android.documentsui.OperationDialogFragment.DialogType;
@@ -129,7 +132,7 @@ public class FilesActivity extends BaseActivity implements AbstractActionHandler
                        this::focusSidebar,
                        getColor(getRes(R.color.primary)));

        mInjector.menuManager = new MenuManager(
        MenuManager menuManager = new MenuManager(
                mInjector.features,
                mSearchManager,
                mState,
@@ -139,11 +142,16 @@ public class FilesActivity extends BaseActivity implements AbstractActionHandler
                        return clipper.hasItemsToPaste();
                    }
                },
                isVisualSignalsFlagEnabled() ? this : getApplicationContext(),
                getApplicationContext(),
                mInjector.selectionMgr,
                mProviders::getApplicationName,
                mInjector.getModel()::getItemUri,
                mInjector.getModel()::getItemCount);
        if (isVisualSignalsFlagEnabled()) {
            menuManager.setJobPanelController(new JobPanelController(this,
                    new ViewModelProvider(this).get(JobPanelViewModel.class)));
        }
        mInjector.menuManager = menuManager;

        if (isUseMaterial3FlagEnabled()) {
            mInjector.selectionBarController =
+5 −7
Original line number Diff line number Diff line
@@ -17,7 +17,6 @@
package com.android.documentsui.files;

import static com.android.documentsui.util.FlagUtils.isDesktopFileHandlingFlagEnabled;
import static com.android.documentsui.util.FlagUtils.isVisualSignalsFlagEnabled;
import static com.android.documentsui.util.FlagUtils.isZipNgFlagEnabled;
import static com.android.documentsui.util.Material3Config.getRes;

@@ -61,7 +60,7 @@ public final class MenuManager extends com.android.documentsui.MenuManager {
    private final SelectionTracker<String> mSelectionManager;
    private final Lookup<String, Uri> mUriLookup;
    private final LookupApplicationName mAppNameLookup;
    @Nullable private final JobPanelController mJobPanelController;
    @Nullable private JobPanelController mJobPanelController;

    public MenuManager(
            Features features,
@@ -81,12 +80,11 @@ public final class MenuManager extends com.android.documentsui.MenuManager {
        mSelectionManager = selectionManager;
        mAppNameLookup = appNameLookup;
        mUriLookup = uriLookup;

        if (isVisualSignalsFlagEnabled()) {
            mJobPanelController = new JobPanelController(context);
        } else {
            mJobPanelController = null;
    }

    // TODO(b/378011512): Remove and merge with constructor once visual signals flag is removed.
    public void setJobPanelController(JobPanelController controller) {
        mJobPanelController = controller;
    }

    @Override
Loading