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

Commit ab51b43b authored by Jeff DeCew's avatar Jeff DeCew Committed by Automerger Merge Worker
Browse files

Merge changes from topic "b204127880_pipeline_backport_3" into sc-v2-dev am: a509832d

Original change: https://googleplex-android-review.googlesource.com/c/platform/frameworks/base/+/16167382

Change-Id: I31db4435cca95ceefc959d922ff40841b6cf893b
parents 839eda32 a509832d
Loading
Loading
Loading
Loading
+37 −10
Original line number Diff line number Diff line
@@ -58,7 +58,6 @@ import com.android.systemui.plugins.statusbar.StatusBarStateController;
import com.android.systemui.statusbar.dagger.StatusBarDependenciesModule;
import com.android.systemui.statusbar.notification.NotificationEntryListener;
import com.android.systemui.statusbar.notification.NotificationEntryManager;
import com.android.systemui.statusbar.notification.collection.NotifPipeline;
import com.android.systemui.statusbar.notification.collection.NotificationEntry;
import com.android.systemui.statusbar.notification.collection.NotificationEntry.EditedSuggestionInfo;
import com.android.systemui.statusbar.notification.logging.NotificationLogger;
@@ -203,7 +202,7 @@ public class NotificationRemoteInputManager implements Dumpable {
                ViewGroup actionGroup = (ViewGroup) parent;
                buttonIndex = actionGroup.indexOfChild(view);
            }
            // FIXME: get this for the new pipeline!
            // TODO(b/204183781): get this from the current pipeline
            final int count = mEntryManager.getActiveNotificationsCount();
            final int rank = entry.getRanking().getRank();

