diff --git a/AndroidManifest.xml b/AndroidManifest.xml index f6c79572f43..35914caf6d7 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -81,6 +81,7 @@ + + + + diff --git a/res/values/strings.xml b/res/values/strings.xml index 8337321a1f8..fe8f25bc55d 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -2441,6 +2441,8 @@ Set up Explore + + Free up space USB computer connection diff --git a/res/xml/deletion_helper_list.xml b/res/xml/deletion_helper_list.xml new file mode 100644 index 00000000000..7979b46c1c5 --- /dev/null +++ b/res/xml/deletion_helper_list.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/src/com/android/settings/applications/ManageApplications.java b/src/com/android/settings/applications/ManageApplications.java index 7936cb96494..d2129685338 100644 --- a/src/com/android/settings/applications/ManageApplications.java +++ b/src/com/android/settings/applications/ManageApplications.java @@ -81,7 +81,7 @@ import com.android.settingslib.applications.ApplicationsState.VolumeFilter; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; -import java.util.Locale; +import java.util.Locale; /** * Activity to pick an application that will be used to display installation information and diff --git a/src/com/android/settings/deletionhelper/AppDeletionPreference.java b/src/com/android/settings/deletionhelper/AppDeletionPreference.java new file mode 100644 index 00000000000..cb025abe637 --- /dev/null +++ b/src/com/android/settings/deletionhelper/AppDeletionPreference.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2016 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.settings.deletionhelper; + +import android.content.Context; +import android.support.v14.preference.SwitchPreference; +import android.support.v7.preference.Preference; +import android.support.v7.preference.PreferenceViewHolder; +import android.text.format.Formatter; +import android.view.View; +import android.widget.Switch; +import android.widget.TextView; +import com.android.settings.R; + +import com.android.settingslib.applications.ApplicationsState; +import com.android.settingslib.applications.ApplicationsState.AppEntry; + +/** + * Preference item for an app with a switch to signify if it should be uninstalled. + * This shows the name and icon of the app along with the days since its last use. + */ +public class AppDeletionPreference extends SwitchPreference { + private AppEntry mEntry; + private Context mContext; + + public AppDeletionPreference(Context context, AppEntry item, ApplicationsState state) { + super(context); + mEntry = item; + mContext = context; + setLayoutResource(com.android.settings.R.layout.preference_app); + setWidgetLayoutResource(R.layout.widget_text_views); + + synchronized (item) { + state.ensureIcon(item); + if (item.icon != null) + setIcon(item.icon); + if (item.label != null) + setTitle(item.label); + } + } + + @Override + public void onBindViewHolder(PreferenceViewHolder holder) { + super.onBindViewHolder(holder); + Switch switchWidget = (Switch) holder.findViewById(com.android.internal.R.id.switch_widget); + switchWidget.setVisibility(View.VISIBLE); + + TextView summary = (TextView) holder.findViewById(R.id.widget_text1); + updateSummaryText(summary); + } + + public String getPackageName() { + return mEntry.label; + } + + private void updateSummaryText(TextView summary) { + if (mEntry.extraInfo == null) return; + if (mEntry.size == ApplicationsState.SIZE_UNKNOWN || + mEntry.size == ApplicationsState.SIZE_INVALID) { + return; + } + + long daysSinceLastUse = (long) mEntry.extraInfo; + String fileSize = Formatter.formatFileSize(mContext, mEntry.size); + if (daysSinceLastUse == AppStateUsageStatsBridge.NEVER_USED) { + summary.setText(mContext.getString(R.string.deletion_helper_app_summary_never_used, + fileSize)); + } else if (daysSinceLastUse == AppStateUsageStatsBridge.UNKNOWN_LAST_USE) { + summary.setText(mContext.getString(R.string.deletion_helper_app_summary_unknown_used, + fileSize)); + } else { + summary.setText(mContext.getString(R.string.deletion_helper_app_summary, + fileSize, + daysSinceLastUse)); + } + } + +} diff --git a/src/com/android/settings/deletionhelper/AppStateUsageStatsBridge.java b/src/com/android/settings/deletionhelper/AppStateUsageStatsBridge.java new file mode 100644 index 00000000000..013c801989c --- /dev/null +++ b/src/com/android/settings/deletionhelper/AppStateUsageStatsBridge.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2016 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.settings.deletionhelper; + +import android.app.usage.UsageStats; +import android.app.usage.UsageStatsManager; +import android.content.Context; +import android.content.pm.ApplicationInfo; + +import com.android.settings.applications.AppStateBaseBridge; +import com.android.settingslib.applications.ApplicationsState; +import com.android.settingslib.applications.ApplicationsState.AppEntry; +import com.android.settingslib.applications.ApplicationsState.AppFilter; + +import java.util.ArrayList; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Connects data from the UsageStatsManager to the ApplicationsState. + */ +public class AppStateUsageStatsBridge extends AppStateBaseBridge { + private UsageStatsManager mUsageStatsManager; + public static final long NEVER_USED = -1; + public static final long UNKNOWN_LAST_USE = -2; + + public AppStateUsageStatsBridge(Context context, ApplicationsState appState, + Callback callback) { + super(appState, callback); + mUsageStatsManager = + (UsageStatsManager) context.getSystemService(Context.USAGE_STATS_SERVICE); + } + + @Override + protected void loadAllExtraInfo() { + ArrayList apps = mAppSession.getAllApps(); + if (apps == null) return; + + final Map map = mUsageStatsManager.queryAndAggregateUsageStats(0, + System.currentTimeMillis()); + for (AppEntry entry : apps) { + UsageStats usageStats = map.get(entry.info.packageName); + entry.extraInfo = getDaysSinceLastUse(usageStats); + } + } + + @Override + protected void updateExtraInfo(AppEntry app, String pkg, int uid) { + Map map = mUsageStatsManager.queryAndAggregateUsageStats(0, + System.currentTimeMillis()); + UsageStats usageStats = map.get(app.info.packageName); + app.extraInfo = getDaysSinceLastUse(usageStats); + } + + private long getDaysSinceLastUse(UsageStats stats) { + if (stats == null) { + return NEVER_USED; + } + long lastUsed = stats.getLastTimeUsed(); + // Sometimes, a usage is recorded without a time and we don't know when the use was. + if (lastUsed == 0) { + return UNKNOWN_LAST_USE; + } + return TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis() - lastUsed); + + } + + /** + * Filters only non-system apps which haven't been used in the last 60 days. If an app's last + * usage is unknown, it is skipped. + */ + public static final AppFilter FILTER_USAGE_STATS = new AppFilter() { + private long UNUSED_DAYS_DELETION_THRESHOLD = 60; + + @Override + public void init() { + } + + @Override + public boolean filterApp(AppEntry info) { + if (info == null) return false; + boolean isBundled = (info.info.flags & ApplicationInfo.FLAG_SYSTEM) != 0; + return isExtraInfoValid(info.extraInfo) && !isBundled; + } + + private boolean isExtraInfoValid(Object extraInfo) { + if (extraInfo == null || !(extraInfo instanceof Long)) { + return false; + } + + long daysSinceLastUse = (long) extraInfo; + return daysSinceLastUse >= UNUSED_DAYS_DELETION_THRESHOLD || + daysSinceLastUse == NEVER_USED; + } + }; +} diff --git a/src/com/android/settings/deletionhelper/DeletionHelperFragment.java b/src/com/android/settings/deletionhelper/DeletionHelperFragment.java new file mode 100644 index 00000000000..9f78b0dbaa6 --- /dev/null +++ b/src/com/android/settings/deletionhelper/DeletionHelperFragment.java @@ -0,0 +1,262 @@ +package com.android.settings.deletionhelper; + +import android.os.Bundle; +import android.support.v7.preference.Preference; +import android.support.v7.preference.PreferenceGroup; +import android.text.format.Formatter; +import android.util.ArraySet; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.CompoundButton; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import com.android.settings.SettingsPreferenceFragment; +import com.android.settings.R; +import com.android.internal.logging.MetricsProto.MetricsEvent; +import com.android.settings.applications.AppStateBaseBridge; +import com.android.settings.deletionhelper.AppStateUsageStatsBridge; +import com.android.settings.deletionhelper.AppDeletionPreference; +import com.android.settingslib.applications.ApplicationsState; +import com.android.settingslib.applications.ApplicationsState.AppEntry; +import com.android.settingslib.applications.ApplicationsState.Callbacks; +import com.android.settingslib.applications.ApplicationsState.Session; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; + +/** + * Settings screen for the deletion helper, which manually removes data which is not recently used. + */ +public class DeletionHelperFragment extends SettingsPreferenceFragment implements + ApplicationsState.Callbacks, AppStateBaseBridge.Callback, Preference.OnPreferenceChangeListener { + private static final String TAG = "DeletionHelperFragment"; + + private static final String EXTRA_HAS_BRIDGE = "hasBridge"; + private static final String EXTRA_HAS_SIZES = "hasSizes"; + private static final String EXTRA_CHECKED_SET = "checkedSet"; + + private static final String KEY_APPS_GROUP = "apps_group"; + + private Button mCancel, mFree; + private PreferenceGroup mApps; + + private ApplicationsState mState; + private Session mSession; + private HashSet mUncheckedApplications; + private AppStateUsageStatsBridge mDataUsageBridge; + private ArrayList mAppEntries; + private boolean mHasReceivedAppEntries, mHasReceivedBridgeCallback, mFinishedLoading; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mState = ApplicationsState.getInstance(getActivity().getApplication()); + mSession = mState.newSession(this); + mUncheckedApplications = new HashSet<>(); + mDataUsageBridge = new AppStateUsageStatsBridge(getActivity(), mState, this); + + addPreferencesFromResource(R.xml.deletion_helper_list); + mApps = (PreferenceGroup) findPreference(KEY_APPS_GROUP); + + if (savedInstanceState != null) { + mHasReceivedAppEntries = + savedInstanceState.getBoolean(EXTRA_HAS_SIZES, false); + mHasReceivedBridgeCallback = + savedInstanceState.getBoolean(EXTRA_HAS_BRIDGE, false); + mUncheckedApplications = + (HashSet) savedInstanceState.getSerializable(EXTRA_CHECKED_SET); + } + } + + private void initializeButtons(View v) { + mCancel = (Button) v.findViewById(R.id.back_button); + mCancel.setText(R.string.cancel); + mCancel.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + finishFragment(); + } + }); + + mFree = (Button) v.findViewById(R.id.next_button); + mFree.setText(R.string.storage_menu_free); + mFree.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + ArraySet apps = new ArraySet<>(); + for (AppEntry entry : mAppEntries) { + if (!mUncheckedApplications.contains(entry.label)) { + synchronized (entry) { + apps.add(entry.info.packageName); + } + } + } + // TODO: If needed, add an action on the callback. + PackageDeletionTask task = new PackageDeletionTask( + getActivity().getPackageManager(), apps, + new PackageDeletionTask.Callback() { + @Override + public void onSuccess() { + } + + @Override + public void onError() { + Log.e(TAG, "An error occurred while uninstalling packages."); + } + }); + finishFragment(); + task.run(); + } + }); + } + + @Override + public void onViewCreated(View v, Bundle savedInstanceState) { + super.onViewCreated(v, savedInstanceState); + initializeButtons(v); + setLoading(true, false); + } + + @Override + public void onResume() { + super.onResume(); + mSession.resume(); + mDataUsageBridge.resume(); + } + + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putBoolean(EXTRA_HAS_SIZES, mHasReceivedAppEntries); + outState.putBoolean(EXTRA_HAS_BRIDGE, mHasReceivedBridgeCallback); + outState.putSerializable(EXTRA_CHECKED_SET, mUncheckedApplications); + } + + + @Override + public void onPause() { + super.onPause(); + mDataUsageBridge.pause(); + mSession.pause(); + } + + private void rebuild() { + // Only rebuild if we have the packages and their usage stats. + if (!mHasReceivedBridgeCallback || !mHasReceivedAppEntries) { + return; + } + + final ArrayList apps = + mSession.rebuild(AppStateUsageStatsBridge.FILTER_USAGE_STATS, + ApplicationsState.SIZE_COMPARATOR); + mAppEntries = apps; + cacheRemoveAllPrefs(mApps); + int entryCount = apps.size(); + for (int i = 0; i < entryCount; i++) { + AppEntry entry = apps.get(i); + final String packageName = entry.label; + AppDeletionPreference preference = (AppDeletionPreference) getCachedPreference(entry.label); + if (preference == null) { + preference = new AppDeletionPreference(getActivity(), entry, + mState); + preference.setKey(packageName); + preference.setChecked(!mUncheckedApplications.contains(packageName)); + preference.setOnPreferenceChangeListener(this); + mApps.addPreference(preference); + } + preference.setOrder(i); + } + removeCachedPrefs(mApps); + + // All applications should be filled in if we've received the sizes. + // setLoading being called multiple times causes flickering, so we only do it once. + if (mHasReceivedAppEntries && !mFinishedLoading) { + mFinishedLoading = true; + setLoading(false, true); + getButtonBar().setVisibility(View.VISIBLE); + } + updateFreeButtonText(); + } + + private void updateFreeButtonText() { + mFree.setText(String.format(getActivity().getString(R.string.deletion_helper_free_button), + Formatter.formatFileSize(getActivity(), getTotalFreeableSpace()))); + } + + @Override + public void onRunningStateChanged(boolean running) { + // No-op. + } + + @Override + public void onPackageListChanged() { + rebuild(); + } + + @Override + public void onRebuildComplete(ArrayList apps) { + } + + @Override + public void onPackageIconChanged() { + } + + @Override + public void onPackageSizeChanged(String packageName) { + rebuild(); + } + + @Override + public void onAllSizesComputed() { + rebuild(); + } + + @Override + public void onLauncherInfoChanged() { + } + + @Override + public void onLoadEntriesCompleted() { + mHasReceivedAppEntries = true; + rebuild(); + } + + @Override + public void onExtraInfoUpdated() { + mHasReceivedBridgeCallback = true; + rebuild(); + } + + @Override + protected int getMetricsCategory() { + return MetricsEvent.DEVICEINFO_STORAGE; + } + + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + boolean checked = (boolean) newValue; + String packageName = ((AppDeletionPreference) preference).getPackageName(); + if (checked) { + mUncheckedApplications.remove(packageName); + } else { + mUncheckedApplications.add(packageName); + } + updateFreeButtonText(); + return true; + } + + private long getTotalFreeableSpace() { + long freeableSpace = 0; + for (int i = 0; i < mAppEntries.size(); i++) { + final AppEntry entry = mAppEntries.get(i); + if (!mUncheckedApplications.contains(entry.label)) { + freeableSpace += mAppEntries.get(i).size; + } + } + return freeableSpace; + } +} \ No newline at end of file diff --git a/src/com/android/settings/deletionhelper/PackageDeletionTask.java b/src/com/android/settings/deletionhelper/PackageDeletionTask.java new file mode 100644 index 00000000000..69e6a6cb384 --- /dev/null +++ b/src/com/android/settings/deletionhelper/PackageDeletionTask.java @@ -0,0 +1,58 @@ +package com.android.settings.deletionhelper; + +import android.content.pm.IPackageDeleteObserver; +import android.content.pm.PackageManager; +import android.os.UserHandle; + +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Deletes a specified set of apps as a specified user and calls back once done. + */ +public class PackageDeletionTask { + private Set mPackages; + private Callback mCallback; + private PackageManager mPm; + private UserHandle mUser; + + public PackageDeletionTask(PackageManager pm, Set packageNames, Callback callback) { + mPackages = packageNames; + mCallback = callback; + mPm = pm; + mUser = android.os.Process.myUserHandle(); + } + + public void run() { + PackageDeletionObserver observer = new PackageDeletionObserver(mPackages.size()); + for (String packageName : mPackages) { + mPm.deletePackageAsUser(packageName, observer, 0, mUser.getIdentifier()); + } + } + + private class PackageDeletionObserver extends IPackageDeleteObserver.Stub { + private final AtomicInteger mPackagesRemaining = new AtomicInteger(0); + + public PackageDeletionObserver(int packages) { + mPackagesRemaining.set(packages); + } + + @Override + public void packageDeleted(String packageName, int returnCode) { + if (returnCode != PackageManager.DELETE_SUCCEEDED) { + mCallback.onError(); + return; + } + + int remaining = mPackagesRemaining.decrementAndGet(); + if (remaining == 0) { + mCallback.onSuccess(); + } + } + } + + public static abstract class Callback { + public abstract void onSuccess(); + public abstract void onError(); + } +} diff --git a/src/com/android/settings/deviceinfo/PrivateVolumeSettings.java b/src/com/android/settings/deviceinfo/PrivateVolumeSettings.java index 66026ebf250..c81ec53a806 100644 --- a/src/com/android/settings/deviceinfo/PrivateVolumeSettings.java +++ b/src/com/android/settings/deviceinfo/PrivateVolumeSettings.java @@ -58,6 +58,7 @@ import com.android.settings.Settings.StorageUseActivity; import com.android.settings.SettingsPreferenceFragment; import com.android.settings.Utils; import com.android.settings.applications.ManageApplications; +import com.android.settings.deletionhelper.DeletionHelperFragment; import com.android.settings.deviceinfo.StorageSettings.MountTask; import com.android.settingslib.deviceinfo.StorageMeasurement; import com.android.settingslib.deviceinfo.StorageMeasurement.MeasurementDetails; @@ -361,6 +362,7 @@ public class PrivateVolumeSettings extends SettingsPreferenceFragment { final MenuItem unmount = menu.findItem(R.id.storage_unmount); final MenuItem format = menu.findItem(R.id.storage_format); final MenuItem migrate = menu.findItem(R.id.storage_migrate); + final MenuItem manage = menu.findItem(R.id.storage_free); // Actions live in menu for non-internal private volumes; they're shown // as preference items for public volumes. @@ -369,11 +371,13 @@ public class PrivateVolumeSettings extends SettingsPreferenceFragment { mount.setVisible(false); unmount.setVisible(false); format.setVisible(false); + manage.setVisible(true); } else { rename.setVisible(mVolume.getType() == VolumeInfo.TYPE_PRIVATE); mount.setVisible(mVolume.getState() == VolumeInfo.STATE_UNMOUNTED); unmount.setVisible(mVolume.isMountedReadable()); format.setVisible(true); + manage.setVisible(false); } format.setTitle(R.string.storage_menu_format_public); @@ -412,6 +416,10 @@ public class PrivateVolumeSettings extends SettingsPreferenceFragment { intent.putExtra(VolumeInfo.EXTRA_VOLUME_ID, mVolume.getId()); startActivity(intent); return true; + case R.id.storage_free: + startFragment(this, DeletionHelperFragment.class.getCanonicalName(), + R.string.deletion_helper_title, 0, args); + return true; } return super.onOptionsItemSelected(item); } diff --git a/tests/unit/src/com/android/settings/deletionhelper/PackageDeletionTaskTest.java b/tests/unit/src/com/android/settings/deletionhelper/PackageDeletionTaskTest.java new file mode 100644 index 00000000000..10ba5850cc0 --- /dev/null +++ b/tests/unit/src/com/android/settings/deletionhelper/PackageDeletionTaskTest.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2016 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.settings.deletionhelper; + +import android.test.AndroidTestCase; +import android.content.pm.IPackageDeleteObserver; +import android.content.pm.PackageManager; +import android.os.RemoteException; +import android.test.mock.MockPackageManager; +import android.test.suitebuilder.annotation.SmallTest; + +import com.android.settings.deletionhelper.PackageDeletionTask; +import com.android.settings.deletionhelper.PackageDeletionTask.Callback; + +import java.util.Set; +import java.util.HashSet; + +public class PackageDeletionTaskTest extends AndroidTestCase { + private FakePackageManager mPackageManager; + private Set mDeletedApps; + + @Override + protected void setUp() throws Exception { + mPackageManager = new FakePackageManager(); + mDeletedApps = new HashSet(); + } + + @SmallTest + public void testDeleteNoApps() throws Exception { + runTask(new HashSet(), false); + } + + @SmallTest + public void testDeleteOneApp() throws Exception { + HashSet appsToDelete = new HashSet(); + appsToDelete.add("app.test1"); + runTask(appsToDelete, false); + } + + @SmallTest + public void testDeleteManyApps() throws Exception { + HashSet appsToDelete = new HashSet(); + appsToDelete.add("app.test1"); + appsToDelete.add("app.test2"); + runTask(appsToDelete, false); + } + + @SmallTest + public void testDeleteFails() throws Exception { + HashSet appsToDelete = new HashSet(); + appsToDelete.add("app.test1"); + mPackageManager.deletionSucceeds = false; + runTask(appsToDelete, true); + } + + private void runTask(HashSet appsToDelete, boolean shouldFail) { + PackageDeletionTask task = new PackageDeletionTask(mPackageManager, appsToDelete, + new VerifierCallback(appsToDelete, shouldFail)); + task.run(); + } + + class FakePackageManager extends MockPackageManager { + public boolean deletionSucceeds = true; + + @Override + public void deletePackageAsUser(String packageName, IPackageDeleteObserver observer, + int flags, int userId) { + int resultCode; + if (deletionSucceeds) { + resultCode = PackageManager.DELETE_SUCCEEDED; + mDeletedApps.add(packageName); + } else { + resultCode = PackageManager.DELETE_FAILED_INTERNAL_ERROR; + } + + try { + observer.packageDeleted(packageName, resultCode); + } catch (RemoteException e) { + fail(e.toString()); + } + } + } + + class VerifierCallback extends Callback { + private Set mExpectedDeletedApps; + private boolean mShouldFail; + + public VerifierCallback(HashSet expectedDeletedApps, boolean shouldFail) { + mExpectedDeletedApps = expectedDeletedApps; + mShouldFail = shouldFail; + } + + @Override + public void onSuccess() { + System.out.println("lol"); + assertFalse(mShouldFail); + assertEquals(mExpectedDeletedApps, mDeletedApps); + } + + @Override + public void onError() { + assertTrue(mShouldFail); + } + } + +}