Merge "DO NOT MERGE Revert "DO NOT MERGE Remove Privacy Indicators"" into qt-r1-dev
diff --git a/core/java/com/android/internal/config/sysui/SystemUiDeviceConfigFlags.java b/core/java/com/android/internal/config/sysui/SystemUiDeviceConfigFlags.java
index 9b3fb0b..f1a1d44 100644
--- a/core/java/com/android/internal/config/sysui/SystemUiDeviceConfigFlags.java
+++ b/core/java/com/android/internal/config/sysui/SystemUiDeviceConfigFlags.java
@@ -106,6 +106,13 @@
*/
public static final String HASH_SALT_MAX_DAYS = "hash_salt_max_days";
+ // Flag related to Privacy Indicators
+
+ /**
+ * Whether the Permissions Hub is showing.
+ */
+ public static final String PROPERTY_PERMISSIONS_HUB_ENABLED = "permissions_hub_enabled";
+
// Flags related to Assistant Handles
/**
diff --git a/packages/SystemUI/res/drawable/privacy_chip_bg.xml b/packages/SystemUI/res/drawable/privacy_chip_bg.xml
new file mode 100644
index 0000000..b7b21fa
--- /dev/null
+++ b/packages/SystemUI/res/drawable/privacy_chip_bg.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2018 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.
+-->
+
+<shape xmlns:android="http://schemas.android.com/apk/res/android">
+ <solid android:color="#242424" /> <!-- 14% of white -->
+ <padding android:paddingTop="@dimen/ongoing_appops_chip_bg_padding"
+ android:paddingBottom="@dimen/ongoing_appops_chip_bg_padding" />
+ <corners android:radius="@dimen/ongoing_appops_chip_bg_corner_radius" />
+</shape>
\ No newline at end of file
diff --git a/packages/SystemUI/res/layout/ongoing_privacy_chip.xml b/packages/SystemUI/res/layout/ongoing_privacy_chip.xml
new file mode 100644
index 0000000..dce9ce1
--- /dev/null
+++ b/packages/SystemUI/res/layout/ongoing_privacy_chip.xml
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2018 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.
+-->
+
+
+<com.android.systemui.privacy.OngoingPrivacyChip
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/privacy_chip"
+ android:layout_height="match_parent"
+ android:layout_width="wrap_content"
+ android:layout_gravity="center_vertical|end"
+ android:gravity="center_vertical"
+ android:orientation="horizontal"
+ android:focusable="true" >
+
+ <FrameLayout
+ android:id="@+id/background"
+ android:layout_height="@dimen/ongoing_appops_chip_height"
+ android:minWidth="48dp"
+ android:layout_width="wrap_content" >
+ <LinearLayout
+ android:id="@+id/icons_container"
+ android:layout_height="match_parent"
+ android:layout_width="wrap_content"
+ android:gravity="center_vertical"
+ />
+ </FrameLayout>
+</com.android.systemui.privacy.OngoingPrivacyChip>
\ No newline at end of file
diff --git a/packages/SystemUI/res/layout/ongoing_privacy_text_item.xml b/packages/SystemUI/res/layout/ongoing_privacy_text_item.xml
new file mode 100644
index 0000000..5595b13
--- /dev/null
+++ b/packages/SystemUI/res/layout/ongoing_privacy_text_item.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2018 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.
+-->
+
+<TextView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textDirection="locale"
+ android:textAppearance="@style/TextAppearance.QS.DetailItemPrimary"
+/>
\ No newline at end of file
diff --git a/packages/SystemUI/res/layout/quick_status_bar_header_system_icons.xml b/packages/SystemUI/res/layout/quick_status_bar_header_system_icons.xml
index 54fb2168..cd9f780 100644
--- a/packages/SystemUI/res/layout/quick_status_bar_header_system_icons.xml
+++ b/packages/SystemUI/res/layout/quick_status_bar_header_system_icons.xml
@@ -22,11 +22,19 @@
android:layout_height="@*android:dimen/quick_qs_offset_height"
android:clipChildren="false"
android:clipToPadding="false"
+ android:gravity="center"
android:orientation="horizontal"
android:clickable="true"
android:paddingStart="@dimen/status_bar_padding_start"
android:paddingEnd="@dimen/status_bar_padding_end" >
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_weight="1"
+ android:orientation="horizontal"
+ android:gravity="center_vertical|start" >
+
<com.android.systemui.statusbar.policy.Clock
android:id="@+id/clock"
android:layout_width="wrap_content"
@@ -38,4 +46,23 @@
android:singleLine="true"
android:textAppearance="@style/TextAppearance.StatusBar.Clock"
systemui:showDark="false" />
+ </LinearLayout>
+
+ <android.widget.Space
+ android:id="@+id/space"
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_gravity="center_vertical|center_horizontal"
+ android:visibility="gone" />
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_weight="1"
+ android:orientation="horizontal"
+ android:gravity="center_vertical|end" >
+
+ <include layout="@layout/ongoing_privacy_chip" />
+
+ </LinearLayout>
</LinearLayout>
diff --git a/packages/SystemUI/src/com/android/systemui/Dependency.java b/packages/SystemUI/src/com/android/systemui/Dependency.java
index 9f4a4e0..7a82402 100644
--- a/packages/SystemUI/src/com/android/systemui/Dependency.java
+++ b/packages/SystemUI/src/com/android/systemui/Dependency.java
@@ -48,6 +48,7 @@
import com.android.systemui.plugins.statusbar.StatusBarStateController;
import com.android.systemui.power.EnhancedEstimates;
import com.android.systemui.power.PowerUI;
+import com.android.systemui.privacy.PrivacyItemController;
import com.android.systemui.recents.OverviewProxyService;
import com.android.systemui.shared.plugins.PluginManager;
import com.android.systemui.shared.system.ActivityManagerWrapper;
@@ -286,6 +287,7 @@
@Inject Lazy<SensorPrivacyManager> mSensorPrivacyManager;
@Inject Lazy<AutoHideController> mAutoHideController;
@Inject Lazy<ForegroundServiceNotificationListener> mForegroundServiceNotificationListener;
+ @Inject Lazy<PrivacyItemController> mPrivacyItemController;
@Inject @Named(BG_LOOPER_NAME) Lazy<Looper> mBgLooper;
@Inject @Named(BG_HANDLER_NAME) Lazy<Handler> mBgHandler;
@Inject @Named(MAIN_HANDLER_NAME) Lazy<Handler> mMainHandler;
@@ -470,6 +472,7 @@
mProviders.put(ForegroundServiceNotificationListener.class,
mForegroundServiceNotificationListener::get);
mProviders.put(ClockManager.class, mClockManager::get);
+ mProviders.put(PrivacyItemController.class, mPrivacyItemController::get);
mProviders.put(ActivityManagerWrapper.class, mActivityManagerWrapper::get);
mProviders.put(DevicePolicyManagerWrapper.class, mDevicePolicyManagerWrapper::get);
mProviders.put(PackageManagerWrapper.class, mPackageManagerWrapper::get);
diff --git a/packages/SystemUI/src/com/android/systemui/appops/AppOpsControllerImpl.java b/packages/SystemUI/src/com/android/systemui/appops/AppOpsControllerImpl.java
index afb8e74..49bd5bd 100644
--- a/packages/SystemUI/src/com/android/systemui/appops/AppOpsControllerImpl.java
+++ b/packages/SystemUI/src/com/android/systemui/appops/AppOpsControllerImpl.java
@@ -20,6 +20,7 @@
import android.app.AppOpsManager;
import android.content.Context;
+import android.content.pm.PackageManager;
import android.os.Handler;
import android.os.Looper;
import android.os.UserHandle;
@@ -210,6 +211,59 @@
}
/**
+ * Does the app-op code refer to a user sensitive permission for the specified user id
+ * and package. Only user sensitive permission should be shown to the user by default.
+ *
+ * @param appOpCode The code of the app-op.
+ * @param uid The uid of the user.
+ * @param packageName The name of the package.
+ *
+ * @return {@code true} iff the app-op item is user sensitive
+ */
+ private boolean isUserSensitive(int appOpCode, int uid, String packageName) {
+ String permission = AppOpsManager.opToPermission(appOpCode);
+ if (permission == null) {
+ return false;
+ }
+ int permFlags = mContext.getPackageManager().getPermissionFlags(permission,
+ packageName, UserHandle.getUserHandleForUid(uid));
+ return (permFlags & PackageManager.FLAG_PERMISSION_USER_SENSITIVE_WHEN_GRANTED) != 0;
+ }
+
+ /**
+ * Does the app-op item refer to an operation that should be shown to the user.
+ * Only specficic ops (like SYSTEM_ALERT_WINDOW) or ops that refer to user sensitive
+ * permission should be shown to the user by default.
+ *
+ * @param item The item
+ *
+ * @return {@code true} iff the app-op item should be shown to the user
+ */
+ private boolean isUserVisible(AppOpItem item) {
+ return isUserVisible(item.getCode(), item.getUid(), item.getPackageName());
+ }
+
+
+ /**
+ * Does the app-op, uid and package name, refer to an operation that should be shown to the
+ * user. Only specficic ops (like {@link AppOpsManager.OP_SYSTEM_ALERT_WINDOW}) or
+ * ops that refer to user sensitive permission should be shown to the user by default.
+ *
+ * @param item The item
+ *
+ * @return {@code true} iff the app-op for should be shown to the user
+ */
+ private boolean isUserVisible(int appOpCode, int uid, String packageName) {
+ // currently OP_SYSTEM_ALERT_WINDOW does not correspond to a platform permission
+ // which may be user senstive, so for now always show it to the user.
+ if (appOpCode == AppOpsManager.OP_SYSTEM_ALERT_WINDOW) {
+ return true;
+ }
+
+ return isUserSensitive(appOpCode, uid, packageName);
+ }
+
+ /**
* Returns a copy of the list containing all the active AppOps that the controller tracks.
*
* @return List of active AppOps information
@@ -232,8 +286,8 @@
final int numActiveItems = mActiveItems.size();
for (int i = 0; i < numActiveItems; i++) {
AppOpItem item = mActiveItems.get(i);
- if ((userId == UserHandle.USER_ALL
- || UserHandle.getUserId(item.getUid()) == userId)) {
+ if ((userId == UserHandle.USER_ALL || UserHandle.getUserId(item.getUid()) == userId)
+ && isUserVisible(item)) {
list.add(item);
}
}
@@ -242,8 +296,8 @@
final int numNotedItems = mNotedItems.size();
for (int i = 0; i < numNotedItems; i++) {
AppOpItem item = mNotedItems.get(i);
- if ((userId == UserHandle.USER_ALL
- || UserHandle.getUserId(item.getUid()) == userId)) {
+ if ((userId == UserHandle.USER_ALL || UserHandle.getUserId(item.getUid()) == userId)
+ && isUserVisible(item)) {
list.add(item);
}
}
@@ -269,7 +323,8 @@
}
private void notifySuscribers(int code, int uid, String packageName, boolean active) {
- if (mCallbacksByCode.containsKey(code)) {
+ if (mCallbacksByCode.containsKey(code)
+ && isUserVisible(code, uid, packageName)) {
for (Callback cb: mCallbacksByCode.get(code)) {
cb.onActiveStateChanged(code, uid, packageName, active);
}
diff --git a/packages/SystemUI/src/com/android/systemui/privacy/OngoingPrivacyChip.kt b/packages/SystemUI/src/com/android/systemui/privacy/OngoingPrivacyChip.kt
new file mode 100644
index 0000000..a5a915b
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/privacy/OngoingPrivacyChip.kt
@@ -0,0 +1,110 @@
+/*
+ * Copyright (C) 2018 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.privacy
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.Gravity
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.LinearLayout
+import com.android.systemui.R
+
+class OngoingPrivacyChip @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttrs: Int = 0,
+ defStyleRes: Int = 0
+) : LinearLayout(context, attrs, defStyleAttrs, defStyleRes) {
+
+ private val iconMarginExpanded = context.resources.getDimensionPixelSize(
+ R.dimen.ongoing_appops_chip_icon_margin_expanded)
+ private val iconMarginCollapsed = context.resources.getDimensionPixelSize(
+ R.dimen.ongoing_appops_chip_icon_margin_collapsed)
+ private val iconSize =
+ context.resources.getDimensionPixelSize(R.dimen.ongoing_appops_chip_icon_size)
+ private val iconColor = context.resources.getColor(
+ R.color.status_bar_clock_color, context.theme)
+ private val sidePadding =
+ context.resources.getDimensionPixelSize(R.dimen.ongoing_appops_chip_side_padding)
+ private val backgroundDrawable = context.getDrawable(R.drawable.privacy_chip_bg)
+ private lateinit var iconsContainer: LinearLayout
+ private lateinit var back: FrameLayout
+ var expanded = false
+ set(value) {
+ if (value != field) {
+ field = value
+ updateView()
+ }
+ }
+
+ var builder = PrivacyDialogBuilder(context, emptyList<PrivacyItem>())
+ var privacyList = emptyList<PrivacyItem>()
+ set(value) {
+ field = value
+ builder = PrivacyDialogBuilder(context, value)
+ updateView()
+ }
+
+ override fun onFinishInflate() {
+ super.onFinishInflate()
+
+ back = findViewById(R.id.background)
+ iconsContainer = findViewById(R.id.icons_container)
+ }
+
+ // Should only be called if the builder icons or app changed
+ private fun updateView() {
+ back.background = if (expanded) backgroundDrawable else null
+ val padding = if (expanded) sidePadding else 0
+ back.setPaddingRelative(padding, 0, padding, 0)
+ fun setIcons(dialogBuilder: PrivacyDialogBuilder, iconsContainer: ViewGroup) {
+ iconsContainer.removeAllViews()
+ dialogBuilder.generateIcons().forEachIndexed { i, it ->
+ it.mutate()
+ it.setTint(iconColor)
+ val image = ImageView(context).apply {
+ setImageDrawable(it)
+ scaleType = ImageView.ScaleType.CENTER_INSIDE
+ }
+ iconsContainer.addView(image, iconSize, iconSize)
+ if (i != 0) {
+ val lp = image.layoutParams as MarginLayoutParams
+ lp.marginStart = if (expanded) iconMarginExpanded else iconMarginCollapsed
+ image.layoutParams = lp
+ }
+ }
+ }
+
+ if (!privacyList.isEmpty()) {
+ generateContentDescription()
+ setIcons(builder, iconsContainer)
+ val lp = iconsContainer.layoutParams as FrameLayout.LayoutParams
+ lp.gravity = Gravity.CENTER_VERTICAL or
+ (if (expanded) Gravity.CENTER_HORIZONTAL else Gravity.END)
+ iconsContainer.layoutParams = lp
+ } else {
+ iconsContainer.removeAllViews()
+ }
+ requestLayout()
+ }
+
+ private fun generateContentDescription() {
+ val typesText = builder.joinTypes()
+ contentDescription = context.getString(
+ R.string.ongoing_privacy_chip_content_multiple_apps, typesText)
+ }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/privacy/PrivacyDialogBuilder.kt b/packages/SystemUI/src/com/android/systemui/privacy/PrivacyDialogBuilder.kt
new file mode 100644
index 0000000..d08a373
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/privacy/PrivacyDialogBuilder.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2018 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.privacy
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import com.android.systemui.R
+
+class PrivacyDialogBuilder(private val context: Context, itemsList: List<PrivacyItem>) {
+
+ val appsAndTypes: List<Pair<PrivacyApplication, List<PrivacyType>>>
+ val types: List<PrivacyType>
+ private val separator = context.getString(R.string.ongoing_privacy_dialog_separator)
+ private val lastSeparator = context.getString(R.string.ongoing_privacy_dialog_last_separator)
+
+ init {
+ appsAndTypes = itemsList.groupBy({ it.application }, { it.privacyType })
+ .toList()
+ .sortedWith(compareBy({ -it.second.size }, // Sort by number of AppOps
+ { it.second.min() })) // Sort by "smallest" AppOpp (Location is largest)
+ types = itemsList.map { it.privacyType }.distinct().sorted()
+ }
+
+ fun generateIconsForApp(types: List<PrivacyType>): List<Drawable> {
+ return types.sorted().map { it.getIcon(context) }
+ }
+
+ fun generateIcons() = types.map { it.getIcon(context) }
+
+ private fun <T> List<T>.joinWithAnd(): StringBuilder {
+ return subList(0, size - 1).joinTo(StringBuilder(), separator = separator).apply {
+ append(lastSeparator)
+ append(this@joinWithAnd.last())
+ }
+ }
+
+ fun joinTypes(): String {
+ return when (types.size) {
+ 0 -> ""
+ 1 -> types[0].getName(context)
+ else -> types.map { it.getName(context) }.joinWithAnd().toString()
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/privacy/PrivacyItem.kt b/packages/SystemUI/src/com/android/systemui/privacy/PrivacyItem.kt
new file mode 100644
index 0000000..2909424
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/privacy/PrivacyItem.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2018 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.privacy
+
+import android.content.Context
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
+import android.graphics.drawable.Drawable
+import android.os.UserHandle
+import android.util.IconDrawableFactory
+import com.android.systemui.R
+
+typealias Privacy = PrivacyType
+
+enum class PrivacyType(private val nameId: Int, val iconId: Int) {
+ // This is uses the icons used by the corresponding permission groups in the AndroidManifest
+ TYPE_CAMERA(R.string.privacy_type_camera,
+ com.android.internal.R.drawable.perm_group_camera),
+ TYPE_MICROPHONE(R.string.privacy_type_microphone,
+ com.android.internal.R.drawable.perm_group_microphone),
+ TYPE_LOCATION(R.string.privacy_type_location,
+ com.android.internal.R.drawable.perm_group_location);
+
+ fun getName(context: Context) = context.resources.getString(nameId)
+
+ fun getIcon(context: Context) = context.resources.getDrawable(iconId, context.theme)
+}
+
+data class PrivacyItem(
+ val privacyType: PrivacyType,
+ val application: PrivacyApplication
+)
+
+data class PrivacyApplication(val packageName: String, val uid: Int, val context: Context)
+ : Comparable<PrivacyApplication> {
+
+ override fun compareTo(other: PrivacyApplication): Int {
+ return applicationName.compareTo(other.applicationName)
+ }
+
+ private val applicationInfo: ApplicationInfo? by lazy {
+ try {
+ val userHandle = UserHandle.getUserHandleForUid(uid)
+ context.createPackageContextAsUser(packageName, 0, userHandle).getPackageManager()
+ .getApplicationInfo(packageName, 0)
+ } catch (_: PackageManager.NameNotFoundException) {
+ null
+ }
+ }
+ val icon: Drawable by lazy {
+ applicationInfo?.let {
+ try {
+ val iconFactory = IconDrawableFactory.newInstance(context, true)
+ iconFactory.getBadgedIcon(it, UserHandle.getUserId(uid))
+ } catch (_: Exception) {
+ null
+ }
+ } ?: context.getDrawable(android.R.drawable.sym_def_app_icon)
+ }
+
+ val applicationName: String by lazy {
+ applicationInfo?.let {
+ context.packageManager.getApplicationLabel(it) as String
+ } ?: packageName
+ }
+
+ override fun toString() = "PrivacyApplication(packageName=$packageName, uid=$uid)"
+}
diff --git a/packages/SystemUI/src/com/android/systemui/privacy/PrivacyItemController.kt b/packages/SystemUI/src/com/android/systemui/privacy/PrivacyItemController.kt
new file mode 100644
index 0000000..82a2c1f
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/privacy/PrivacyItemController.kt
@@ -0,0 +1,294 @@
+/*
+ * Copyright (C) 2018 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.privacy
+
+import android.app.ActivityManager
+import android.app.AppOpsManager
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Handler
+import android.os.Looper
+import android.os.Message
+import android.os.UserHandle
+import android.os.UserManager
+import android.provider.DeviceConfig
+import com.android.internal.annotations.VisibleForTesting
+import com.android.internal.config.sysui.SystemUiDeviceConfigFlags
+import com.android.systemui.Dependency.BG_HANDLER_NAME
+import com.android.systemui.Dependency.MAIN_HANDLER_NAME
+import com.android.systemui.R
+import com.android.systemui.appops.AppOpItem
+import com.android.systemui.appops.AppOpsController
+import com.android.systemui.Dumpable
+import java.io.FileDescriptor
+import java.io.PrintWriter
+import java.lang.ref.WeakReference
+import javax.inject.Inject
+import javax.inject.Named
+import javax.inject.Singleton
+
+fun isPermissionsHubEnabled() = DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_PRIVACY,
+ SystemUiDeviceConfigFlags.PROPERTY_PERMISSIONS_HUB_ENABLED, false)
+
+@Singleton
+class PrivacyItemController @Inject constructor(
+ val context: Context,
+ private val appOpsController: AppOpsController,
+ @Named(MAIN_HANDLER_NAME) private val uiHandler: Handler,
+ @Named(BG_HANDLER_NAME) private val bgHandler: Handler
+) : Dumpable {
+
+ @VisibleForTesting
+ internal companion object {
+ val OPS = intArrayOf(AppOpsManager.OP_CAMERA,
+ AppOpsManager.OP_RECORD_AUDIO,
+ AppOpsManager.OP_COARSE_LOCATION,
+ AppOpsManager.OP_FINE_LOCATION)
+ val intents = listOf(Intent.ACTION_USER_FOREGROUND,
+ Intent.ACTION_MANAGED_PROFILE_ADDED,
+ Intent.ACTION_MANAGED_PROFILE_REMOVED)
+ const val TAG = "PrivacyItemController"
+ const val SYSTEM_UID = 1000
+ const val MSG_ADD_CALLBACK = 0
+ const val MSG_REMOVE_CALLBACK = 1
+ const val MSG_UPDATE_LISTENING_STATE = 2
+ }
+
+ @VisibleForTesting
+ internal var privacyList = emptyList<PrivacyItem>()
+ @Synchronized get() = field.toList() // Returns a shallow copy of the list
+ @Synchronized set
+
+ private val userManager = context.getSystemService(UserManager::class.java)
+ private var currentUserIds = emptyList<Int>()
+ private var listening = false
+ val systemApp =
+ PrivacyApplication(context.getString(R.string.device_services), SYSTEM_UID, context)
+ private val callbacks = mutableListOf<WeakReference<Callback>>()
+ private val messageHandler = H(WeakReference(this), uiHandler.looper)
+
+ private val notifyChanges = Runnable {
+ val list = privacyList
+ callbacks.forEach { it.get()?.privacyChanged(list) }
+ }
+
+ private val updateListAndNotifyChanges = Runnable {
+ updatePrivacyList()
+ uiHandler.post(notifyChanges)
+ }
+
+ private var indicatorsAvailable = isPermissionsHubEnabled()
+ @VisibleForTesting
+ internal val devicePropertyChangedListener =
+ object : DeviceConfig.OnPropertyChangedListener {
+ override fun onPropertyChanged(namespace: String, name: String, value: String?) {
+ if (DeviceConfig.NAMESPACE_PRIVACY.equals(namespace) &&
+ SystemUiDeviceConfigFlags.PROPERTY_PERMISSIONS_HUB_ENABLED.equals(name)) {
+ indicatorsAvailable = java.lang.Boolean.parseBoolean(value)
+ messageHandler.removeMessages(MSG_UPDATE_LISTENING_STATE)
+ messageHandler.sendEmptyMessage(MSG_UPDATE_LISTENING_STATE)
+ }
+ }
+ }
+
+ private val cb = object : AppOpsController.Callback {
+ override fun onActiveStateChanged(
+ code: Int,
+ uid: Int,
+ packageName: String,
+ active: Boolean
+ ) {
+ val userId = UserHandle.getUserId(uid)
+ if (userId in currentUserIds) {
+ update(false)
+ }
+ }
+ }
+
+ @VisibleForTesting
+ internal var userSwitcherReceiver = Receiver()
+ set(value) {
+ context.unregisterReceiver(field)
+ field = value
+ registerReceiver()
+ }
+
+ init {
+ DeviceConfig.addOnPropertyChangedListener(
+ DeviceConfig.NAMESPACE_PRIVACY, context.mainExecutor, devicePropertyChangedListener)
+ }
+
+ private fun unregisterReceiver() {
+ context.unregisterReceiver(userSwitcherReceiver)
+ }
+
+ private fun registerReceiver() {
+ context.registerReceiverAsUser(userSwitcherReceiver, UserHandle.ALL, IntentFilter().apply {
+ intents.forEach {
+ addAction(it)
+ }
+ }, null, null)
+ }
+
+ private fun update(updateUsers: Boolean) {
+ if (updateUsers) {
+ val currentUser = ActivityManager.getCurrentUser()
+ currentUserIds = userManager.getProfiles(currentUser).map { it.id }
+ }
+ bgHandler.post(updateListAndNotifyChanges)
+ }
+
+ /**
+ * Updates listening status based on whether there are callbacks and the indicators are enabled
+ *
+ * This is only called from private (add/remove)Callback and from the config listener, all in
+ * main thread.
+ */
+ private fun setListeningState() {
+ val listen = !callbacks.isEmpty() and indicatorsAvailable
+ if (listening == listen) return
+ listening = listen
+ if (listening) {
+ appOpsController.addCallback(OPS, cb)
+ registerReceiver()
+ update(true)
+ } else {
+ appOpsController.removeCallback(OPS, cb)
+ unregisterReceiver()
+ // Make sure that we remove all indicators and notify listeners if we are not
+ // listening anymore due to indicators being disabled
+ update(false)
+ }
+ }
+
+ private fun addCallback(callback: WeakReference<Callback>) {
+ callbacks.add(callback)
+ if (callbacks.isNotEmpty() && !listening) {
+ messageHandler.removeMessages(MSG_UPDATE_LISTENING_STATE)
+ messageHandler.sendEmptyMessage(MSG_UPDATE_LISTENING_STATE)
+ }
+ // Notify this callback if we didn't set to listening
+ else if (listening) uiHandler.post(NotifyChangesToCallback(callback.get(), privacyList))
+ }
+
+ private fun removeCallback(callback: WeakReference<Callback>) {
+ // Removes also if the callback is null
+ callbacks.removeIf { it.get()?.equals(callback.get()) ?: true }
+ if (callbacks.isEmpty()) {
+ messageHandler.removeMessages(MSG_UPDATE_LISTENING_STATE)
+ messageHandler.sendEmptyMessage(MSG_UPDATE_LISTENING_STATE)
+ }
+ }
+
+ fun addCallback(callback: Callback) {
+ messageHandler.obtainMessage(MSG_ADD_CALLBACK, callback).sendToTarget()
+ }
+
+ fun removeCallback(callback: Callback) {
+ messageHandler.obtainMessage(MSG_REMOVE_CALLBACK, callback).sendToTarget()
+ }
+
+ private fun updatePrivacyList() {
+ if (!listening) {
+ privacyList = emptyList()
+ return
+ }
+ val list = currentUserIds.flatMap { appOpsController.getActiveAppOpsForUser(it) }
+ .mapNotNull { toPrivacyItem(it) }.distinct()
+ privacyList = list
+ }
+
+ private fun toPrivacyItem(appOpItem: AppOpItem): PrivacyItem? {
+ val type: PrivacyType = when (appOpItem.code) {
+ AppOpsManager.OP_CAMERA -> PrivacyType.TYPE_CAMERA
+ AppOpsManager.OP_COARSE_LOCATION -> PrivacyType.TYPE_LOCATION
+ AppOpsManager.OP_FINE_LOCATION -> PrivacyType.TYPE_LOCATION
+ AppOpsManager.OP_RECORD_AUDIO -> PrivacyType.TYPE_MICROPHONE
+ else -> return null
+ }
+ if (appOpItem.uid == SYSTEM_UID) return PrivacyItem(type, systemApp)
+ val app = PrivacyApplication(appOpItem.packageName, appOpItem.uid, context)
+ return PrivacyItem(type, app)
+ }
+
+ // Used by containing class to get notified of changes
+ interface Callback {
+ fun privacyChanged(privacyItems: List<PrivacyItem>)
+ }
+
+ internal inner class Receiver : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action in intents) {
+ update(true)
+ }
+ }
+ }
+
+ private class NotifyChangesToCallback(
+ private val callback: Callback?,
+ private val list: List<PrivacyItem>
+ ) : Runnable {
+ override fun run() {
+ callback?.privacyChanged(list)
+ }
+ }
+
+ override fun dump(fd: FileDescriptor?, pw: PrintWriter?, args: Array<out String>?) {
+ pw?.println("PrivacyItemController state:")
+ pw?.println(" Listening: $listening")
+ pw?.println(" Current user ids: $currentUserIds")
+ pw?.println(" Privacy Items:")
+ privacyList.forEach {
+ pw?.print(" ")
+ pw?.println(it.toString())
+ }
+ pw?.println(" Callbacks:")
+ callbacks.forEach {
+ it.get()?.let {
+ pw?.print(" ")
+ pw?.println(it.toString())
+ }
+ }
+ }
+
+ private class H(
+ private val outerClass: WeakReference<PrivacyItemController>,
+ looper: Looper
+ ) : Handler(looper) {
+ override fun handleMessage(msg: Message) {
+ super.handleMessage(msg)
+ when (msg.what) {
+ MSG_UPDATE_LISTENING_STATE -> outerClass.get()?.setListeningState()
+
+ MSG_ADD_CALLBACK -> {
+ if (msg.obj !is PrivacyItemController.Callback) return
+ outerClass.get()?.addCallback(
+ WeakReference(msg.obj as PrivacyItemController.Callback))
+ }
+
+ MSG_REMOVE_CALLBACK -> {
+ if (msg.obj !is PrivacyItemController.Callback) return
+ outerClass.get()?.removeCallback(
+ WeakReference(msg.obj as PrivacyItemController.Callback))
+ }
+ else -> {}
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/qs/QuickStatusBarHeader.java b/packages/SystemUI/src/com/android/systemui/qs/QuickStatusBarHeader.java
index d59e251..410a13e 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/QuickStatusBarHeader.java
+++ b/packages/SystemUI/src/com/android/systemui/qs/QuickStatusBarHeader.java
@@ -32,24 +32,30 @@
import android.graphics.Rect;
import android.media.AudioManager;
import android.os.Handler;
+import android.os.Looper;
import android.provider.AlarmClock;
+import android.provider.DeviceConfig;
import android.provider.Settings;
import android.service.notification.ZenModeConfig;
import android.text.format.DateUtils;
import android.util.AttributeSet;
import android.util.Log;
import android.util.Pair;
+import android.util.StatsLog;
import android.view.ContextThemeWrapper;
import android.view.DisplayCutout;
import android.view.View;
import android.view.WindowInsets;
import android.widget.FrameLayout;
import android.widget.ImageView;
+import android.widget.LinearLayout;
import android.widget.RelativeLayout;
+import android.widget.Space;
import android.widget.TextView;
import androidx.annotation.VisibleForTesting;
+import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
import com.android.settingslib.Utils;
import com.android.systemui.BatteryMeterView;
import com.android.systemui.DualToneHandler;
@@ -57,6 +63,11 @@
import com.android.systemui.plugins.ActivityStarter;
import com.android.systemui.plugins.DarkIconDispatcher;
import com.android.systemui.plugins.DarkIconDispatcher.DarkReceiver;
+import com.android.systemui.privacy.OngoingPrivacyChip;
+import com.android.systemui.privacy.PrivacyDialogBuilder;
+import com.android.systemui.privacy.PrivacyItem;
+import com.android.systemui.privacy.PrivacyItemController;
+import com.android.systemui.privacy.PrivacyItemControllerKt;
import com.android.systemui.qs.QSDetail.Callback;
import com.android.systemui.statusbar.phone.PhoneStatusBarView;
import com.android.systemui.statusbar.phone.StatusBarIconController;
@@ -67,6 +78,8 @@
import com.android.systemui.statusbar.policy.NextAlarmController;
import com.android.systemui.statusbar.policy.ZenModeController;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Locale;
import java.util.Objects;
@@ -108,6 +121,7 @@
private TintedIconManager mIconManager;
private TouchAnimator mStatusIconsAlphaAnimator;
private TouchAnimator mHeaderTextContainerAlphaAnimator;
+ private TouchAnimator mPrivacyChipAlphaAnimator;
private DualToneHandler mDualToneHandler;
private View mSystemIconsView;
@@ -127,7 +141,12 @@
private View mRingerContainer;
private Clock mClockView;
private DateView mDateView;
+ private OngoingPrivacyChip mPrivacyChip;
+ private Space mSpace;
private BatteryMeterView mBatteryRemainingIcon;
+ private boolean mPermissionsHubEnabled;
+
+ private PrivacyItemController mPrivacyItemController;
private final BroadcastReceiver mRingerReceiver = new BroadcastReceiver() {
@Override
@@ -137,17 +156,41 @@
}
};
private boolean mHasTopCutout = false;
+ private boolean mPrivacyChipLogged = false;
+
+ private final DeviceConfig.OnPropertyChangedListener mPropertyListener =
+ new DeviceConfig.OnPropertyChangedListener() {
+ @Override
+ public void onPropertyChanged(String namespace, String name, String value) {
+ if (DeviceConfig.NAMESPACE_PRIVACY.equals(namespace)
+ && SystemUiDeviceConfigFlags.PROPERTY_PERMISSIONS_HUB_ENABLED.equals(
+ name)) {
+ mPermissionsHubEnabled = Boolean.valueOf(value);
+ StatusIconContainer iconContainer = findViewById(R.id.statusIcons);
+ iconContainer.setIgnoredSlots(getIgnoredIconSlots());
+ }
+ }
+ };
+
+ private PrivacyItemController.Callback mPICCallback = new PrivacyItemController.Callback() {
+ @Override
+ public void privacyChanged(List<PrivacyItem> privacyItems) {
+ mPrivacyChip.setPrivacyList(privacyItems);
+ setChipVisibility(!privacyItems.isEmpty());
+ }
+ };
@Inject
public QuickStatusBarHeader(@Named(VIEW_CONTEXT) Context context, AttributeSet attrs,
NextAlarmController nextAlarmController, ZenModeController zenModeController,
StatusBarIconController statusBarIconController,
- ActivityStarter activityStarter) {
+ ActivityStarter activityStarter, PrivacyItemController privacyItemController) {
super(context, attrs);
mAlarmController = nextAlarmController;
mZenController = zenModeController;
mStatusBarIconController = statusBarIconController;
mActivityStarter = activityStarter;
+ mPrivacyItemController = privacyItemController;
mDualToneHandler = new DualToneHandler(
new ContextThemeWrapper(context, R.style.QSHeaderTheme));
}
@@ -160,6 +203,8 @@
mSystemIconsView = findViewById(R.id.quick_status_bar_system_icons);
mQuickQsStatusIcons = findViewById(R.id.quick_qs_status_icons);
StatusIconContainer iconContainer = findViewById(R.id.statusIcons);
+ // Ignore privacy icons because they show in the space above QQS
+ iconContainer.addIgnoredSlots(getIgnoredIconSlots());
iconContainer.setShouldRestrictIcons(false);
mIconManager = new TintedIconManager(iconContainer);
@@ -173,6 +218,9 @@
mRingerModeIcon = findViewById(R.id.ringer_mode_icon);
mRingerModeTextView = findViewById(R.id.ringer_mode_text);
mRingerContainer = findViewById(R.id.ringer_container);
+ mRingerContainer.setOnClickListener(this::onClick);
+ mPrivacyChip = findViewById(R.id.privacy_chip);
+ mPrivacyChip.setOnClickListener(this::onClick);
mCarrierGroup = findViewById(R.id.carrier_group);
@@ -195,6 +243,7 @@
mClockView = findViewById(R.id.clock);
mClockView.setOnClickListener(this);
mDateView = findViewById(R.id.date);
+ mSpace = findViewById(R.id.space);
// Tint for the battery icons are handled in setupHost()
mBatteryRemainingIcon = findViewById(R.id.batteryRemainingIcon);
@@ -205,6 +254,26 @@
mBatteryRemainingIcon.setPercentShowMode(BatteryMeterView.MODE_ESTIMATE);
mRingerModeTextView.setSelected(true);
mNextAlarmTextView.setSelected(true);
+
+ mPermissionsHubEnabled = PrivacyItemControllerKt.isPermissionsHubEnabled();
+ // Change the ignored slots when DeviceConfig flag changes
+ DeviceConfig.addOnPropertyChangedListener(DeviceConfig.NAMESPACE_PRIVACY,
+ mContext.getMainExecutor(), mPropertyListener);
+
+ }
+
+ private List<String> getIgnoredIconSlots() {
+ ArrayList<String> ignored = new ArrayList<>();
+ ignored.add(mContext.getResources().getString(
+ com.android.internal.R.string.status_bar_camera));
+ ignored.add(mContext.getResources().getString(
+ com.android.internal.R.string.status_bar_microphone));
+ if (mPermissionsHubEnabled) {
+ ignored.add(mContext.getResources().getString(
+ com.android.internal.R.string.status_bar_location));
+ }
+
+ return ignored;
}
private void updateStatusText() {
@@ -218,6 +287,21 @@
}
}
+ private void setChipVisibility(boolean chipVisible) {
+ if (chipVisible && mPermissionsHubEnabled) {
+ mPrivacyChip.setVisibility(View.VISIBLE);
+ // Makes sure that the chip is logged as viewed at most once each time QS is opened
+ // mListening makes sure that the callback didn't return after the user closed QS
+ if (!mPrivacyChipLogged && mListening) {
+ mPrivacyChipLogged = true;
+ StatsLog.write(StatsLog.PRIVACY_INDICATORS_INTERACTED,
+ StatsLog.PRIVACY_INDICATORS_INTERACTED__TYPE__CHIP_VIEWED);
+ }
+ } else {
+ mPrivacyChip.setVisibility(View.GONE);
+ }
+ }
+
private boolean updateRingerStatus() {
boolean isOriginalVisible = mRingerModeTextView.getVisibility() == View.VISIBLE;
CharSequence originalRingerText = mRingerModeTextView.getText();
@@ -324,6 +408,7 @@
updateStatusIconAlphaAnimator();
updateHeaderTextContainerAlphaAnimator();
+ updatePrivacyChipAlphaAnimator();
}
private void updateStatusIconAlphaAnimator() {
@@ -338,6 +423,12 @@
.build();
}
+ private void updatePrivacyChipAlphaAnimator() {
+ mPrivacyChipAlphaAnimator = new TouchAnimator.Builder()
+ .addFloat(mPrivacyChip, "alpha", 1, 0, 1)
+ .build();
+ }
+
public void setExpanded(boolean expanded) {
if (mExpanded == expanded) return;
mExpanded = expanded;
@@ -376,6 +467,10 @@
mHeaderTextContainerView.setVisibility(INVISIBLE);
}
}
+ if (mPrivacyChipAlphaAnimator != null) {
+ mPrivacyChip.setExpanded(expansionFraction > 0.5);
+ mPrivacyChipAlphaAnimator.setPosition(keyguardExpansionFraction);
+ }
}
public void disable(int state1, int state2, boolean animate) {
@@ -408,6 +503,21 @@
mSystemIconsView.setPadding(padding.first, 0, padding.second, 0);
}
+ LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) mSpace.getLayoutParams();
+ if (cutout != null) {
+ Rect topCutout = cutout.getBoundingRectTop();
+ if (topCutout.isEmpty()) {
+ mHasTopCutout = false;
+ lp.width = 0;
+ mSpace.setVisibility(View.GONE);
+ } else {
+ mHasTopCutout = true;
+ lp.width = topCutout.width();
+ mSpace.setVisibility(View.VISIBLE);
+ }
+ }
+ mSpace.setLayoutParams(lp);
+ setChipVisibility(mPrivacyChip.getVisibility() == View.VISIBLE);
return super.onApplyWindowInsets(insets);
}
@@ -432,10 +542,13 @@
mAlarmController.addCallback(this);
mContext.registerReceiver(mRingerReceiver,
new IntentFilter(AudioManager.INTERNAL_RINGER_MODE_CHANGED_ACTION));
+ mPrivacyItemController.addCallback(mPICCallback);
} else {
mZenController.removeCallback(this);
mAlarmController.removeCallback(this);
+ mPrivacyItemController.removeCallback(mPICCallback);
mContext.unregisterReceiver(mRingerReceiver);
+ mPrivacyChipLogged = false;
}
}
@@ -453,6 +566,18 @@
mActivityStarter.postStartActivityDismissingKeyguard(new Intent(
AlarmClock.ACTION_SHOW_ALARMS), 0);
}
+ } else if (v == mPrivacyChip) {
+ // Makes sure that the builder is grabbed as soon as the chip is pressed
+ PrivacyDialogBuilder builder = mPrivacyChip.getBuilder();
+ if (builder.getAppsAndTypes().size() == 0) return;
+ Handler mUiHandler = new Handler(Looper.getMainLooper());
+ StatsLog.write(StatsLog.PRIVACY_INDICATORS_INTERACTED,
+ StatsLog.PRIVACY_INDICATORS_INTERACTED__TYPE__CHIP_CLICKED);
+ mUiHandler.post(() -> {
+ mActivityStarter.postStartActivityDismissingKeyguard(
+ new Intent(Intent.ACTION_REVIEW_ONGOING_PERMISSION_USAGE), 0);
+ mHost.collapsePanels();
+ });
} else if (v == mRingerContainer && mRingerContainer.isVisibleToUser()) {
mActivityStarter.postStartActivityDismissingKeyguard(new Intent(
Settings.ACTION_SOUND_SETTINGS), 0);
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/PhoneStatusBarPolicy.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/PhoneStatusBarPolicy.java
index 17c200e..ee438795 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/PhoneStatusBarPolicy.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/PhoneStatusBarPolicy.java
@@ -42,6 +42,10 @@
import com.android.systemui.R;
import com.android.systemui.SysUiServiceProvider;
import com.android.systemui.UiOffloadThread;
+import com.android.systemui.privacy.PrivacyItem;
+import com.android.systemui.privacy.PrivacyItemController;
+import com.android.systemui.privacy.PrivacyItemControllerKt;
+import com.android.systemui.privacy.PrivacyType;
import com.android.systemui.qs.tiles.DndTile;
import com.android.systemui.qs.tiles.RotationLockTile;
import com.android.systemui.statusbar.CommandQueue;
@@ -62,6 +66,9 @@
import com.android.systemui.statusbar.policy.UserInfoController;
import com.android.systemui.statusbar.policy.ZenModeController;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.List;
import java.util.Locale;
/**
@@ -76,12 +83,12 @@
ZenModeController.Callback,
DeviceProvisionedListener,
KeyguardMonitor.Callback,
+ PrivacyItemController.Callback,
LocationController.LocationChangeCallback {
private static final String TAG = "PhoneStatusBarPolicy";
private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
- public static final int LOCATION_STATUS_ICON_ID =
- com.android.internal.R.drawable.perm_group_location;
+ public static final int LOCATION_STATUS_ICON_ID = PrivacyType.TYPE_LOCATION.getIconId();
private final String mSlotCast;
private final String mSlotHotspot;
@@ -95,6 +102,8 @@
private final String mSlotHeadset;
private final String mSlotDataSaver;
private final String mSlotLocation;
+ private final String mSlotMicrophone;
+ private final String mSlotCamera;
private final String mSlotSensorsOff;
private final Context mContext;
@@ -112,6 +121,7 @@
private final DeviceProvisionedController mProvisionedController;
private final KeyguardMonitor mKeyguardMonitor;
private final LocationController mLocationController;
+ private final PrivacyItemController mPrivacyItemController;
private final UiOffloadThread mUiOffloadThread = Dependency.get(UiOffloadThread.class);
private final SensorPrivacyController mSensorPrivacyController;
@@ -144,6 +154,7 @@
mProvisionedController = Dependency.get(DeviceProvisionedController.class);
mKeyguardMonitor = Dependency.get(KeyguardMonitor.class);
mLocationController = Dependency.get(LocationController.class);
+ mPrivacyItemController = Dependency.get(PrivacyItemController.class);
mSensorPrivacyController = Dependency.get(SensorPrivacyController.class);
mSlotCast = context.getString(com.android.internal.R.string.status_bar_cast);
@@ -159,6 +170,8 @@
mSlotHeadset = context.getString(com.android.internal.R.string.status_bar_headset);
mSlotDataSaver = context.getString(com.android.internal.R.string.status_bar_data_saver);
mSlotLocation = context.getString(com.android.internal.R.string.status_bar_location);
+ mSlotMicrophone = context.getString(com.android.internal.R.string.status_bar_microphone);
+ mSlotCamera = context.getString(com.android.internal.R.string.status_bar_camera);
mSlotSensorsOff = context.getString(com.android.internal.R.string.status_bar_sensors_off);
// listen for broadcasts
@@ -218,6 +231,13 @@
context.getString(R.string.accessibility_data_saver_on));
mIconController.setIconVisibility(mSlotDataSaver, false);
+ // privacy items
+ mIconController.setIcon(mSlotMicrophone, PrivacyType.TYPE_MICROPHONE.getIconId(),
+ PrivacyType.TYPE_MICROPHONE.getName(mContext));
+ mIconController.setIconVisibility(mSlotMicrophone, false);
+ mIconController.setIcon(mSlotCamera, PrivacyType.TYPE_CAMERA.getIconId(),
+ PrivacyType.TYPE_CAMERA.getName(mContext));
+ mIconController.setIconVisibility(mSlotCamera, false);
mIconController.setIcon(mSlotLocation, LOCATION_STATUS_ICON_ID,
mContext.getString(R.string.accessibility_location_active));
mIconController.setIconVisibility(mSlotLocation, false);
@@ -237,6 +257,7 @@
mNextAlarmController.addCallback(mNextAlarmCallback);
mDataSaver.addCallback(this);
mKeyguardMonitor.addCallback(this);
+ mPrivacyItemController.addCallback(this);
mSensorPrivacyController.addCallback(mSensorPrivacyListener);
mLocationController.addCallback(this);
@@ -580,9 +601,46 @@
mIconController.setIconVisibility(mSlotDataSaver, isDataSaving);
}
+ @Override // PrivacyItemController.Callback
+ public void privacyChanged(List<PrivacyItem> privacyItems) {
+ updatePrivacyItems(privacyItems);
+ }
+
+ private void updatePrivacyItems(List<PrivacyItem> items) {
+ boolean showCamera = false;
+ boolean showMicrophone = false;
+ boolean showLocation = false;
+ for (PrivacyItem item : items) {
+ if (item == null /* b/124234367 */) {
+ if (DEBUG) {
+ Log.e(TAG, "updatePrivacyItems - null item found");
+ StringWriter out = new StringWriter();
+ mPrivacyItemController.dump(null, new PrintWriter(out), null);
+ Log.e(TAG, out.toString());
+ }
+ continue;
+ }
+ switch (item.getPrivacyType()) {
+ case TYPE_CAMERA:
+ showCamera = true;
+ break;
+ case TYPE_LOCATION:
+ showLocation = true;
+ break;
+ case TYPE_MICROPHONE:
+ showMicrophone = true;
+ break;
+ }
+ }
+
+ mIconController.setIconVisibility(mSlotCamera, showCamera);
+ mIconController.setIconVisibility(mSlotMicrophone, showMicrophone);
+ mIconController.setIconVisibility(mSlotLocation, showLocation);
+ }
+
@Override
public void onLocationActiveChanged(boolean active) {
- updateLocation();
+ if (!PrivacyItemControllerKt.isPermissionsHubEnabled()) updateLocation();
}
// Updates the status view based on the current state of location requests.
diff --git a/packages/SystemUI/tests/src/com/android/systemui/appops/AppOpsControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/appops/AppOpsControllerTest.java
index bd7f897..cc31531 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/appops/AppOpsControllerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/appops/AppOpsControllerTest.java
@@ -25,9 +25,11 @@
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
import android.app.AppOpsManager;
import android.content.pm.PackageManager;
@@ -53,6 +55,7 @@
private static final String TEST_PACKAGE_NAME = "test";
private static final int TEST_UID = UserHandle.getUid(0, 0);
private static final int TEST_UID_OTHER = UserHandle.getUid(1, 0);
+ private static final int TEST_UID_NON_USER_SENSITIVE = UserHandle.getUid(2, 0);
@Mock
private AppOpsManager mAppOpsManager;
@@ -71,6 +74,18 @@
getContext().addMockSystemService(AppOpsManager.class, mAppOpsManager);
+ // All permissions of TEST_UID and TEST_UID_OTHER are user sensitive. None of
+ // TEST_UID_NON_USER_SENSITIVE are user sensitive.
+ getContext().setMockPackageManager(mPackageManager);
+ when(mPackageManager.getPermissionFlags(anyString(), anyString(),
+ eq(UserHandle.getUserHandleForUid(TEST_UID)))).thenReturn(
+ PackageManager.FLAG_PERMISSION_USER_SENSITIVE_WHEN_GRANTED);
+ when(mPackageManager.getPermissionFlags(anyString(), anyString(),
+ eq(UserHandle.getUserHandleForUid(TEST_UID_OTHER)))).thenReturn(
+ PackageManager.FLAG_PERMISSION_USER_SENSITIVE_WHEN_GRANTED);
+ when(mPackageManager.getPermissionFlags(anyString(), anyString(),
+ eq(UserHandle.getUserHandleForUid(TEST_UID_NON_USER_SENSITIVE)))).thenReturn(0);
+
mController = new AppOpsControllerImpl(mContext, Dependency.get(Dependency.BG_LOOPER));
}
@@ -163,6 +178,14 @@
}
@Test
+ public void nonUserSensitiveOpsAreIgnored() {
+ mController.onOpActiveChanged(AppOpsManager.OP_RECORD_AUDIO,
+ TEST_UID_NON_USER_SENSITIVE, TEST_PACKAGE_NAME, true);
+ assertEquals(0, mController.getActiveAppOpsForUser(
+ UserHandle.getUserId(TEST_UID_NON_USER_SENSITIVE)).size());
+ }
+
+ @Test
public void opNotedScheduledForRemoval() {
mController.setBGHandler(mMockHandler);
mController.onOpNoted(AppOpsManager.OP_FINE_LOCATION, TEST_UID, TEST_PACKAGE_NAME,
diff --git a/packages/SystemUI/tests/src/com/android/systemui/privacy/PrivacyDialogBuilderTest.kt b/packages/SystemUI/tests/src/com/android/systemui/privacy/PrivacyDialogBuilderTest.kt
new file mode 100644
index 0000000..6302f9d
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/privacy/PrivacyDialogBuilderTest.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2018 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.privacy
+
+import androidx.test.filters.SmallTest
+import androidx.test.runner.AndroidJUnit4
+import com.android.systemui.SysuiTestCase
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class PrivacyDialogBuilderTest : SysuiTestCase() {
+
+ companion object {
+ val TEST_UID = 1
+ }
+
+ @Test
+ fun testGenerateAppsList() {
+ val bar2 = PrivacyItem(Privacy.TYPE_CAMERA, PrivacyApplication(
+ "Bar", TEST_UID, context))
+ val bar3 = PrivacyItem(Privacy.TYPE_LOCATION, PrivacyApplication(
+ "Bar", TEST_UID, context))
+ val foo0 = PrivacyItem(Privacy.TYPE_MICROPHONE, PrivacyApplication(
+ "Foo", TEST_UID, context))
+ val baz1 = PrivacyItem(Privacy.TYPE_CAMERA, PrivacyApplication(
+ "Baz", TEST_UID, context))
+
+ val items = listOf(bar2, foo0, baz1, bar3)
+
+ val textBuilder = PrivacyDialogBuilder(context, items)
+
+ val list = textBuilder.appsAndTypes
+ assertEquals(3, list.size)
+ val appsList = list.map { it.first }
+ val typesList = list.map { it.second }
+ // List is sorted by number of types and then by types
+ assertEquals(listOf("Bar", "Baz", "Foo"), appsList.map { it.packageName })
+ assertEquals(listOf(Privacy.TYPE_CAMERA, Privacy.TYPE_LOCATION), typesList[0])
+ assertEquals(listOf(Privacy.TYPE_CAMERA), typesList[1])
+ assertEquals(listOf(Privacy.TYPE_MICROPHONE), typesList[2])
+ }
+
+ @Test
+ fun testOrder() {
+ // We want location to always go last, so it will go in the "+ other apps"
+ val appCamera = PrivacyItem(PrivacyType.TYPE_CAMERA,
+ PrivacyApplication("Camera", TEST_UID, context))
+ val appMicrophone =
+ PrivacyItem(PrivacyType.TYPE_MICROPHONE,
+ PrivacyApplication("Microphone", TEST_UID, context))
+ val appLocation =
+ PrivacyItem(PrivacyType.TYPE_LOCATION,
+ PrivacyApplication("Location", TEST_UID, context))
+
+ val items = listOf(appLocation, appMicrophone, appCamera)
+ val textBuilder = PrivacyDialogBuilder(context, items)
+ val appList = textBuilder.appsAndTypes.map { it.first }.map { it.packageName }
+ assertEquals(listOf("Camera", "Microphone", "Location"), appList)
+ }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/tests/src/com/android/systemui/privacy/PrivacyItemControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/privacy/PrivacyItemControllerTest.kt
new file mode 100644
index 0000000..e2e0bb1
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/privacy/PrivacyItemControllerTest.kt
@@ -0,0 +1,293 @@
+/*
+ * Copyright (C) 2018 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.privacy
+
+import android.app.ActivityManager
+import android.app.AppOpsManager
+import android.content.Context
+import android.content.Intent
+import android.content.pm.UserInfo
+import android.os.Handler
+import android.os.UserHandle
+import android.os.UserManager
+import android.provider.DeviceConfig
+import android.provider.Settings.RESET_MODE_PACKAGE_DEFAULTS
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import android.testing.TestableLooper.RunWithLooper
+import androidx.test.filters.SmallTest
+import com.android.internal.config.sysui.SystemUiDeviceConfigFlags
+import com.android.systemui.Dependency
+import com.android.systemui.R
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.appops.AppOpItem
+import com.android.systemui.appops.AppOpsController
+import org.hamcrest.Matchers.hasItem
+import org.hamcrest.Matchers.not
+import org.hamcrest.Matchers.nullValue
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThat
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.ArgumentMatchers.any
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.ArgumentMatchers.anyList
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.Captor
+import org.mockito.Mock
+import org.mockito.Mockito.atLeastOnce
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.reset
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoMoreInteractions
+import org.mockito.MockitoAnnotations
+
+@RunWith(AndroidTestingRunner::class)
+@SmallTest
+@RunWithLooper
+class PrivacyItemControllerTest : SysuiTestCase() {
+
+ companion object {
+ val CURRENT_USER_ID = ActivityManager.getCurrentUser()
+ val TEST_UID = CURRENT_USER_ID * UserHandle.PER_USER_RANGE
+ const val SYSTEM_UID = 1000
+ const val TEST_PACKAGE_NAME = "test"
+ const val DEVICE_SERVICES_STRING = "Device services"
+ const val TAG = "PrivacyItemControllerTest"
+ fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture()
+ }
+
+ @Mock
+ private lateinit var appOpsController: AppOpsController
+ @Mock
+ private lateinit var callback: PrivacyItemController.Callback
+ @Mock
+ private lateinit var userManager: UserManager
+ @Captor
+ private lateinit var argCaptor: ArgumentCaptor<List<PrivacyItem>>
+ @Captor
+ private lateinit var argCaptorCallback: ArgumentCaptor<AppOpsController.Callback>
+
+ private lateinit var testableLooper: TestableLooper
+ private lateinit var privacyItemController: PrivacyItemController
+ private lateinit var handler: Handler
+
+ fun PrivacyItemController(context: Context) =
+ PrivacyItemController(context, appOpsController, handler, handler)
+
+ @Before
+ fun setup() {
+ MockitoAnnotations.initMocks(this)
+ testableLooper = TestableLooper.get(this)
+ handler = Handler(testableLooper.looper)
+
+ appOpsController = mDependency.injectMockDependency(AppOpsController::class.java)
+ mDependency.injectTestDependency(Dependency.BG_HANDLER, handler)
+ mDependency.injectTestDependency(Dependency.MAIN_HANDLER, handler)
+ mContext.addMockSystemService(UserManager::class.java, userManager)
+ mContext.getOrCreateTestableResources().addOverride(R.string.device_services,
+ DEVICE_SERVICES_STRING)
+ DeviceConfig.setProperty(DeviceConfig.NAMESPACE_PRIVACY,
+ SystemUiDeviceConfigFlags.PROPERTY_PERMISSIONS_HUB_ENABLED,
+ "true", false)
+
+ doReturn(listOf(object : UserInfo() {
+ init {
+ id = CURRENT_USER_ID
+ }
+ })).`when`(userManager).getProfiles(anyInt())
+
+ privacyItemController = PrivacyItemController(mContext)
+ }
+
+ @After
+ fun tearDown() {
+ DeviceConfig.resetToDefaults(RESET_MODE_PACKAGE_DEFAULTS, DeviceConfig.NAMESPACE_PRIVACY)
+ }
+
+ @Test
+ fun testSetListeningTrueByAddingCallback() {
+ privacyItemController.addCallback(callback)
+ testableLooper.processAllMessages()
+ verify(appOpsController).addCallback(eq(PrivacyItemController.OPS),
+ any(AppOpsController.Callback::class.java))
+ testableLooper.processAllMessages()
+ verify(callback).privacyChanged(anyList())
+ }
+
+ @Test
+ fun testSetListeningFalseByRemovingLastCallback() {
+ privacyItemController.addCallback(callback)
+ testableLooper.processAllMessages()
+ verify(appOpsController, never()).removeCallback(any(IntArray::class.java),
+ any(AppOpsController.Callback::class.java))
+ privacyItemController.removeCallback(callback)
+ testableLooper.processAllMessages()
+ verify(appOpsController).removeCallback(eq(PrivacyItemController.OPS),
+ any(AppOpsController.Callback::class.java))
+ verify(callback).privacyChanged(emptyList())
+ }
+
+ @Test
+ fun testDistinctItems() {
+ doReturn(listOf(AppOpItem(AppOpsManager.OP_CAMERA, TEST_UID, "", 0),
+ AppOpItem(AppOpsManager.OP_CAMERA, TEST_UID, "", 1)))
+ .`when`(appOpsController).getActiveAppOpsForUser(anyInt())
+
+ privacyItemController.addCallback(callback)
+ testableLooper.processAllMessages()
+ verify(callback).privacyChanged(capture(argCaptor))
+ assertEquals(1, argCaptor.value.size)
+ }
+
+ @Test
+ fun testSystemApps() {
+ doReturn(listOf(AppOpItem(AppOpsManager.OP_COARSE_LOCATION, SYSTEM_UID, TEST_PACKAGE_NAME,
+ 0))).`when`(appOpsController).getActiveAppOpsForUser(anyInt())
+ privacyItemController.addCallback(callback)
+ testableLooper.processAllMessages()
+ verify(callback).privacyChanged(capture(argCaptor))
+ assertEquals(1, argCaptor.value.size)
+ assertEquals(context.getString(R.string.device_services),
+ argCaptor.value[0].application.applicationName)
+ }
+
+ @Test
+ fun testRegisterReceiver_allUsers() {
+ val spiedContext = spy(mContext)
+ val itemController = PrivacyItemController(spiedContext)
+ itemController.addCallback(callback)
+ testableLooper.processAllMessages()
+ verify(spiedContext, atLeastOnce()).registerReceiverAsUser(
+ eq(itemController.userSwitcherReceiver), eq(UserHandle.ALL), any(), eq(null),
+ eq(null))
+ verify(spiedContext, never()).unregisterReceiver(eq(itemController.userSwitcherReceiver))
+ }
+
+ @Test
+ fun testReceiver_ACTION_USER_FOREGROUND() {
+ privacyItemController.userSwitcherReceiver.onReceive(context,
+ Intent(Intent.ACTION_USER_FOREGROUND))
+ verify(userManager).getProfiles(anyInt())
+ }
+
+ @Test
+ fun testReceiver_ACTION_MANAGED_PROFILE_ADDED() {
+ privacyItemController.userSwitcherReceiver.onReceive(context,
+ Intent(Intent.ACTION_MANAGED_PROFILE_ADDED))
+ verify(userManager).getProfiles(anyInt())
+ }
+
+ @Test
+ fun testReceiver_ACTION_MANAGED_PROFILE_REMOVED() {
+ privacyItemController.userSwitcherReceiver.onReceive(context,
+ Intent(Intent.ACTION_MANAGED_PROFILE_REMOVED))
+ verify(userManager).getProfiles(anyInt())
+ }
+
+ @Test
+ fun testAddMultipleCallbacks() {
+ val otherCallback = mock(PrivacyItemController.Callback::class.java)
+ privacyItemController.addCallback(callback)
+ testableLooper.processAllMessages()
+ verify(callback).privacyChanged(anyList())
+
+ privacyItemController.addCallback(otherCallback)
+ testableLooper.processAllMessages()
+ verify(otherCallback).privacyChanged(anyList())
+ // Adding a callback should not unnecessarily call previous ones
+ verifyNoMoreInteractions(callback)
+ }
+
+ @Test
+ fun testMultipleCallbacksAreUpdated() {
+ doReturn(emptyList<AppOpItem>()).`when`(appOpsController).getActiveAppOpsForUser(anyInt())
+
+ val otherCallback = mock(PrivacyItemController.Callback::class.java)
+ privacyItemController.addCallback(callback)
+ privacyItemController.addCallback(otherCallback)
+ testableLooper.processAllMessages()
+ reset(callback)
+ reset(otherCallback)
+
+ verify(appOpsController).addCallback(any<IntArray>(), capture(argCaptorCallback))
+ argCaptorCallback.value.onActiveStateChanged(0, TEST_UID, "", true)
+ testableLooper.processAllMessages()
+ verify(callback).privacyChanged(anyList())
+ verify(otherCallback).privacyChanged(anyList())
+ }
+
+ @Test
+ fun testRemoveCallback() {
+ doReturn(emptyList<AppOpItem>()).`when`(appOpsController).getActiveAppOpsForUser(anyInt())
+ val otherCallback = mock(PrivacyItemController.Callback::class.java)
+ privacyItemController.addCallback(callback)
+ privacyItemController.addCallback(otherCallback)
+ testableLooper.processAllMessages()
+ reset(callback)
+ reset(otherCallback)
+
+ verify(appOpsController).addCallback(any<IntArray>(), capture(argCaptorCallback))
+ privacyItemController.removeCallback(callback)
+ argCaptorCallback.value.onActiveStateChanged(0, TEST_UID, "", true)
+ testableLooper.processAllMessages()
+ verify(callback, never()).privacyChanged(anyList())
+ verify(otherCallback).privacyChanged(anyList())
+ }
+
+ @Test
+ fun testListShouldNotHaveNull() {
+ doReturn(listOf(AppOpItem(AppOpsManager.OP_ACTIVATE_VPN, TEST_UID, "", 0),
+ AppOpItem(AppOpsManager.OP_COARSE_LOCATION, TEST_UID, "", 0)))
+ .`when`(appOpsController).getActiveAppOpsForUser(anyInt())
+ privacyItemController.addCallback(callback)
+ testableLooper.processAllMessages()
+
+ verify(callback).privacyChanged(capture(argCaptor))
+ assertEquals(1, argCaptor.value.size)
+ assertThat(argCaptor.value, not(hasItem(nullValue())))
+ }
+
+ @Test
+ fun testListShouldBeCopy() {
+ val list = listOf(PrivacyItem(PrivacyType.TYPE_CAMERA,
+ PrivacyApplication("", TEST_UID, mContext)))
+ privacyItemController.privacyList = list
+ val privacyList = privacyItemController.privacyList
+ assertEquals(list, privacyList)
+ assertTrue(list !== privacyList)
+ }
+
+ @Test
+ fun testNotListeningWhenIndicatorsDisabled() {
+ privacyItemController.devicePropertyChangedListener.onPropertyChanged(
+ DeviceConfig.NAMESPACE_PRIVACY,
+ SystemUiDeviceConfigFlags.PROPERTY_PERMISSIONS_HUB_ENABLED,
+ "false")
+ privacyItemController.addCallback(callback)
+ testableLooper.processAllMessages()
+ verify(appOpsController, never()).addCallback(eq(PrivacyItemController.OPS),
+ any(AppOpsController.Callback::class.java))
+ }
+}
\ No newline at end of file