@@ -265,7 +264,7 @@ public class NotificationRemoteInputManager implements Dumpable {
            NotificationLockscreenUserManager lockscreenUserManager,
            SmartReplyController smartReplyController,
            NotificationEntryManager notificationEntryManager,
            NotifPipeline notifPipeline,
            RemoteInputNotificationRebuilder rebuilder,
            Lazy<Optional<StatusBar>> statusBarOptionalLazy,
            StatusBarStateController statusBarStateController,
            @Main Handler mainHandler,
@@ -284,7 +283,7 @@ public class NotificationRemoteInputManager implements Dumpable {
        mBarService = IStatusBarService.Stub.asInterface(
                ServiceManager.getService(Context.STATUS_BAR_SERVICE));
        mUserManager = (UserManager) mContext.getSystemService(Context.USER_SERVICE);
        mRebuilder = new RemoteInputNotificationRebuilder(context);  // TODO: inject?
        mRebuilder = rebuilder;
        if (!featureFlags.isNewNotifPipelineRenderingEnabled()) {
            mRemoteInputListener = createLegacyRemoteInputLifetimeExtender(mainHandler,
                    notificationEntryManager, smartReplyController);
@@ -320,6 +319,19 @@ public class NotificationRemoteInputManager implements Dumpable {
        });
    }

    /** Add a listener for various remote input events.  Works with NEW pipeline only. */
    public void setRemoteInputListener(@NonNull RemoteInputListener remoteInputListener) {
        if (mFeatureFlags.isNewNotifPipelineRenderingEnabled()) {
            if (mRemoteInputListener != null) {
                throw new IllegalStateException("mRemoteInputListener is already set");
            }
            mRemoteInputListener = remoteInputListener;
            if (mRemoteInputController != null) {
                mRemoteInputListener.setRemoteInputController(mRemoteInputController);
            }
        }
    }

    @NonNull
    @VisibleForTesting
    protected LegacyRemoteInputLifetimeExtender createLegacyRemoteInputLifetimeExtender(
@@ -333,7 +345,9 @@ public class NotificationRemoteInputManager implements Dumpable {
    public void setUpWithCallback(Callback callback, RemoteInputController.Delegate delegate) {
        mCallback = callback;
        mRemoteInputController = new RemoteInputController(delegate, mRemoteInputUriController);
        if (mRemoteInputListener != null) {
            mRemoteInputListener.setRemoteInputController(mRemoteInputController);
        }
        // Register all stored callbacks from before the Controller was initialized.
        for (RemoteInputController.Callback cb : mControllerCallbacks) {
            mRemoteInputController.addCallback(cb);
@@ -366,7 +380,6 @@ public class NotificationRemoteInputManager implements Dumpable {
            }
        });
        if (!mFeatureFlags.isNewNotifPipelineRenderingEnabled()) {
            // FIXME: Don't forget to implement this in the coordinator!
            mSmartReplyController.setCallback((entry, reply) -> {
                StatusBarNotification newSbn = mRebuilder.rebuildForSendingSmartReply(entry, reply);
                mEntryManager.updateNotification(newSbn, null /* ranking */);
@@ -572,6 +585,14 @@ public class NotificationRemoteInputManager implements Dumpable {
        // OLD pipeline code ONLY; can assume implementation
        ((LegacyRemoteInputLifetimeExtender) mRemoteInputListener)
                .mKeysKeptForRemoteInputHistory.remove(key);
        cleanUpRemoteInputForUserRemoval(entry);
    }

    /**
     * Disable remote input on the entry and remove the remote input view.
     * This should be called when a user dismisses a notification that won't be lifetime extended.
     */
    public void cleanUpRemoteInputForUserRemoval(NotificationEntry entry) {
        if (isRemoteInputActive(entry)) {
            entry.mRemoteEditImeVisible = false;
            mRemoteInputController.removeRemoteInput(entry, null);
@@ -762,15 +783,21 @@ public class NotificationRemoteInputManager implements Dumpable {
        boolean showBouncerIfNecessary();
    }

    /** An interface for listening to remote input events that relate to notification lifetime */
    public interface RemoteInputListener {
        void onRemoteInputSent(NotificationEntry entry);
        /** Called when remote input pending intent has been sent */
        void onRemoteInputSent(@NonNull NotificationEntry entry);

        /** Called when the notification shade becomes fully closed */
        void onPanelCollapsed();

        boolean isNotificationKeptForRemoteInputHistory(String key);
        /** @return whether lifetime of a notification is being extended by the listener */
        boolean isNotificationKeptForRemoteInputHistory(@NonNull String key);

        /** Called on user interaction to end lifetime extension for history */
        void releaseNotificationIfKeptForRemoteInputHistory(@NonNull NotificationEntry entry);

        /** Called when the RemoteInputController is attached to the manager */
        void setRemoteInputController(@NonNull RemoteInputController remoteInputController);
    }

@@ -826,7 +853,7 @@ public class NotificationRemoteInputManager implements Dumpable {
        }

        @Override
        public void onRemoteInputSent(NotificationEntry entry) {
        public void onRemoteInputSent(@NonNull NotificationEntry entry) {
            if (FORCE_REMOTE_INPUT_HISTORY
                    && isNotificationKeptForRemoteInputHistory(entry.getKey())) {
                mNotificationLifetimeFinishedCallback.onSafeToRemove(entry.getKey());
@@ -858,7 +885,7 @@ public class NotificationRemoteInputManager implements Dumpable {
        }

        @Override
        public boolean isNotificationKeptForRemoteInputHistory(String key) {
        public boolean isNotificationKeptForRemoteInputHistory(@NonNull String key) {
            return mKeysKeptForRemoteInputHistory.contains(key);
        }

+3 −2
Original line number Diff line number Diff line
@@ -43,6 +43,7 @@ import com.android.systemui.statusbar.NotificationMediaManager;
import com.android.systemui.statusbar.NotificationRemoteInputManager;
import com.android.systemui.statusbar.NotificationShadeWindowController;
import com.android.systemui.statusbar.NotificationViewHierarchyManager;
import com.android.systemui.statusbar.RemoteInputNotificationRebuilder;
import com.android.systemui.statusbar.SmartReplyController;
import com.android.systemui.statusbar.StatusBarStateControllerImpl;
import com.android.systemui.statusbar.SysuiStatusBarStateController;
@@ -100,7 +101,7 @@ public interface StatusBarDependenciesModule {
            NotificationLockscreenUserManager lockscreenUserManager,
            SmartReplyController smartReplyController,
            NotificationEntryManager notificationEntryManager,
            NotifPipeline notifPipeline,
            RemoteInputNotificationRebuilder rebuilder,
            Lazy<Optional<StatusBar>> statusBarOptionalLazy,
            StatusBarStateController statusBarStateController,
            Handler mainHandler,
@@ -114,7 +115,7 @@ public interface StatusBarDependenciesModule {
                lockscreenUserManager,
                smartReplyController,
                notificationEntryManager,
                notifPipeline,
                rebuilder,
                statusBarOptionalLazy,
                statusBarStateController,
                mainHandler,
+2 −0
Original line number Diff line number Diff line
@@ -48,6 +48,7 @@ class NotifCoordinatorsImpl @Inject constructor(
    conversationCoordinator: ConversationCoordinator,
    preparationCoordinator: PreparationCoordinator,
    mediaCoordinator: MediaCoordinator,
    remoteInputCoordinator: RemoteInputCoordinator,
    shadeEventCoordinator: ShadeEventCoordinator,
    smartspaceDedupingCoordinator: SmartspaceDedupingCoordinator,
    viewConfigCoordinator: ViewConfigCoordinator,
@@ -72,6 +73,7 @@ class NotifCoordinatorsImpl @Inject constructor(
        mCoordinators.add(bubbleCoordinator)
        mCoordinators.add(conversationCoordinator)
        mCoordinators.add(mediaCoordinator)
        mCoordinators.add(remoteInputCoordinator)
        mCoordinators.add(shadeEventCoordinator)
        mCoordinators.add(viewConfigCoordinator)
        mCoordinators.add(visualStabilityCoordinator)
+225 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2021 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.systemui.statusbar.notification.collection.coordinator

import android.os.Handler
import android.service.notification.NotificationListenerService.REASON_CANCEL
import android.service.notification.NotificationListenerService.REASON_CLICK
import android.util.Log
import androidx.annotation.VisibleForTesting
import com.android.systemui.Dumpable
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.dump.DumpManager
import com.android.systemui.statusbar.NotificationRemoteInputManager
import com.android.systemui.statusbar.NotificationRemoteInputManager.RemoteInputListener
import com.android.systemui.statusbar.RemoteInputController
import com.android.systemui.statusbar.RemoteInputNotificationRebuilder
import com.android.systemui.statusbar.SmartReplyController
import com.android.systemui.statusbar.notification.collection.NotifPipeline
import com.android.systemui.statusbar.notification.collection.NotificationEntry
import com.android.systemui.statusbar.notification.collection.notifcollection.SelfTrackingLifetimeExtender
import com.android.systemui.statusbar.notification.collection.notifcollection.InternalNotifUpdater
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifLifetimeExtender
import java.io.FileDescriptor
import java.io.PrintWriter
import javax.inject.Inject

private const val TAG = "RemoteInputCoordinator"

/**
 * How long to wait before auto-dismissing a notification that was kept for active remote input, and
 * has now sent a remote input. We auto-dismiss, because the app may not cannot cancel
 * these given that they technically don't exist anymore. We wait a bit in case the app issues
 * an update, and to also give the other lifetime extenders a beat to decide they want it.
 */
private const val REMOTE_INPUT_ACTIVE_EXTENDER_AUTO_CANCEL_DELAY: Long = 500

/**
 * How long to wait before releasing a lifetime extension when requested to do so due to a user
 * interaction (such as tapping another action).
 * We wait a bit in case the app issues an update in response to the action, but not too long or we
 * risk appearing unresponsive to the user.
 */
private const val REMOTE_INPUT_EXTENDER_RELEASE_DELAY: Long = 200

/** Whether this class should print spammy debug logs */
private val DEBUG: Boolean by lazy { Log.isLoggable(TAG, Log.DEBUG) }

@SysUISingleton
class RemoteInputCoordinator @Inject constructor(
    dumpManager: DumpManager,
    private val mRebuilder: RemoteInputNotificationRebuilder,
    private val mNotificationRemoteInputManager: NotificationRemoteInputManager,
    @Main private val mMainHandler: Handler,
    private val mSmartReplyController: SmartReplyController
) : Coordinator, RemoteInputListener, Dumpable {

    @VisibleForTesting val mRemoteInputHistoryExtender = RemoteInputHistoryExtender()
    @VisibleForTesting val mSmartReplyHistoryExtender = SmartReplyHistoryExtender()
    @VisibleForTesting val mRemoteInputActiveExtender = RemoteInputActiveExtender()
    private val mRemoteInputLifetimeExtenders = listOf(
            mRemoteInputHistoryExtender,
            mSmartReplyHistoryExtender,
            mRemoteInputActiveExtender
    )

    private lateinit var mNotifUpdater: InternalNotifUpdater

    init {
        dumpManager.registerDumpable(this)
    }

    fun getLifetimeExtenders(): List<NotifLifetimeExtender> = mRemoteInputLifetimeExtenders

    override fun attach(pipeline: NotifPipeline) {
        mNotificationRemoteInputManager.setRemoteInputListener(this)
        mRemoteInputLifetimeExtenders.forEach { pipeline.addNotificationLifetimeExtender(it) }
        mNotifUpdater = pipeline.getInternalNotifUpdater(TAG)
        pipeline.addCollectionListener(mCollectionListener)
    }

    val mCollectionListener = object : NotifCollectionListener {
        override fun onEntryUpdated(entry: NotificationEntry, fromSystem: Boolean) {
            if (DEBUG) {
                Log.d(TAG, "mCollectionListener.onEntryUpdated(entry=${entry.key}," +
                        " fromSystem=$fromSystem)")
            }
            if (fromSystem) {
                // Mark smart replies as sent whenever a notification is updated by the app,
                // otherwise the smart replies are never marked as sent.
                mSmartReplyController.stopSending(entry)
            }
        }

        override fun onEntryRemoved(entry: NotificationEntry, reason: Int) {
            if (DEBUG) Log.d(TAG, "mCollectionListener.onEntryRemoved(entry=${entry.key})")
            // We're removing the notification, the smart reply controller can forget about it.
            // TODO(b/145659174): track 'sending' state on the entry to avoid having to clear it.
            mSmartReplyController.stopSending(entry)

            // When we know the entry will not be lifetime extended, clean up the remote input view
            // TODO: Share code with NotifCollection.cannotBeLifetimeExtended
            if (reason == REASON_CANCEL || reason == REASON_CLICK) {
                mNotificationRemoteInputManager.cleanUpRemoteInputForUserRemoval(entry)
            }
        }
    }

    override fun dump(fd: FileDescriptor, pw: PrintWriter, args: Array<out String>) {
        mRemoteInputLifetimeExtenders.forEach { it.dump(fd, pw, args) }
    }

    override fun onRemoteInputSent(entry: NotificationEntry) {
        if (DEBUG) Log.d(TAG, "onRemoteInputSent(entry=${entry.key})")
        // These calls effectively ensure the freshness of the lifetime extensions.
        // NOTE: This is some trickery! By removing the lifetime extensions when we know they should
        // be immediately re-upped, we ensure that the side-effects of the lifetime extenders get to
        // fire again, thus ensuring that we add subsequent replies to the notification.
        mRemoteInputHistoryExtender.endLifetimeExtension(entry.key)
        mSmartReplyHistoryExtender.endLifetimeExtension(entry.key)

        // If we're extending for remote input being active, then from the apps point of
        // view it is already canceled, so we'll need to cancel it on the apps behalf
        // now that a reply has been sent. However, delay so that the app has time to posts an
        // update in the mean time, and to give another lifetime extender time to pick it up.
        mRemoteInputActiveExtender.endLifetimeExtensionAfterDelay(entry.key,
                REMOTE_INPUT_ACTIVE_EXTENDER_AUTO_CANCEL_DELAY)
    }

    private fun onSmartReplySent(entry: NotificationEntry, reply: CharSequence) {
        if (DEBUG) Log.d(TAG, "onSmartReplySent(entry=${entry.key})")
        val newSbn = mRebuilder.rebuildForSendingSmartReply(entry, reply)
        mNotifUpdater.onInternalNotificationUpdate(newSbn,
                "Adding smart reply spinner for sent")

        // If we're extending for remote input being active, then from the apps point of
        // view it is already canceled, so we'll need to cancel it on the apps behalf
        // now that a reply has been sent. However, delay so that the app has time to posts an
        // update in the mean time, and to give another lifetime extender time to pick it up.
        mRemoteInputActiveExtender.endLifetimeExtensionAfterDelay(entry.key,
                REMOTE_INPUT_ACTIVE_EXTENDER_AUTO_CANCEL_DELAY)
    }

    override fun onPanelCollapsed() {
        mRemoteInputActiveExtender.endAllLifetimeExtensions()
    }

    override fun isNotificationKeptForRemoteInputHistory(key: String) =
            mRemoteInputHistoryExtender.isExtending(key) ||
                    mSmartReplyHistoryExtender.isExtending(key)

    override fun releaseNotificationIfKeptForRemoteInputHistory(entry: NotificationEntry) {
        if (DEBUG) Log.d(TAG, "releaseNotificationIfKeptForRemoteInputHistory(entry=${entry.key})")
        mRemoteInputHistoryExtender.endLifetimeExtensionAfterDelay(entry.key,
                REMOTE_INPUT_EXTENDER_RELEASE_DELAY)
        mSmartReplyHistoryExtender.endLifetimeExtensionAfterDelay(entry.key,
                REMOTE_INPUT_EXTENDER_RELEASE_DELAY)
        mRemoteInputActiveExtender.endLifetimeExtensionAfterDelay(entry.key,
                REMOTE_INPUT_EXTENDER_RELEASE_DELAY)
    }

    override fun setRemoteInputController(remoteInputController: RemoteInputController) {
        mSmartReplyController.setCallback(this::onSmartReplySent)
    }

    @VisibleForTesting
    inner class RemoteInputHistoryExtender :
            SelfTrackingLifetimeExtender(TAG, "RemoteInputHistory", DEBUG, mMainHandler) {

        override fun queryShouldExtendLifetime(entry: NotificationEntry): Boolean =
                mNotificationRemoteInputManager.shouldKeepForRemoteInputHistory(entry)

        override fun onStartedLifetimeExtension(entry: NotificationEntry) {
            val newSbn = mRebuilder.rebuildForRemoteInputReply(entry)
            entry.onRemoteInputInserted()
            mNotifUpdater.onInternalNotificationUpdate(newSbn,
                    "Extending lifetime of notification with remote input")
            // TODO: Check if the entry was removed due perhaps to an inflation exception?
        }
    }

    @VisibleForTesting
    inner class SmartReplyHistoryExtender :
            SelfTrackingLifetimeExtender(TAG, "SmartReplyHistory", DEBUG, mMainHandler) {

        override fun queryShouldExtendLifetime(entry: NotificationEntry): Boolean =
                mNotificationRemoteInputManager.shouldKeepForSmartReplyHistory(entry)

        override fun onStartedLifetimeExtension(entry: NotificationEntry) {
            val newSbn = mRebuilder.rebuildForCanceledSmartReplies(entry)
            mSmartReplyController.stopSending(entry)
            mNotifUpdater.onInternalNotificationUpdate(newSbn,
                    "Extending lifetime of notification with smart reply")
            // TODO: Check if the entry was removed due perhaps to an inflation exception?
        }

        override fun onCanceledLifetimeExtension(entry: NotificationEntry) {
            // TODO(b/145659174): track 'sending' state on the entry to avoid having to clear it.
            mSmartReplyController.stopSending(entry)
        }
    }

    @VisibleForTesting
    inner class RemoteInputActiveExtender :
            SelfTrackingLifetimeExtender(TAG, "RemoteInputActive", DEBUG, mMainHandler) {

        override fun queryShouldExtendLifetime(entry: NotificationEntry): Boolean =
                mNotificationRemoteInputManager.isRemoteInputActive(entry)
    }
}
 No newline at end of file
+113 −0
Original line number Diff line number Diff line
package com.android.systemui.statusbar.notification.collection.notifcollection

import android.os.Handler
import android.util.ArrayMap
import android.util.Log
import com.android.systemui.Dumpable
import com.android.systemui.statusbar.notification.collection.NotificationEntry
import java.io.FileDescriptor
import java.io.PrintWriter

/**
 * A helpful class that implements the core contract of the lifetime extender internally,
 * making it easier for coordinators to interact with them
 */
abstract class SelfTrackingLifetimeExtender(
    private val tag: String,
    private val name: String,
    private val debug: Boolean,
    private val mainHandler: Handler
) : NotifLifetimeExtender, Dumpable {
    private lateinit var mCallback: NotifLifetimeExtender.OnEndLifetimeExtensionCallback
    protected val mEntriesExtended = ArrayMap<String, NotificationEntry>()
    private var mEnding = false

    /**
     * When debugging, warn if the call is happening during and "end lifetime extension" call.
     *
     * Note: this will warn a lot! The pipeline explicitly re-invokes all lifetime extenders
     * whenever one ends, giving all of them a chance to re-up their lifetime extension.
     */
    private fun warnIfEnding() {
        if (debug && mEnding) Log.w(tag, "reentrant code while ending a lifetime extension")
    }

    fun endAllLifetimeExtensions() {
        // clear the map before iterating over a copy of the items, because the pipeline will
        // always give us another chance to extend the lifetime again, and we don't want
        // concurrent modification
        val entries = mEntriesExtended.values.toList()
        if (debug) Log.d(tag, "$name.endAllLifetimeExtensions() entries=$entries")
        mEntriesExtended.clear()
        warnIfEnding()
        mEnding = true
        entries.forEach { mCallback.onEndLifetimeExtension(this, it) }
        mEnding = false
    }

    fun endLifetimeExtensionAfterDelay(key: String, delayMillis: Long) {
        if (debug) {
            Log.d(tag, "$name.endLifetimeExtensionAfterDelay" +
                    "(key=$key, delayMillis=$delayMillis)" +
                    " isExtending=${isExtending(key)}")
        }
        if (isExtending(key)) {
            mainHandler.postDelayed({ endLifetimeExtension(key) }, delayMillis)
        }
    }

    fun endLifetimeExtension(key: String) {
        if (debug) {
            Log.d(tag, "$name.endLifetimeExtension(key=$key)" +
                    " isExtending=${isExtending(key)}")
        }
        warnIfEnding()
        mEnding = true
        mEntriesExtended.remove(key)?.let { removedEntry ->
            mCallback.onEndLifetimeExtension(this, removedEntry)
        }
        mEnding = false
    }

    fun isExtending(key: String) = mEntriesExtended.contains(key)

    final override fun getName(): String = name

    final override fun shouldExtendLifetime(entry: NotificationEntry, reason: Int): Boolean {
        val shouldExtend = queryShouldExtendLifetime(entry)
        if (debug) {
            Log.d(tag, "$name.shouldExtendLifetime(key=${entry.key}, reason=$reason)" +
                    " isExtending=${isExtending(entry.key)}" +
                    " shouldExtend=$shouldExtend")
        }
        warnIfEnding()
        if (shouldExtend && mEntriesExtended.put(entry.key, entry) == null) {
            onStartedLifetimeExtension(entry)
        }
        return shouldExtend
    }

    final override fun cancelLifetimeExtension(entry: NotificationEntry) {
        if (debug) {
            Log.d(tag, "$name.cancelLifetimeExtension(key=${entry.key})" +
                    " isExtending=${isExtending(entry.key)}")
        }
        warnIfEnding()
        mEntriesExtended.remove(entry.key)
        onCanceledLifetimeExtension(entry)
    }

    abstract fun queryShouldExtendLifetime(entry: NotificationEntry): Boolean
    open fun onStartedLifetimeExtension(entry: NotificationEntry) {}
    open fun onCanceledLifetimeExtension(entry: NotificationEntry) {}

    final override fun setCallback(callback: NotifLifetimeExtender.OnEndLifetimeExtensionCallback) {
        mCallback = callback
    }

    final override fun dump(fd: FileDescriptor, pw: PrintWriter, args: Array<out String>) {
        pw.println("LifetimeExtender: $name:")
        pw.println("  mEntriesExtended: ${mEntriesExtended.size}")
        mEntriesExtended.forEach { pw.println("  * ${it.key}") }
    }
}
 No newline at end of file
Loading