diff options
author | Steve Kondik <steve@cyngn.com> | 2016-03-10 18:24:23 -0800 |
---|---|---|
committer | Steve Kondik <steve@cyngn.com> | 2016-03-10 18:24:23 -0800 |
commit | e002f536cba85cb90655e44e15497054c1a5651a (patch) | |
tree | 82ced13a5969a87673836a935a0f67d3cf16d5b5 /src | |
parent | cb079ef38ce9881687ab9c89e1c321ded722c1b3 (diff) | |
parent | b145bb2c34c495a51b83bf755e560d7b931ea8f7 (diff) | |
download | android_packages_apps_PackageInstaller-staging/cm-13.0+r22.tar.gz android_packages_apps_PackageInstaller-staging/cm-13.0+r22.tar.bz2 android_packages_apps_PackageInstaller-staging/cm-13.0+r22.zip |
Merge tag 'android-6.0.1_r22' of https://android.googlesource.com/platform/packages/apps/PackageInstaller into cm-13.0staging/cm-13.0+r22
Android 6.0.1 release 22
Diffstat (limited to 'src')
48 files changed, 7126 insertions, 83 deletions
diff --git a/src/android/support/wearable/view/CircledImageView.java b/src/android/support/wearable/view/CircledImageView.java new file mode 100644 index 00000000..53cb78cf --- /dev/null +++ b/src/android/support/wearable/view/CircledImageView.java @@ -0,0 +1,603 @@ +/* + * Copyright (C) 2015 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 android.support.wearable.view; + +import android.animation.ArgbEvaluator; +import android.animation.ValueAnimator; +import android.animation.ValueAnimator.AnimatorUpdateListener; +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Paint.Style; +import android.graphics.RadialGradient; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Shader; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.util.AttributeSet; +import android.view.View; + +import java.util.Objects; +import com.android.packageinstaller.R; + +import com.android.packageinstaller.R; + +/** + * An image view surrounded by a circle. + */ +@TargetApi(Build.VERSION_CODES.LOLLIPOP) +public class CircledImageView extends View { + + private static final ArgbEvaluator ARGB_EVALUATOR = new ArgbEvaluator(); + + private Drawable mDrawable; + + private final RectF mOval; + private final Paint mPaint; + + private ColorStateList mCircleColor; + + private float mCircleRadius; + private float mCircleRadiusPercent; + + private float mCircleRadiusPressed; + private float mCircleRadiusPressedPercent; + + private float mRadiusInset; + + private int mCircleBorderColor; + + private float mCircleBorderWidth; + private float mProgress = 1f; + private final float mShadowWidth; + + private float mShadowVisibility; + private boolean mCircleHidden = false; + + private float mInitialCircleRadius; + + private boolean mPressed = false; + + private boolean mProgressIndeterminate; + private ProgressDrawable mIndeterminateDrawable; + private Rect mIndeterminateBounds = new Rect(); + private long mColorChangeAnimationDurationMs = 0; + + private float mImageCirclePercentage = 1f; + private float mImageHorizontalOffcenterPercentage = 0f; + private Integer mImageTint; + + private final Drawable.Callback mDrawableCallback = new Drawable.Callback() { + @Override + public void invalidateDrawable(Drawable drawable) { + invalidate(); + } + + @Override + public void scheduleDrawable(Drawable drawable, Runnable runnable, long l) { + // Not needed. + } + + @Override + public void unscheduleDrawable(Drawable drawable, Runnable runnable) { + // Not needed. + } + }; + + private int mCurrentColor; + + private final AnimatorUpdateListener mAnimationListener = new AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + int color = (int) animation.getAnimatedValue(); + if (color != CircledImageView.this.mCurrentColor) { + CircledImageView.this.mCurrentColor = color; + CircledImageView.this.invalidate(); + } + } + }; + + private ValueAnimator mColorAnimator; + + public CircledImageView(Context context) { + this(context, null); + } + + public CircledImageView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public CircledImageView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.CircledImageView); + mDrawable = a.getDrawable(R.styleable.CircledImageView_android_src); + + mCircleColor = a.getColorStateList(R.styleable.CircledImageView_circle_color); + if (mCircleColor == null) { + mCircleColor = ColorStateList.valueOf(android.R.color.darker_gray); + } + + mCircleRadius = a.getDimension( + R.styleable.CircledImageView_circle_radius, 0); + mInitialCircleRadius = mCircleRadius; + mCircleRadiusPressed = a.getDimension( + R.styleable.CircledImageView_circle_radius_pressed, mCircleRadius); + mCircleBorderColor = a.getColor( + R.styleable.CircledImageView_circle_border_color, Color.BLACK); + mCircleBorderWidth = a.getDimension(R.styleable.CircledImageView_circle_border_width, 0); + + if (mCircleBorderWidth > 0) { + mRadiusInset += mCircleBorderWidth; + } + + float circlePadding = a.getDimension(R.styleable.CircledImageView_circle_padding, 0); + if (circlePadding > 0) { + mRadiusInset += circlePadding; + } + mShadowWidth = a.getDimension(R.styleable.CircledImageView_shadow_width, 0); + + mImageCirclePercentage = a.getFloat( + R.styleable.CircledImageView_image_circle_percentage, 0f); + + mImageHorizontalOffcenterPercentage = a.getFloat( + R.styleable.CircledImageView_image_horizontal_offcenter_percentage, 0f); + + if (a.hasValue(R.styleable.CircledImageView_image_tint)) { + mImageTint = a.getColor(R.styleable.CircledImageView_image_tint, 0); + } + + mCircleRadiusPercent = a.getFraction(R.styleable.CircledImageView_circle_radius_percent, + 1, 1, 0f); + + mCircleRadiusPressedPercent = a.getFraction( + R.styleable.CircledImageView_circle_radius_pressed_percent, 1, 1, + mCircleRadiusPercent); + + a.recycle(); + + mOval = new RectF(); + mPaint = new Paint(); + mPaint.setAntiAlias(true); + + mIndeterminateDrawable = new ProgressDrawable(); + // {@link #mDrawableCallback} must be retained as a member, as Drawable callback + // is held by weak reference, we must retain it for it to continue to be called. + mIndeterminateDrawable.setCallback(mDrawableCallback); + + setWillNotDraw(false); + + setColorForCurrentState(); + } + + public void setCircleHidden(boolean circleHidden) { + if (circleHidden != mCircleHidden) { + mCircleHidden = circleHidden; + invalidate(); + } + } + + + @Override + protected boolean onSetAlpha(int alpha) { + return true; + } + + @Override + protected void onDraw(Canvas canvas) { + int paddingLeft = getPaddingLeft(); + int paddingTop = getPaddingTop(); + + + float circleRadius = mPressed ? getCircleRadiusPressed() : getCircleRadius(); + if (mShadowWidth > 0 && mShadowVisibility > 0) { + // First let's find the center of the view. + mOval.set(paddingLeft, paddingTop, getWidth() - getPaddingRight(), + getHeight() - getPaddingBottom()); + // Having the center, lets make the shadow start beyond the circled and possibly the + // border. + final float radius = circleRadius + mCircleBorderWidth + + mShadowWidth * mShadowVisibility; + mPaint.setColor(Color.BLACK); + mPaint.setAlpha(Math.round(mPaint.getAlpha() * getAlpha())); + mPaint.setStyle(Style.FILL); + // TODO: precalc and pre-allocate this + mPaint.setShader(new RadialGradient(mOval.centerX(), mOval.centerY(), radius, + new int[]{Color.BLACK, Color.TRANSPARENT}, new float[]{0.6f, 1f}, + Shader.TileMode.MIRROR)); + canvas.drawCircle(mOval.centerX(), mOval.centerY(), radius, mPaint); + mPaint.setShader(null); + } + if (mCircleBorderWidth > 0) { + // First let's find the center of the view. + mOval.set(paddingLeft, paddingTop, getWidth() - getPaddingRight(), + getHeight() - getPaddingBottom()); + // Having the center, lets make the border meet the circle. + mOval.set(mOval.centerX() - circleRadius, mOval.centerY() - circleRadius, + mOval.centerX() + circleRadius, mOval.centerY() + circleRadius); + mPaint.setColor(mCircleBorderColor); + // {@link #Paint.setAlpha} is a helper method that just sets the alpha portion of the + // color. {@link #Paint.setPaint} will clear any previously set alpha value. + mPaint.setAlpha(Math.round(mPaint.getAlpha() * getAlpha())); + mPaint.setStyle(Style.STROKE); + mPaint.setStrokeWidth(mCircleBorderWidth); + + if (mProgressIndeterminate) { + mOval.roundOut(mIndeterminateBounds); + mIndeterminateDrawable.setBounds(mIndeterminateBounds); + mIndeterminateDrawable.setRingColor(mCircleBorderColor); + mIndeterminateDrawable.setRingWidth(mCircleBorderWidth); + mIndeterminateDrawable.draw(canvas); + } else { + canvas.drawArc(mOval, -90, 360 * mProgress, false, mPaint); + } + } + if (!mCircleHidden) { + mOval.set(paddingLeft, paddingTop, getWidth() - getPaddingRight(), + getHeight() - getPaddingBottom()); + // {@link #Paint.setAlpha} is a helper method that just sets the alpha portion of the + // color. {@link #Paint.setPaint} will clear any previously set alpha value. + mPaint.setColor(mCurrentColor); + mPaint.setAlpha(Math.round(mPaint.getAlpha() * getAlpha())); + + mPaint.setStyle(Style.FILL); + float centerX = mOval.centerX(); + float centerY = mOval.centerY(); + + canvas.drawCircle(centerX, centerY, circleRadius, mPaint); + } + + if (mDrawable != null) { + mDrawable.setAlpha(Math.round(getAlpha() * 255)); + + if (mImageTint != null) { + mDrawable.setTint(mImageTint); + } + mDrawable.draw(canvas); + } + + super.onDraw(canvas); + } + + private void setColorForCurrentState() { + int newColor = mCircleColor.getColorForState(getDrawableState(), + mCircleColor.getDefaultColor()); + if (mColorChangeAnimationDurationMs > 0) { + if (mColorAnimator != null) { + mColorAnimator.cancel(); + } else { + mColorAnimator = new ValueAnimator(); + } + mColorAnimator.setIntValues(new int[] { + mCurrentColor, newColor }); + mColorAnimator.setEvaluator(ARGB_EVALUATOR); + mColorAnimator.setDuration(mColorChangeAnimationDurationMs); + mColorAnimator.addUpdateListener(this.mAnimationListener); + mColorAnimator.start(); + } else { + if (newColor != mCurrentColor) { + mCurrentColor = newColor; + invalidate(); + } + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + + final float radius = getCircleRadius() + mCircleBorderWidth + + mShadowWidth * mShadowVisibility; + float desiredWidth = radius * 2; + float desiredHeight = radius * 2; + + int widthMode = MeasureSpec.getMode(widthMeasureSpec); + int widthSize = MeasureSpec.getSize(widthMeasureSpec); + int heightMode = MeasureSpec.getMode(heightMeasureSpec); + int heightSize = MeasureSpec.getSize(heightMeasureSpec); + + int width; + int height; + + if (widthMode == MeasureSpec.EXACTLY) { + width = widthSize; + } else if (widthMode == MeasureSpec.AT_MOST) { + width = (int) Math.min(desiredWidth, widthSize); + } else { + width = (int) desiredWidth; + } + + if (heightMode == MeasureSpec.EXACTLY) { + height = heightSize; + } else if (heightMode == MeasureSpec.AT_MOST) { + height = (int) Math.min(desiredHeight, heightSize); + } else { + height = (int) desiredHeight; + } + + super.onMeasure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + if (mDrawable != null) { + // Retrieve the sizes of the drawable and the view. + final int nativeDrawableWidth = mDrawable.getIntrinsicWidth(); + final int nativeDrawableHeight = mDrawable.getIntrinsicHeight(); + final int viewWidth = getMeasuredWidth(); + final int viewHeight = getMeasuredHeight(); + final float imageCirclePercentage = mImageCirclePercentage > 0 + ? mImageCirclePercentage : 1; + + final float scaleFactor = Math.min(1f, + Math.min( + (float) nativeDrawableWidth != 0 + ? imageCirclePercentage * viewWidth / nativeDrawableWidth : 1, + (float) nativeDrawableHeight != 0 + ? imageCirclePercentage + * viewHeight / nativeDrawableHeight : 1)); + + // Scale the drawable down to fit the view, if needed. + final int drawableWidth = Math.round(scaleFactor * nativeDrawableWidth); + final int drawableHeight = Math.round(scaleFactor * nativeDrawableHeight); + + // Center the drawable within the view. + final int drawableLeft = (viewWidth - drawableWidth) / 2 + + Math.round(mImageHorizontalOffcenterPercentage * drawableWidth); + final int drawableTop = (viewHeight - drawableHeight) / 2; + + mDrawable.setBounds(drawableLeft, drawableTop, drawableLeft + drawableWidth, + drawableTop + drawableHeight); + } + + super.onLayout(changed, left, top, right, bottom); + } + + public void setImageDrawable(Drawable drawable) { + if (drawable != mDrawable) { + final Drawable existingDrawable = mDrawable; + mDrawable = drawable; + + final boolean skipLayout = drawable != null + && existingDrawable != null + && existingDrawable.getIntrinsicHeight() == drawable.getIntrinsicHeight() + && existingDrawable.getIntrinsicWidth() == drawable.getIntrinsicWidth(); + + if (skipLayout) { + mDrawable.setBounds(existingDrawable.getBounds()); + } else { + requestLayout(); + } + + invalidate(); + } + } + + public void setImageResource(int resId) { + setImageDrawable(resId == 0 ? null : getContext().getDrawable(resId)); + } + + public void setImageCirclePercentage(float percentage) { + float clamped = Math.max(0, Math.min(1, percentage)); + if (clamped != mImageCirclePercentage) { + mImageCirclePercentage = clamped; + invalidate(); + } + } + + public void setImageHorizontalOffcenterPercentage(float percentage) { + if (percentage != mImageHorizontalOffcenterPercentage) { + mImageHorizontalOffcenterPercentage = percentage; + invalidate(); + } + } + + public void setImageTint(int tint) { + if (tint != mImageTint) { + mImageTint = tint; + invalidate(); + } + } + + public float getCircleRadius() { + float radius = mCircleRadius; + if (mCircleRadius <= 0 && mCircleRadiusPercent > 0) { + radius = Math.max(getMeasuredHeight(), getMeasuredWidth()) * mCircleRadiusPercent; + } + + return radius - mRadiusInset; + } + + public float getCircleRadiusPercent() { + return mCircleRadiusPercent; + } + + public float getCircleRadiusPressed() { + float radius = mCircleRadiusPressed; + + if (mCircleRadiusPressed <= 0 && mCircleRadiusPressedPercent > 0) { + radius = Math.max(getMeasuredHeight(), getMeasuredWidth()) + * mCircleRadiusPressedPercent; + } + + return radius - mRadiusInset; + } + + public float getCircleRadiusPressedPercent() { + return mCircleRadiusPressedPercent; + } + + public void setCircleRadius(float circleRadius) { + if (circleRadius != mCircleRadius) { + mCircleRadius = circleRadius; + invalidate(); + } + } + + /** + * Sets the radius of the circle to be a percentage of the largest dimension of the view. + * @param circleRadiusPercent A {@code float} from 0 to 1 representing the radius percentage. + */ + public void setCircleRadiusPercent(float circleRadiusPercent) { + if (circleRadiusPercent != mCircleRadiusPercent) { + mCircleRadiusPercent = circleRadiusPercent; + invalidate(); + } + } + + public void setCircleRadiusPressed(float circleRadiusPressed) { + if (circleRadiusPressed != mCircleRadiusPressed) { + mCircleRadiusPressed = circleRadiusPressed; + invalidate(); + } + } + + /** + * Sets the radius of the circle to be a percentage of the largest dimension of the view when + * pressed. + * @param circleRadiusPressedPercent A {@code float} from 0 to 1 representing the radius + * percentage. + */ + public void setCircleRadiusPressedPercent(float circleRadiusPressedPercent) { + if (circleRadiusPressedPercent != mCircleRadiusPressedPercent) { + mCircleRadiusPressedPercent = circleRadiusPressedPercent; + invalidate(); + } + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + setColorForCurrentState(); + } + + public void setCircleColor(int circleColor) { + setCircleColorStateList(ColorStateList.valueOf(circleColor)); + } + + public void setCircleColorStateList(ColorStateList circleColor) { + if (!Objects.equals(circleColor, mCircleColor)) { + mCircleColor = circleColor; + setColorForCurrentState(); + invalidate(); + } + } + + public ColorStateList getCircleColorStateList() { + return mCircleColor; + } + + public int getDefaultCircleColor() { + return mCircleColor.getDefaultColor(); + } + + /** + * Show the circle border as an indeterminate progress spinner. + * The views circle border width and color must be set for this to have an effect. + * + * @param show true if the progress spinner is shown, false to hide it. + */ + public void showIndeterminateProgress(boolean show) { + mProgressIndeterminate = show; + if (show) { + mIndeterminateDrawable.startAnimation(); + } else { + mIndeterminateDrawable.stopAnimation(); + } + } + + @Override + protected void onVisibilityChanged(View changedView, int visibility) { + super.onVisibilityChanged(changedView, visibility); + if (visibility != View.VISIBLE) { + showIndeterminateProgress(false); + } else if (mProgressIndeterminate) { + showIndeterminateProgress(true); + } + } + + public void setProgress(float progress) { + if (progress != mProgress) { + mProgress = progress; + invalidate(); + } + } + + /** + * Set how much of the shadow should be shown. + * @param shadowVisibility Value between 0 and 1. + */ + public void setShadowVisibility(float shadowVisibility) { + if (shadowVisibility != mShadowVisibility) { + mShadowVisibility = shadowVisibility; + invalidate(); + } + } + + public float getInitialCircleRadius() { + return mInitialCircleRadius; + } + + public void setCircleBorderColor(int circleBorderColor) { + mCircleBorderColor = circleBorderColor; + } + + /** + * Set the border around the circle. + * @param circleBorderWidth Width of the border around the circle. + */ + public void setCircleBorderWidth(float circleBorderWidth) { + if (circleBorderWidth != mCircleBorderWidth) { + mCircleBorderWidth = circleBorderWidth; + invalidate(); + } + } + + @Override + public void setPressed(boolean pressed) { + super.setPressed(pressed); + if (pressed != mPressed) { + mPressed = pressed; + invalidate(); + } + } + + public Drawable getImageDrawable() { + return mDrawable; + } + + /** + * @return the milliseconds duration of the transition animation when the color changes. + */ + public long getColorChangeAnimationDuration() { + return mColorChangeAnimationDurationMs; + } + + /** + * @param mColorChangeAnimationDurationMs the milliseconds duration of the color change + * animation. The color change animation will run if the color changes with {@link #setCircleColor} + * or as a result of the active state changing. + */ + public void setColorChangeAnimationDuration(long mColorChangeAnimationDurationMs) { + this.mColorChangeAnimationDurationMs = mColorChangeAnimationDurationMs; + } +} diff --git a/src/android/support/wearable/view/Gusterpolator.java b/src/android/support/wearable/view/Gusterpolator.java new file mode 100644 index 00000000..dc85bcb5 --- /dev/null +++ b/src/android/support/wearable/view/Gusterpolator.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2015 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 android.support.wearable.view; + +import android.animation.TimeInterpolator; +import android.annotation.TargetApi; +import android.os.Build; + +/** + * Interpolator that uses a Bezier derived S shaped curve. + * @hide + */ +@TargetApi(Build.VERSION_CODES.KITKAT_WATCH) +class Gusterpolator implements TimeInterpolator { + + /** An instance of {@link android.support.wearable.view.Gusterpolator}. */ + public static final Gusterpolator INSTANCE = new Gusterpolator(); + + /** + * To avoid users of this class creating multiple copies needlessly, the constructor is + * private. + */ + private Gusterpolator() {} + + /** + * Lookup table values. + * Generated using a Bezier curve from (0,0) to (1,1) with control points: + * P0 (0,0) + * P1 (0.4, 0) + * P2 (0.2, 1.0) + * P3 (1.0, 1.0) + * + * Values sampled with x at regular intervals between 0 and 1. + */ + private static final float[] VALUES = new float[] { + 0.0f, 0.0002f, 0.0009f, 0.0019f, 0.0036f, 0.0059f, 0.0086f, 0.0119f, 0.0157f, 0.0209f, + 0.0257f, 0.0321f, 0.0392f, 0.0469f, 0.0566f, 0.0656f, 0.0768f, 0.0887f, 0.1033f, 0.1186f, + 0.1349f, 0.1519f, 0.1696f, 0.1928f, 0.2121f, 0.237f, 0.2627f, 0.2892f, 0.3109f, 0.3386f, + 0.3667f, 0.3952f, 0.4241f, 0.4474f, 0.4766f, 0.5f, 0.5234f, 0.5468f, 0.5701f, 0.5933f, + 0.6134f, 0.6333f, 0.6531f, 0.6698f, 0.6891f, 0.7054f, 0.7214f, 0.7346f, 0.7502f, 0.763f, + 0.7756f, 0.7879f, 0.8f, 0.8107f, 0.8212f, 0.8326f, 0.8415f, 0.8503f, 0.8588f, 0.8672f, + 0.8754f, 0.8833f, 0.8911f, 0.8977f, 0.9041f, 0.9113f, 0.9165f, 0.9232f, 0.9281f, 0.9328f, + 0.9382f, 0.9434f, 0.9476f, 0.9518f, 0.9557f, 0.9596f, 0.9632f, 0.9662f, 0.9695f, 0.9722f, + 0.9753f, 0.9777f, 0.9805f, 0.9826f, 0.9847f, 0.9866f, 0.9884f, 0.9901f, 0.9917f, 0.9931f, + 0.9944f, 0.9955f, 0.9964f, 0.9973f, 0.9981f, 0.9986f, 0.9992f, 0.9995f, 0.9998f, 1.0f, 1.0f + }; + + private static final float STEP_SIZE = 1.0f / (VALUES.length - 1); + + @Override + public float getInterpolation(float input) { + if (input >= 1.0f) { + return 1.0f; + } + + if (input <= 0f) { + return 0f; + } + + int position = Math.min( + (int)(input * (VALUES.length - 1)), + VALUES.length - 2); + + float quantized = position * STEP_SIZE; + float difference = input - quantized; + float weight = difference / STEP_SIZE; + + return VALUES[position] + weight * (VALUES[position + 1] - VALUES[position]); + } +} diff --git a/src/android/support/wearable/view/ProgressDrawable.java b/src/android/support/wearable/view/ProgressDrawable.java new file mode 100644 index 00000000..63e6a039 --- /dev/null +++ b/src/android/support/wearable/view/ProgressDrawable.java @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2015 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 android.support.wearable.view; + +import android.animation.ObjectAnimator; +import android.animation.TimeInterpolator; +import android.animation.ValueAnimator; +import android.annotation.TargetApi; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.util.Property; +import android.view.animation.LinearInterpolator; + +/** + * Drawable for showing an indeterminate progress indicator. + * + * TODO: When Material progress drawable is available in the support library stop using this. + * + * @hide + */ +@TargetApi(Build.VERSION_CODES.KITKAT_WATCH) +class ProgressDrawable extends Drawable { + + private static Property<ProgressDrawable, Integer> LEVEL = + new Property<ProgressDrawable, Integer>(Integer.class, "level") { + @Override + public Integer get(ProgressDrawable drawable) { + return drawable.getLevel(); + } + + @Override + public void set(ProgressDrawable drawable, Integer value) { + drawable.setLevel(value); + drawable.invalidateSelf(); + } + }; + /** Max level for a level drawable, as specified in developer docs for {@link Drawable}. */ + private static final int MAX_LEVEL = 10000; + + /** How many different sections are there, five gives us the material style star. **/ + private static final int NUMBER_OF_SEGMENTS = 5; + + private static final int LEVELS_PER_SEGMENT = MAX_LEVEL / NUMBER_OF_SEGMENTS; + private static final float STARTING_ANGLE = -90f; + private static final long ANIMATION_DURATION = 6000; + private static final int FULL_CIRCLE = 360; + private static final int MAX_SWEEP = 306; + private static final int CORRECTION_ANGLE = FULL_CIRCLE - MAX_SWEEP; + /** How far through each cycle does the bar stop growing and start shrinking, half way. **/ + private static final float GROW_SHRINK_RATIO = 0.5f; + // TODO: replace this with BakedBezierInterpolator when its available in support library. + private static final TimeInterpolator mInterpolator = Gusterpolator.INSTANCE; + + private final RectF mInnerCircleBounds = new RectF(); + private final Paint mPaint = new Paint(); + private final ObjectAnimator mAnimator; + private float mCircleBorderWidth; + private int mCircleBorderColor; + + public ProgressDrawable() { + mPaint.setAntiAlias(true); + mPaint.setStyle(Paint.Style.STROKE); + mAnimator = ObjectAnimator.ofInt(this, LEVEL, 0, MAX_LEVEL); + mAnimator.setRepeatCount(ValueAnimator.INFINITE); + mAnimator.setRepeatMode(ValueAnimator.RESTART); + mAnimator.setDuration(ANIMATION_DURATION); + mAnimator.setInterpolator(new LinearInterpolator()); + } + + public void setRingColor(int color) { + mCircleBorderColor = color; + } + + public void setRingWidth(float width) { + mCircleBorderWidth = width; + } + + public void startAnimation() { + mAnimator.start(); + } + + public void stopAnimation() { + mAnimator.cancel(); + } + + @Override + public void draw(Canvas canvas) { + canvas.save(); + mInnerCircleBounds.set(getBounds()); + mInnerCircleBounds.inset(mCircleBorderWidth / 2.0f, mCircleBorderWidth / 2.0f); + mPaint.setStrokeWidth(mCircleBorderWidth); + mPaint.setColor(mCircleBorderColor); + + float sweepAngle = FULL_CIRCLE; + boolean growing = false; + float correctionAngle = 0; + int level = getLevel(); + + int currentSegment = level / LEVELS_PER_SEGMENT; + int offset = currentSegment * LEVELS_PER_SEGMENT; + float progress = (level - offset) / (float) LEVELS_PER_SEGMENT; + + growing = progress < GROW_SHRINK_RATIO; + correctionAngle = CORRECTION_ANGLE * progress; + + if (growing) { + sweepAngle = MAX_SWEEP * mInterpolator.getInterpolation( + lerpInv(0f, GROW_SHRINK_RATIO, progress)); + } else { + sweepAngle = MAX_SWEEP * (1.0f - mInterpolator.getInterpolation( + lerpInv(GROW_SHRINK_RATIO, 1.0f, progress))); + } + + sweepAngle = Math.max(1, sweepAngle); + + canvas.rotate( + level * (1.0f / MAX_LEVEL) * 2 * FULL_CIRCLE + STARTING_ANGLE + correctionAngle, + mInnerCircleBounds.centerX(), + mInnerCircleBounds.centerY()); + canvas.drawArc(mInnerCircleBounds, + growing ? 0 : MAX_SWEEP - sweepAngle, + sweepAngle, + false, + mPaint); + canvas.restore(); + } + + @Override + public void setAlpha(int i) { + // Not supported. + } + + @Override + public void setColorFilter(ColorFilter colorFilter) { + // Not supported. + } + + @Override + public int getOpacity() { + return PixelFormat.OPAQUE; + } + + @Override + protected boolean onLevelChange(int level) { + return true; // Changing the level of this drawable does change its appearance. + } + + /** + * Returns the interpolation scalar (s) that satisfies the equation: + * {@code value = }lerp(a, b, s) + * + * <p>If {@code a == b}, then this function will return 0. + */ + private static float lerpInv(float a, float b, float value) { + return a != b ? ((value - a) / (b - a)) : 0.0f; + } +} diff --git a/src/android/support/wearable/view/SimpleAnimatorListener.java b/src/android/support/wearable/view/SimpleAnimatorListener.java new file mode 100644 index 00000000..13631a2a --- /dev/null +++ b/src/android/support/wearable/view/SimpleAnimatorListener.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2015 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 android.support.wearable.view; + +import android.animation.Animator; +import android.annotation.TargetApi; +import android.os.Build; + +/** + * Convenience class for listening for Animator events that implements the AnimatorListener + * interface and allows extending only methods that are necessary. + */ +@TargetApi(Build.VERSION_CODES.KITKAT_WATCH) +public class SimpleAnimatorListener implements Animator.AnimatorListener { + + private boolean mWasCanceled; + + @Override + public void onAnimationCancel(Animator animator) { + mWasCanceled = true; + } + + @Override + public void onAnimationEnd(Animator animator) { + if (!mWasCanceled) { + onAnimationComplete(animator); + } + } + + @Override + public void onAnimationRepeat(Animator animator) { + } + + @Override + public void onAnimationStart(Animator animator) { + mWasCanceled = false; + } + + /** + * Called when the animation finishes. Not called if the animation was canceled. + */ + public void onAnimationComplete(Animator animator) { + } + + /** + * Provides information if the animation was cancelled. + * @return True if animation was cancelled. + */ + public boolean wasCanceled() { + return mWasCanceled; + } + +} diff --git a/src/android/support/wearable/view/WearableListView.java b/src/android/support/wearable/view/WearableListView.java new file mode 100644 index 00000000..01baa98b --- /dev/null +++ b/src/android/support/wearable/view/WearableListView.java @@ -0,0 +1,1387 @@ +/* + * Copyright (C) 2015 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 android.support.wearable.view; + +import android.animation.Animator; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.PointF; +import android.os.Build; +import android.os.Handler; +import android.support.v7.widget.LinearSmoothScroller; +import android.support.v7.widget.RecyclerView; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.Property; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.widget.Scroller; + +import java.util.ArrayList; +import java.util.List; + +/** + * An alternative version of ListView that is optimized for ease of use on small screen wearable + * devices. It displays a vertically scrollable list of items, and automatically snaps to the + * nearest item when the user stops scrolling. + * + * <p> + * For a quick start, you will need to implement a subclass of {@link .Adapter}, + * which will create and bind your views to the {@link .ViewHolder} objects. If you want to add + * more visual treatment to your views when they become the central items of the + * WearableListView, have them implement the {@link .OnCenterProximityListener} interface. + * </p> + */ +@TargetApi(Build.VERSION_CODES.KITKAT_WATCH) +public class WearableListView extends RecyclerView { + @SuppressWarnings("unused") + private static final String TAG = "WearableListView"; + + private static final long FLIP_ANIMATION_DURATION_MS = 150; + private static final long CENTERING_ANIMATION_DURATION_MS = 150; + + private static final float TOP_TAP_REGION_PERCENTAGE = .33f; + private static final float BOTTOM_TAP_REGION_PERCENTAGE = .33f; + + // Each item will occupy one third of the height. + private static final int THIRD = 3; + + private final int mMinFlingVelocity; + private final int mMaxFlingVelocity; + + private boolean mMaximizeSingleItem; + private boolean mCanClick = true; + // WristGesture navigation signals are delivered as KeyEvents. Allow developer to disable them + // for this specific View. It might be cleaner to simply have users re-implement onKeyDown(). + // TOOD: Finalize the disabling mechanism here. + private boolean mGestureNavigationEnabled = true; + private int mTapPositionX; + private int mTapPositionY; + private ClickListener mClickListener; + + private Animator mScrollAnimator; + // This is a little hacky due to the fact that animator provides incremental values instead of + // deltas and scrolling code requires deltas. We animate WearableListView directly and use this + // field to calculate deltas. Obviously this means that only one scrolling algorithm can run at + // a time, but I don't think it would be wise to have more than one running. + private int mLastScrollChange; + + private SetScrollVerticallyProperty mSetScrollVerticallyProperty = + new SetScrollVerticallyProperty(); + + private final List<OnScrollListener> mOnScrollListeners = new ArrayList<OnScrollListener>(); + + private final List<OnCentralPositionChangedListener> mOnCentralPositionChangedListeners = + new ArrayList<OnCentralPositionChangedListener>(); + + private OnOverScrollListener mOverScrollListener; + + private boolean mGreedyTouchMode; + + private float mStartX; + + private float mStartY; + + private float mStartFirstTop; + + private final int mTouchSlop; + + private boolean mPossibleVerticalSwipe; + + private int mInitialOffset = 0; + + private Scroller mScroller; + + // Top and bottom boundaries for tap checking. Need to recompute by calling computeTapRegions + // before referencing. + private final float[] mTapRegions = new float[2]; + + private boolean mGestureDirectionLocked; + private int mPreviousCentral = 0; + + // Temp variable for storing locations on screen. + private final int[] mLocation = new int[2]; + + // TODO: Consider clearing this when underlying data set changes. If the data set changes, you + // can't safely assume that this pressed view is in the same place as it was before and it will + // receive setPressed(false) unnecessarily. In theory it should be fine, but in practice we + // have places like this: mIconView.setCircleColor(pressed ? mPressedColor : mSelectedColor); + // This might set selected color on non selected item. Our logic should be: if you change + // underlying data set, all best are off and you need to preserve the state; we will clear + // this field. However, I am not willing to introduce this so late in C development. + private View mPressedView = null; + + private final Runnable mPressedRunnable = new Runnable() { + @Override + public void run() { + if (getChildCount() > 0) { + mPressedView = getChildAt(findCenterViewIndex()); + mPressedView.setPressed(true); + } else { + Log.w(TAG, "mPressedRunnable: the children were removed, skipping."); + } + } + }; + + private final Runnable mReleasedRunnable = new Runnable() { + @Override + public void run() { + releasePressedItem(); + } + }; + + private Runnable mNotifyChildrenPostLayoutRunnable = new Runnable() { + @Override + public void run() { + notifyChildrenAboutProximity(false); + } + }; + + private final AdapterDataObserver mObserver = new AdapterDataObserver() { + @Override + public void onChanged() { + WearableListView.this.addOnLayoutChangeListener(new OnLayoutChangeListener() { + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, + int oldLeft, int oldTop, int oldRight, int oldBottom) { + WearableListView.this.removeOnLayoutChangeListener(this); + if (WearableListView.this.getChildCount() > 0) { + WearableListView.this.animateToCenter(); + } + } + }); + } + }; + + public WearableListView(Context context) { + this(context, null); + } + + public WearableListView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public WearableListView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + setHasFixedSize(true); + setOverScrollMode(View.OVER_SCROLL_NEVER); + setLayoutManager(new LayoutManager()); + + final RecyclerView.OnScrollListener onScrollListener = new RecyclerView.OnScrollListener() { + @Override + public void onScrollStateChanged(RecyclerView recyclerView, int newState) { + if (newState == RecyclerView.SCROLL_STATE_IDLE && getChildCount() > 0) { + handleTouchUp(null, newState); + } + for (OnScrollListener listener : mOnScrollListeners) { + listener.onScrollStateChanged(newState); + } + } + + @Override + public void onScrolled(RecyclerView recyclerView, int dx, int dy) { + onScroll(dy); + } + }; + setOnScrollListener(onScrollListener); + + final ViewConfiguration vc = ViewConfiguration.get(context); + mTouchSlop = vc.getScaledTouchSlop(); + + mMinFlingVelocity = vc.getScaledMinimumFlingVelocity(); + mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity(); + } + + @Override + public void setAdapter(RecyclerView.Adapter adapter) { + RecyclerView.Adapter currentAdapter = getAdapter(); + if (currentAdapter != null) { + currentAdapter.unregisterAdapterDataObserver(mObserver); + } + + super.setAdapter(adapter); + + if (adapter != null) { + adapter.registerAdapterDataObserver(mObserver); + } + } + + /** + * @return the position of the center child's baseline; -1 if no center child exists or if + * the center child does not return a valid baseline. + */ + @Override + public int getBaseline() { + // No children implies there is no center child for which a baseline can be computed. + if (getChildCount() == 0) { + return super.getBaseline(); + } + + // Compute the baseline of the center child. + final int centerChildIndex = findCenterViewIndex(); + final int centerChildBaseline = getChildAt(centerChildIndex).getBaseline(); + + // If the center child has no baseline, neither does this list view. + if (centerChildBaseline == -1) { + return super.getBaseline(); + } + + return getCentralViewTop() + centerChildBaseline; + } + + /** + * @return true if the list is scrolled all the way to the top. + */ + public boolean isAtTop() { + if (getChildCount() == 0) { + return true; + } + + int centerChildIndex = findCenterViewIndex(); + View centerView = getChildAt(centerChildIndex); + return getChildAdapterPosition(centerView) == 0 && + getScrollState() == RecyclerView.SCROLL_STATE_IDLE; + } + + /** + * Clears the state of the layout manager that positions list items. + */ + public void resetLayoutManager() { + setLayoutManager(new LayoutManager()); + } + + /** + * Controls whether WearableListView should intercept all touch events and also prevent the + * parent from receiving them. + * @param greedy If true it will intercept all touch events. + */ + public void setGreedyTouchMode(boolean greedy) { + mGreedyTouchMode = greedy; + } + + /** + * By default the first element of the list is initially positioned in the center of the screen. + * This method allows the developer to specify a different offset, e.g. to hide the + * WearableListView before the user is allowed to use it. + * + * @param top How far the elements should be pushed down. + */ + public void setInitialOffset(int top) { + mInitialOffset = top; + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent event) { + if (!isEnabled()) { + return false; + } + + if (mGreedyTouchMode && getChildCount() > 0) { + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_DOWN) { + mStartX = event.getX(); + mStartY = event.getY(); + mStartFirstTop = getChildCount() > 0 ? getChildAt(0).getTop() : 0; + mPossibleVerticalSwipe = true; + mGestureDirectionLocked = false; + } else if (action == MotionEvent.ACTION_MOVE && mPossibleVerticalSwipe) { + handlePossibleVerticalSwipe(event); + } + getParent().requestDisallowInterceptTouchEvent(mPossibleVerticalSwipe); + } + return super.onInterceptTouchEvent(event); + } + + private boolean handlePossibleVerticalSwipe(MotionEvent event) { + if (mGestureDirectionLocked) { + return mPossibleVerticalSwipe; + } + float deltaX = Math.abs(mStartX - event.getX()); + float deltaY = Math.abs(mStartY - event.getY()); + float distance = (deltaX * deltaX) + (deltaY * deltaY); + // Verify that the distance moved in the combined x/y direction is at + // least touch slop before determining the gesture direction. + if (distance > (mTouchSlop * mTouchSlop)) { + if (deltaX > deltaY) { + mPossibleVerticalSwipe = false; + } + mGestureDirectionLocked = true; + } + return mPossibleVerticalSwipe; + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (!isEnabled()) { + return false; + } + + // super.onTouchEvent can change the state of the scroll, keep a copy so that handleTouchUp + // can exit early if scrollState != IDLE when the touch event started. + int scrollState = getScrollState(); + boolean result = super.onTouchEvent(event); + if (getChildCount() > 0) { + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_DOWN) { + handleTouchDown(event); + } else if (action == MotionEvent.ACTION_UP) { + handleTouchUp(event, scrollState); + getParent().requestDisallowInterceptTouchEvent(false); + } else if (action == MotionEvent.ACTION_MOVE) { + if (Math.abs(mTapPositionX - (int) event.getX()) >= mTouchSlop || + Math.abs(mTapPositionY - (int) event.getY()) >= mTouchSlop) { + releasePressedItem(); + mCanClick = false; + } + result |= handlePossibleVerticalSwipe(event); + getParent().requestDisallowInterceptTouchEvent(mPossibleVerticalSwipe); + } else if (action == MotionEvent.ACTION_CANCEL) { + getParent().requestDisallowInterceptTouchEvent(false); + mCanClick = true; + } + } + return result; + } + + private void releasePressedItem() { + if (mPressedView != null) { + mPressedView.setPressed(false); + mPressedView = null; + } + Handler handler = getHandler(); + if (handler != null) { + handler.removeCallbacks(mPressedRunnable); + } + } + + private void onScroll(int dy) { + for (OnScrollListener listener : mOnScrollListeners) { + listener.onScroll(dy); + } + notifyChildrenAboutProximity(true); + } + + /** + * Adds a listener that will be called when the content of the list view is scrolled. + */ + public void addOnScrollListener(OnScrollListener listener) { + mOnScrollListeners.add(listener); + } + + /** + * Removes listener for scroll events. + */ + public void removeOnScrollListener(OnScrollListener listener) { + mOnScrollListeners.remove(listener); + } + + /** + * Adds a listener that will be called when the central item of the list changes. + */ + public void addOnCentralPositionChangedListener(OnCentralPositionChangedListener listener) { + mOnCentralPositionChangedListeners.add(listener); + } + + /** + * Removes a listener that would be called when the central item of the list changes. + */ + public void removeOnCentralPositionChangedListener(OnCentralPositionChangedListener listener) { + mOnCentralPositionChangedListeners.remove(listener); + } + + /** + * Determines if navigation of list with wrist gestures is enabled. + */ + public boolean isGestureNavigationEnabled() { + return mGestureNavigationEnabled; + } + + /** + * Sets whether navigation of list with wrist gestures is enabled. + */ + public void setEnableGestureNavigation(boolean enabled) { + mGestureNavigationEnabled = enabled; + } + + @Override /* KeyEvent.Callback */ + public boolean onKeyDown(int keyCode, KeyEvent event) { + // Respond to keycodes (at least originally generated and injected by wrist gestures). + if (mGestureNavigationEnabled) { + switch (keyCode) { + case KeyEvent.KEYCODE_NAVIGATE_PREVIOUS: + fling(0, -mMinFlingVelocity); + return true; + case KeyEvent.KEYCODE_NAVIGATE_NEXT: + fling(0, mMinFlingVelocity); + return true; + case KeyEvent.KEYCODE_NAVIGATE_IN: + return tapCenterView(); + case KeyEvent.KEYCODE_NAVIGATE_OUT: + // Returing false leaves the action to the container of this WearableListView + // (e.g. finishing the activity containing this WearableListView). + return false; + } + } + return super.onKeyDown(keyCode, event); + } + + /** + * Simulate tapping the child view at the center of this list. + */ + private boolean tapCenterView() { + if (!isEnabled() || getVisibility() != View.VISIBLE) { + return false; + } + int index = findCenterViewIndex(); + View view = getChildAt(index); + ViewHolder holder = getChildViewHolder(view); + if (mClickListener != null) { + mClickListener.onClick(holder); + return true; + } + return false; + } + + private boolean checkForTap(MotionEvent event) { + // No taps are accepted if this view is disabled. + if (!isEnabled()) { + return false; + } + + float rawY = event.getRawY(); + int index = findCenterViewIndex(); + View view = getChildAt(index); + ViewHolder holder = getChildViewHolder(view); + computeTapRegions(mTapRegions); + if (rawY > mTapRegions[0] && rawY < mTapRegions[1]) { + if (mClickListener != null) { + mClickListener.onClick(holder); + } + return true; + } + if (index > 0 && rawY <= mTapRegions[0]) { + animateToMiddle(index - 1, index); + return true; + } + if (index < getChildCount() - 1 && rawY >= mTapRegions[1]) { + animateToMiddle(index + 1, index); + return true; + } + if (index == 0 && rawY <= mTapRegions[0] && mClickListener != null) { + // Special case: if the top third of the screen is empty and the touch event happens + // there, we don't want to immediately disallow the parent from using it. We tell + // parent to disallow intercept only after we locked a gesture. Before that he + // might do something with the action. + mClickListener.onTopEmptyRegionClick(); + return true; + } + return false; + } + + private void animateToMiddle(int newCenterIndex, int oldCenterIndex) { + if (newCenterIndex == oldCenterIndex) { + throw new IllegalArgumentException( + "newCenterIndex must be different from oldCenterIndex"); + } + List<Animator> animators = new ArrayList<Animator>(); + View child = getChildAt(newCenterIndex); + int scrollToMiddle = getCentralViewTop() - child.getTop(); + startScrollAnimation(animators, scrollToMiddle, FLIP_ANIMATION_DURATION_MS); + } + + private void startScrollAnimation(List<Animator> animators, int scroll, long duration) { + startScrollAnimation(animators, scroll, duration, 0); + } + + private void startScrollAnimation(List<Animator> animators, int scroll, long duration, + long delay) { + startScrollAnimation(animators, scroll, duration, delay, null); + } + + private void startScrollAnimation( + int scroll, long duration, long delay, Animator.AnimatorListener listener) { + startScrollAnimation(null, scroll, duration, delay, listener); + } + + private void startScrollAnimation(List<Animator> animators, int scroll, long duration, + long delay, Animator.AnimatorListener listener) { + if (mScrollAnimator != null) { + mScrollAnimator.cancel(); + } + + mLastScrollChange = 0; + ObjectAnimator scrollAnimator = ObjectAnimator.ofInt(this, mSetScrollVerticallyProperty, + 0, -scroll); + + if (animators != null) { + animators.add(scrollAnimator); + AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.playTogether(animators); + mScrollAnimator = animatorSet; + } else { + mScrollAnimator = scrollAnimator; + } + mScrollAnimator.setDuration(duration); + if (listener != null) { + mScrollAnimator.addListener(listener); + } + if (delay > 0) { + mScrollAnimator.setStartDelay(delay); + } + mScrollAnimator.start(); + } + + @Override + public boolean fling(int velocityX, int velocityY) { + if (getChildCount() == 0) { + return false; + } + // If we are flinging towards empty space (before first element or after last), we reuse + // original flinging mechanism. + final int index = findCenterViewIndex(); + final View child = getChildAt(index); + int currentPosition = getChildPosition(child); + if ((currentPosition == 0 && velocityY < 0) || + (currentPosition == getAdapter().getItemCount() - 1 && velocityY > 0)) { + return super.fling(velocityX, velocityY); + } + + if (Math.abs(velocityY) < mMinFlingVelocity) { + return false; + } + velocityY = Math.max(Math.min(velocityY, mMaxFlingVelocity), -mMaxFlingVelocity); + + if (mScroller == null) { + mScroller = new Scroller(getContext(), null, true); + } + mScroller.fling(0, 0, 0, velocityY, Integer.MIN_VALUE, Integer.MAX_VALUE, + Integer.MIN_VALUE, Integer.MAX_VALUE); + int finalY = mScroller.getFinalY(); + int delta = finalY / (getPaddingTop() + getAdjustedHeight() / 2); + if (delta == 0) { + // If the fling would not be enough to change position, we increase it to satisfy user's + // intent of switching current position. + delta = velocityY > 0 ? 1 : -1; + } + int finalPosition = Math.max( + 0, Math.min(getAdapter().getItemCount() - 1, currentPosition + delta)); + smoothScrollToPosition(finalPosition); + return true; + } + + public void smoothScrollToPosition(int position, RecyclerView.SmoothScroller smoothScroller) { + LayoutManager layoutManager = (LayoutManager) getLayoutManager(); + layoutManager.setCustomSmoothScroller(smoothScroller); + smoothScrollToPosition(position); + layoutManager.clearCustomSmoothScroller(); + } + + @Override + public ViewHolder getChildViewHolder(View child) { + return (ViewHolder) super.getChildViewHolder(child); + } + + /** + * Adds a listener that will be called when the user taps on the WearableListView or its items. + */ + public void setClickListener(ClickListener clickListener) { + mClickListener = clickListener; + } + + /** + * Adds a listener that will be called when the user drags the top element below its allowed + * bottom position. + * + * @hide + */ + public void setOverScrollListener(OnOverScrollListener listener) { + mOverScrollListener = listener; + } + + private int findCenterViewIndex() { + // TODO(gruszczy): This could be easily optimized, so that we stop looking when we the + // distance starts growing again, instead of finding the closest. It would safe half of + // the loop. + int count = getChildCount(); + int index = -1; + int closest = Integer.MAX_VALUE; + int centerY = getCenterYPos(this); + for (int i = 0; i < count; ++i) { + final View child = getChildAt(i); + int childCenterY = getTop() + getCenterYPos(child); + final int distance = Math.abs(centerY - childCenterY); + if (distance < closest) { + closest = distance; + index = i; + } + } + if (index == -1) { + throw new IllegalStateException("Can't find central view."); + } + return index; + } + + private static int getCenterYPos(View v) { + return v.getTop() + v.getPaddingTop() + getAdjustedHeight(v) / 2; + } + + private void handleTouchUp(MotionEvent event, int scrollState) { + if (mCanClick && event != null && checkForTap(event)) { + Handler handler = getHandler(); + if (handler != null) { + handler.postDelayed(mReleasedRunnable, ViewConfiguration.getTapTimeout()); + } + return; + } + + if (scrollState != RecyclerView.SCROLL_STATE_IDLE) { + // We are flinging, so let's not start animations just yet. Instead we will start them + // when the fling finishes. + return; + } + + if (isOverScrolling()) { + mOverScrollListener.onOverScroll(); + } else { + animateToCenter(); + } + } + + private boolean isOverScrolling() { + return getChildCount() > 0 + // If first view top was below the central top, it means it was never centered. + // Don't allow overscroll, otherwise a simple touch (instead of a drag) will be + // enough to trigger overscroll. + && mStartFirstTop <= getCentralViewTop() + && getChildAt(0).getTop() >= getTopViewMaxTop() + && mOverScrollListener != null; + } + + private int getTopViewMaxTop() { + return getHeight() / 2; + } + + private int getItemHeight() { + // Round up so that the screen is fully occupied by 3 items. + return getAdjustedHeight() / THIRD + 1; + } + + /** + * Returns top of the central {@code View} in the list when such view is fully centered. + * + * This is a more or a less a static value that you can use to align other views with the + * central one. + */ + public int getCentralViewTop() { + return getPaddingTop() + getItemHeight(); + } + + /** + * Automatically starts an animation that snaps the list to center on the element closest to the + * middle. + */ + public void animateToCenter() { + final int index = findCenterViewIndex(); + final View child = getChildAt(index); + final int scrollToMiddle = getCentralViewTop() - child.getTop(); + startScrollAnimation(scrollToMiddle, CENTERING_ANIMATION_DURATION_MS, 0, + new SimpleAnimatorListener() { + @Override + public void onAnimationEnd(Animator animator) { + if (!wasCanceled()) { + mCanClick = true; + } + } + }); + } + + /** + * Animate the list so that the first view is back to its initial position. + * @param endAction Action to execute when the animation is done. + * @hide + */ + public void animateToInitialPosition(final Runnable endAction) { + final View child = getChildAt(0); + final int scrollToMiddle = getCentralViewTop() + mInitialOffset - child.getTop(); + startScrollAnimation(scrollToMiddle, CENTERING_ANIMATION_DURATION_MS, 0, + new SimpleAnimatorListener() { + @Override + public void onAnimationEnd(Animator animator) { + if (endAction != null) { + endAction.run(); + } + } + }); + } + + private void handleTouchDown(MotionEvent event) { + if (mCanClick) { + mTapPositionX = (int) event.getX(); + mTapPositionY = (int) event.getY(); + float rawY = event.getRawY(); + computeTapRegions(mTapRegions); + if (rawY > mTapRegions[0] && rawY < mTapRegions[1]) { + View view = getChildAt(findCenterViewIndex()); + if (view instanceof OnCenterProximityListener) { + Handler handler = getHandler(); + if (handler != null) { + handler.removeCallbacks(mReleasedRunnable); + handler.postDelayed(mPressedRunnable, ViewConfiguration.getTapTimeout()); + } + } + } + } + } + + private void setScrollVertically(int scroll) { + scrollBy(0, scroll - mLastScrollChange); + mLastScrollChange = scroll; + } + + private int getAdjustedHeight() { + return getAdjustedHeight(this); + } + + private static int getAdjustedHeight(View v) { + return v.getHeight() - v.getPaddingBottom() - v.getPaddingTop(); + } + + private void computeTapRegions(float[] tapRegions) { + mLocation[0] = mLocation[1] = 0; + getLocationOnScreen(mLocation); + int mScreenTop = mLocation[1]; + int height = getHeight(); + tapRegions[0] = mScreenTop + height * TOP_TAP_REGION_PERCENTAGE; + tapRegions[1] = mScreenTop + height * (1 - BOTTOM_TAP_REGION_PERCENTAGE); + } + + /** + * Determines if, when there is only one item in the WearableListView, that the single item + * is laid out so that it's height fills the entire WearableListView. + */ + public boolean getMaximizeSingleItem() { + return mMaximizeSingleItem; + } + + /** + * When set to true, if there is only one item in the WearableListView, it will fill the entire + * WearableListView. When set to false, the default behavior will be used and the single item + * will fill only a third of the screen. + */ + public void setMaximizeSingleItem(boolean maximizeSingleItem) { + mMaximizeSingleItem = maximizeSingleItem; + } + + private void notifyChildrenAboutProximity(boolean animate) { + LayoutManager layoutManager = (LayoutManager) getLayoutManager(); + int count = layoutManager.getChildCount(); + + if (count == 0) { + return; + } + + int index = layoutManager.findCenterViewIndex(); + for (int i = 0; i < count; ++i) { + final View view = layoutManager.getChildAt(i); + ViewHolder holder = getChildViewHolder(view); + holder.onCenterProximity(i == index, animate); + } + final int position = getChildViewHolder(getChildAt(index)).getPosition(); + if (position != mPreviousCentral) { + for (OnScrollListener listener : mOnScrollListeners) { + listener.onCentralPositionChanged(position); + } + for (OnCentralPositionChangedListener listener : + mOnCentralPositionChangedListeners) { + listener.onCentralPositionChanged(position); + } + mPreviousCentral = position; + } + } + + // TODO: Move this to a separate class, so it can't directly interact with the WearableListView. + private class LayoutManager extends RecyclerView.LayoutManager { + private int mFirstPosition; + + private boolean mPushFirstHigher; + + private int mAbsoluteScroll; + + private boolean mUseOldViewTop = true; + + private boolean mWasZoomedIn = false; + + private RecyclerView.SmoothScroller mSmoothScroller; + + private RecyclerView.SmoothScroller mDefaultSmoothScroller; + + // We need to have another copy of the same method, because this one uses + // LayoutManager.getChildCount/getChildAt instead of View.getChildCount/getChildAt and + // they return different values. + private int findCenterViewIndex() { + // TODO(gruszczy): This could be easily optimized, so that we stop looking when we the + // distance starts growing again, instead of finding the closest. It would safe half of + // the loop. + int count = getChildCount(); + int index = -1; + int closest = Integer.MAX_VALUE; + int centerY = getCenterYPos(WearableListView.this); + for (int i = 0; i < count; ++i) { + final View child = getLayoutManager().getChildAt(i); + int childCenterY = getTop() + getCenterYPos(child); + final int distance = Math.abs(centerY - childCenterY); + if (distance < closest) { + closest = distance; + index = i; + } + } + if (index == -1) { + throw new IllegalStateException("Can't find central view."); + } + return index; + } + + @Override + public void onLayoutChildren(RecyclerView.Recycler recycler, State state) { + final int parentBottom = getHeight() - getPaddingBottom(); + // By default we assume this is the first run and the first element will be centered + // with optional initial offset. + int oldTop = getCentralViewTop() + mInitialOffset; + // Here we handle any other situation where we relayout or we want to achieve a + // specific layout of children. + if (mUseOldViewTop && getChildCount() > 0) { + // We are performing a relayout after we already had some children, because e.g. the + // contents of an adapter has changed. First we want to check, if the central item + // from before the layout is still here, because we want to preserve it. + int index = findCenterViewIndex(); + int position = getPosition(getChildAt(index)); + if (position == NO_POSITION) { + // Central item was removed. Let's find the first surviving item and use it + // as an anchor. + for (int i = 0, N = getChildCount(); index + i < N || index - i >= 0; ++i) { + View child = getChildAt(index + i); + if (child != null) { + position = getPosition(child); + if (position != NO_POSITION) { + index = index + i; + break; + } + } + child = getChildAt(index - i); + if (child != null) { + position = getPosition(child); + if (position != NO_POSITION) { + index = index - i; + break; + } + } + } + } + if (position == NO_POSITION) { + // None of the children survives the relayout, let's just use the top of the + // first one. + oldTop = getChildAt(0).getTop(); + int count = state.getItemCount(); + // Lets first make sure that the first position is not above the last element, + // which can happen if elements were removed. + while (mFirstPosition >= count && mFirstPosition > 0) { + mFirstPosition--; + } + } else { + // Some of the children survived the relayout. We will keep it in its place, + // but go through previous children and maybe add them. + if (!mWasZoomedIn) { + // If we were previously zoomed-in on a single item, ignore this and just + // use the default value set above. Reasoning: if we are still zoomed-in, + // oldTop will be ignored when laying out the single child element. If we + // are no longer zoomed in, then we want to position items using the top + // of the single item as if the single item was not zoomed in, which is + // equal to the default value. + oldTop = getChildAt(index).getTop(); + } + while (oldTop > getPaddingTop() && position > 0) { + position--; + oldTop -= getItemHeight(); + } + if (position == 0 && oldTop > getCentralViewTop()) { + // We need to handle special case where the first, central item was removed + // and now the first element is hanging below, instead of being nicely + // centered. + oldTop = getCentralViewTop(); + } + mFirstPosition = position; + } + } else if (mPushFirstHigher) { + // We are trying to position elements ourselves, so we force position of the first + // one. + oldTop = getCentralViewTop() - getItemHeight(); + } + + performLayoutChildren(recycler, state, parentBottom, oldTop); + + // Since the content might have changed, we need to adjust the absolute scroll in case + // some elements have disappeared or were added. + if (getChildCount() == 0) { + setAbsoluteScroll(0); + } else { + View child = getChildAt(findCenterViewIndex()); + setAbsoluteScroll(child.getTop() - getCentralViewTop() + getPosition(child) * + getItemHeight()); + } + + mUseOldViewTop = true; + mPushFirstHigher = false; + } + + private void performLayoutChildren(Recycler recycler, State state, int parentBottom, + int top) { + detachAndScrapAttachedViews(recycler); + + if (mMaximizeSingleItem && state.getItemCount() == 1) { + performLayoutOneChild(recycler, parentBottom); + mWasZoomedIn = true; + } else { + performLayoutMultipleChildren(recycler, state, parentBottom, top); + mWasZoomedIn = false; + } + + if (getChildCount() > 0) { + post(mNotifyChildrenPostLayoutRunnable); + } + } + + private void performLayoutOneChild(Recycler recycler, int parentBottom) { + final int right = getWidth() - getPaddingRight(); + View v = recycler.getViewForPosition(getFirstPosition()); + addView(v, 0); + measureZoomView(v); + v.layout(getPaddingLeft(), getPaddingTop(), right, parentBottom); + } + + private void performLayoutMultipleChildren(Recycler recycler, State state, int parentBottom, + int top) { + int bottom; + final int left = getPaddingLeft(); + final int right = getWidth() - getPaddingRight(); + final int count = state.getItemCount(); + // If we are laying out children with center element being different than the first, we + // need to start with previous child which appears half visible at the top. + for (int i = 0; getFirstPosition() + i < count; i++, top = bottom) { + if (top >= parentBottom) { + break; + } + View v = recycler.getViewForPosition(getFirstPosition() + i); + addView(v, i); + measureThirdView(v); + bottom = top + getItemHeight(); + v.layout(left, top, right, bottom); + } + } + + private void setAbsoluteScroll(int absoluteScroll) { + mAbsoluteScroll = absoluteScroll; + for (OnScrollListener listener : mOnScrollListeners) { + listener.onAbsoluteScrollChange(mAbsoluteScroll); + } + } + + private void measureView(View v, int height) { + final LayoutParams lp = (LayoutParams) v.getLayoutParams(); + final int widthSpec = getChildMeasureSpec(getWidth(), + getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin, lp.width, + canScrollHorizontally()); + final int heightSpec = getChildMeasureSpec(getHeight(), + getPaddingTop() + getPaddingBottom() + lp.topMargin + lp.bottomMargin, + height, canScrollVertically()); + v.measure(widthSpec, heightSpec); + } + + private void measureThirdView(View v) { + measureView(v, (int) (1 + (float) getHeight() / THIRD)); + } + + private void measureZoomView(View v) { + measureView(v, getHeight()); + } + + @Override + public RecyclerView.LayoutParams generateDefaultLayoutParams() { + return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + } + + @Override + public boolean canScrollVertically() { + // Disable vertical scrolling when zoomed. + return getItemCount() != 1 || !mWasZoomedIn; + } + + @Override + public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, State state) { + // TODO(gruszczy): This code is shit, needs to be rewritten. + if (getChildCount() == 0) { + return 0; + } + int scrolled = 0; + final int left = getPaddingLeft(); + final int right = getWidth() - getPaddingRight(); + if (dy < 0) { + while (scrolled > dy) { + final View topView = getChildAt(0); + if (getFirstPosition() > 0) { + final int hangingTop = Math.max(-topView.getTop(), 0); + final int scrollBy = Math.min(scrolled - dy, hangingTop); + scrolled -= scrollBy; + offsetChildrenVertical(scrollBy); + if (getFirstPosition() > 0 && scrolled > dy) { + mFirstPosition--; + View v = recycler.getViewForPosition(getFirstPosition()); + addView(v, 0); + measureThirdView(v); + final int bottom = topView.getTop(); + final int top = bottom - getItemHeight(); + v.layout(left, top, right, bottom); + } else { + break; + } + } else { + mPushFirstHigher = false; + int maxScroll = mOverScrollListener!= null ? + getHeight() : getTopViewMaxTop(); + final int scrollBy = Math.min(-dy + scrolled, maxScroll - topView.getTop()); + scrolled -= scrollBy; + offsetChildrenVertical(scrollBy); + break; + } + } + } else if (dy > 0) { + final int parentHeight = getHeight(); + while (scrolled < dy) { + final View bottomView = getChildAt(getChildCount() - 1); + if (state.getItemCount() > mFirstPosition + getChildCount()) { + final int hangingBottom = + Math.max(bottomView.getBottom() - parentHeight, 0); + final int scrollBy = -Math.min(dy - scrolled, hangingBottom); + scrolled -= scrollBy; + offsetChildrenVertical(scrollBy); + if (scrolled < dy) { + View v = recycler.getViewForPosition(mFirstPosition + getChildCount()); + final int top = getChildAt(getChildCount() - 1).getBottom(); + addView(v); + measureThirdView(v); + final int bottom = top + getItemHeight(); + v.layout(left, top, right, bottom); + } else { + break; + } + } else { + final int scrollBy = + Math.max(-dy + scrolled, getHeight() / 2 - bottomView.getBottom()); + scrolled -= scrollBy; + offsetChildrenVertical(scrollBy); + break; + } + } + } + recycleViewsOutOfBounds(recycler); + setAbsoluteScroll(mAbsoluteScroll + scrolled); + return scrolled; + } + + @Override + public void scrollToPosition(int position) { + mUseOldViewTop = false; + if (position > 0) { + mFirstPosition = position - 1; + mPushFirstHigher = true; + } else { + mFirstPosition = position; + mPushFirstHigher = false; + } + requestLayout(); + } + + public void setCustomSmoothScroller(RecyclerView.SmoothScroller smoothScroller) { + mSmoothScroller = smoothScroller; + } + + public void clearCustomSmoothScroller() { + mSmoothScroller = null; + } + + public RecyclerView.SmoothScroller getDefaultSmoothScroller(RecyclerView recyclerView) { + if (mDefaultSmoothScroller == null) { + mDefaultSmoothScroller = new SmoothScroller( + recyclerView.getContext(), this); + } + return mDefaultSmoothScroller; + } + @Override + public void smoothScrollToPosition(RecyclerView recyclerView, State state, + int position) { + RecyclerView.SmoothScroller scroller = mSmoothScroller; + if (scroller == null) { + scroller = getDefaultSmoothScroller(recyclerView); + } + scroller.setTargetPosition(position); + startSmoothScroll(scroller); + } + + private void recycleViewsOutOfBounds(RecyclerView.Recycler recycler) { + final int childCount = getChildCount(); + final int parentWidth = getWidth(); + // Here we want to use real height, so we don't remove views that are only visible in + // padded section. + final int parentHeight = getHeight(); + boolean foundFirst = false; + int first = 0; + int last = 0; + for (int i = 0; i < childCount; i++) { + final View v = getChildAt(i); + if (v.hasFocus() || (v.getRight() >= 0 && v.getLeft() <= parentWidth && + v.getBottom() >= 0 && v.getTop() <= parentHeight)) { + if (!foundFirst) { + first = i; + foundFirst = true; + } + last = i; + } + } + for (int i = childCount - 1; i > last; i--) { + removeAndRecycleViewAt(i, recycler); + } + for (int i = first - 1; i >= 0; i--) { + removeAndRecycleViewAt(i, recycler); + } + if (getChildCount() == 0) { + mFirstPosition = 0; + } else if (first > 0) { + mPushFirstHigher = true; + mFirstPosition += first; + } + } + + public int getFirstPosition() { + return mFirstPosition; + } + + @Override + public void onAdapterChanged(RecyclerView.Adapter oldAdapter, + RecyclerView.Adapter newAdapter) { + removeAllViews(); + } + } + + /** + * Interface for receiving callbacks when WearableListView children become or cease to be the + * central item. + */ + public interface OnCenterProximityListener { + /** + * Called when this view becomes central item of the WearableListView. + * + * @param animate Whether you should animate your transition of the View to become the + * central item. If false, this is the initial setting and you should + * transition immediately. + */ + void onCenterPosition(boolean animate); + + /** + * Called when this view stops being the central item of the WearableListView. + * @param animate Whether you should animate your transition of the View to being + * non central item. If false, this is the initial setting and you should + * transition immediately. + */ + void onNonCenterPosition(boolean animate); + } + + /** + * Interface for listening for click events on WearableListView. + */ + public interface ClickListener { + /** + * Called when the central child of the WearableListView is tapped. + * @param view View that was clicked. + */ + public void onClick(ViewHolder view); + + /** + * Called when the user taps the top third of the WearableListView and no item is present + * there. This can happen when you are in initial state and the first, top-most item of the + * WearableListView is centered. + */ + public void onTopEmptyRegionClick(); + } + + /** + * @hide + */ + public interface OnOverScrollListener { + public void onOverScroll(); + } + + /** + * Interface for listening to WearableListView content scrolling. + */ + public interface OnScrollListener { + /** + * Called when the content is scrolled, reporting the relative scroll value. + * @param scroll Amount the content was scrolled. This is a delta from the previous + * position to the new position. + */ + public void onScroll(int scroll); + + /** + * Called when the content is scrolled, reporting the absolute scroll value. + * + * @deprecated BE ADVISED DO NOT USE THIS This might provide wrong values when contents + * of a RecyclerView change. + * + * @param scroll Absolute scroll position of the content inside the WearableListView. + */ + @Deprecated + public void onAbsoluteScrollChange(int scroll); + + /** + * Called when WearableListView's scroll state changes. + * + * @param scrollState The updated scroll state. One of {@link #SCROLL_STATE_IDLE}, + * {@link #SCROLL_STATE_DRAGGING} or {@link #SCROLL_STATE_SETTLING}. + */ + public void onScrollStateChanged(int scrollState); + + /** + * Called when the central item of the WearableListView changes. + * + * @param centralPosition Position of the item in the Adapter. + */ + public void onCentralPositionChanged(int centralPosition); + } + + /** + * A listener interface that can be added to the WearableListView to get notified when the + * central item is changed. + */ + public interface OnCentralPositionChangedListener { + /** + * Called when the central item of the WearableListView changes. + * + * @param centralPosition Position of the item in the Adapter. + */ + void onCentralPositionChanged(int centralPosition); + } + + /** + * Base class for adapters providing data for the WearableListView. For details refer to + * RecyclerView.Adapter. + */ + public static abstract class Adapter extends RecyclerView.Adapter<ViewHolder> { + } + + private static class SmoothScroller extends LinearSmoothScroller { + + private static final float MILLISECONDS_PER_INCH = 100f; + + private final LayoutManager mLayoutManager; + + public SmoothScroller(Context context, WearableListView.LayoutManager manager) { + super(context); + mLayoutManager = manager; + } + + @Override + protected void onStart() { + super.onStart(); + } + + // TODO: (mindyp): when flinging, return the dydt that triggered the fling. + @Override + protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) { + return MILLISECONDS_PER_INCH / displayMetrics.densityDpi; + } + + @Override + public int calculateDtToFit(int viewStart, int viewEnd, int boxStart, int boxEnd, int + snapPreference) { + // Snap to center. + return (boxStart + boxEnd) / 2 - (viewStart + viewEnd) / 2; + } + + @Override + public PointF computeScrollVectorForPosition(int targetPosition) { + if (targetPosition < mLayoutManager.getFirstPosition()) { + return new PointF(0, -1); + } else { + return new PointF(0, 1); + } + } + } + + /** + * Wrapper around items displayed in the list view. {@link .Adapter} must return objects that + * are instances of this class. Consider making the wrapped View implement + * {@link .OnCenterProximityListener} if you want to receive a callback when it becomes or + * ceases to be the central item in the WearableListView. + */ + public static class ViewHolder extends RecyclerView.ViewHolder { + public ViewHolder(View itemView) { + super(itemView); + } + + /** + * Called when the wrapped view is becoming or ceasing to be the central item of the + * WearableListView. + * + * Retained as protected for backwards compatibility. + * + * @hide + */ + protected void onCenterProximity(boolean isCentralItem, boolean animate) { + if (!(itemView instanceof OnCenterProximityListener)) { + return; + } + OnCenterProximityListener item = (OnCenterProximityListener) itemView; + if (isCentralItem) { + item.onCenterPosition(animate); + } else { + item.onNonCenterPosition(animate); + } + } + } + + private class SetScrollVerticallyProperty extends Property<WearableListView, Integer> { + public SetScrollVerticallyProperty() { + super(Integer.class, "scrollVertically"); + } + + @Override + public Integer get(WearableListView wearableListView) { + return wearableListView.mLastScrollChange; + } + + @Override + public void set(WearableListView wearableListView, Integer value) { + wearableListView.setScrollVertically(value); + } + } +} diff --git a/src/com/android/packageinstaller/DeviceUtils.java b/src/com/android/packageinstaller/DeviceUtils.java new file mode 100644 index 00000000..8e2d57ea --- /dev/null +++ b/src/com/android/packageinstaller/DeviceUtils.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2015 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.packageinstaller; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.res.Configuration; + +public class DeviceUtils { + public static boolean isTelevision(Context context) { + int uiMode = context.getResources().getConfiguration().uiMode; + return (uiMode & Configuration.UI_MODE_TYPE_MASK) == Configuration.UI_MODE_TYPE_TELEVISION; + } + + public static boolean isWear(final Context context) { + return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_WATCH); + } +} diff --git a/src/com/android/packageinstaller/InstallFlowAnalytics.java b/src/com/android/packageinstaller/InstallFlowAnalytics.java index 2fc6db37..4591f31c 100644 --- a/src/com/android/packageinstaller/InstallFlowAnalytics.java +++ b/src/com/android/packageinstaller/InstallFlowAnalytics.java @@ -85,6 +85,11 @@ public class InstallFlowAnalytics implements Parcelable { */ static final byte RESULT_PACKAGE_MANAGER_INSTALL_FAILED = 6; + /** + * Installation blocked since this feature is not allowed on Android Wear devices yet. + */ + static final byte RESULT_NOT_ALLOWED_ON_WEAR = 7; + private static final int FLAG_INSTALLS_FROM_UNKNOWN_SOURCES_PERMITTED = 1 << 0; private static final int FLAG_INSTALL_REQUEST_FROM_UNKNOWN_SOURCE = 1 << 1; private static final int FLAG_VERIFY_APPS_ENABLED = 1 << 2; @@ -600,4 +605,4 @@ public class InstallFlowAnalytics implements Parcelable { } return digest.digest(); } -}
\ No newline at end of file +} diff --git a/src/com/android/packageinstaller/PackageInstallerActivity.java b/src/com/android/packageinstaller/PackageInstallerActivity.java index 6bcd80e4..868872a9 100644 --- a/src/com/android/packageinstaller/PackageInstallerActivity.java +++ b/src/com/android/packageinstaller/PackageInstallerActivity.java @@ -110,6 +110,7 @@ public class PackageInstallerActivity extends Activity implements OnCancelListen private static final int DLG_INSTALL_ERROR = DLG_BASE + 4; private static final int DLG_ALLOW_SOURCE = DLG_BASE + 5; private static final int DLG_ADMIN_RESTRICTS_UNKNOWN_SOURCES = DLG_BASE + 6; + private static final int DLG_NOT_SUPPORTED_ON_WEAR = DLG_BASE + 7; private void startInstallConfirm() { TabHost tabHost = (TabHost)findViewById(android.R.id.tabhost); @@ -293,7 +294,7 @@ public class PackageInstallerActivity extends Activity implements OnCancelListen Log.i(TAG, "Canceling installation"); finish(); } - }) + }) .setOnCancelListener(this) .create(); case DLG_INSTALL_ERROR : @@ -333,6 +334,18 @@ public class PackageInstallerActivity extends Activity implements OnCancelListen }) .setOnCancelListener(this) .create(); + case DLG_NOT_SUPPORTED_ON_WEAR: + return new AlertDialog.Builder(this) + .setTitle(R.string.wear_not_allowed_dlg_title) + .setMessage(R.string.wear_not_allowed_dlg_text) + .setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + setResult(RESULT_OK); + finish(); + } + }) + .setOnCancelListener(this) + .create(); } return null; } @@ -478,6 +491,13 @@ public class PackageInstallerActivity extends Activity implements OnCancelListen mInstallFlowAnalytics.setAppVerifierInstalled(isAppVerifierInstalled()); mInstallFlowAnalytics.setPackageUri(mPackageURI.toString()); + if (DeviceUtils.isWear(this)) { + showDialogInner(DLG_NOT_SUPPORTED_ON_WEAR); + mInstallFlowAnalytics.setFlowFinished( + InstallFlowAnalytics.RESULT_NOT_ALLOWED_ON_WEAR); + return; + } + final String scheme = mPackageURI.getScheme(); if (scheme != null && !"file".equals(scheme) && !"package".equals(scheme)) { Log.w(TAG, "Unsupported scheme " + scheme); diff --git a/src/com/android/packageinstaller/permission/model/AppPermissions.java b/src/com/android/packageinstaller/permission/model/AppPermissions.java index d465ee09..a0f23d64 100644 --- a/src/com/android/packageinstaller/permission/model/AppPermissions.java +++ b/src/com/android/packageinstaller/permission/model/AppPermissions.java @@ -23,6 +23,8 @@ import android.text.BidiFormatter; import android.text.TextPaint; import android.text.TextUtils; +import com.android.packageinstaller.DeviceUtils; + import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; @@ -165,9 +167,12 @@ public final class AppPermissions { private static CharSequence loadEllipsizedAppLabel(Context context, PackageInfo packageInfo) { String label = packageInfo.applicationInfo.loadLabel( context.getPackageManager()).toString(); - String noNewLineLabel = label.replace("\n", " "); - String ellipsizedLabel = TextUtils.ellipsize(noNewLineLabel, sAppLabelEllipsizePaint, + String ellipsizedLabel = label.replace("\n", " "); + if (!DeviceUtils.isWear(context)) { + // Only ellipsize for non-Wear devices. + ellipsizedLabel = TextUtils.ellipsize(ellipsizedLabel, sAppLabelEllipsizePaint, MAX_APP_LABEL_LENGTH_PIXELS, TextUtils.TruncateAt.END).toString(); + } return BidiFormatter.getInstance().unicodeWrap(ellipsizedLabel); } } diff --git a/src/com/android/packageinstaller/permission/model/PermissionApps.java b/src/com/android/packageinstaller/permission/model/PermissionApps.java index 9365bf13..e5d96d55 100644 --- a/src/com/android/packageinstaller/permission/model/PermissionApps.java +++ b/src/com/android/packageinstaller/permission/model/PermissionApps.java @@ -31,6 +31,7 @@ import android.util.ArraySet; import android.util.Log; import android.util.SparseArray; +import com.android.packageinstaller.R; import com.android.packageinstaller.permission.utils.Utils; import java.util.ArrayList; @@ -275,7 +276,7 @@ public class PermissionApps { if (info.icon != 0) { mIcon = info.loadUnbadgedIcon(mPm); } else { - mIcon = mContext.getDrawable(com.android.internal.R.drawable.ic_perm_device_info); + mIcon = mContext.getDrawable(R.drawable.ic_perm_device_info); } mIcon = Utils.applyTint(mContext, mIcon, android.R.attr.colorControlNormal); } diff --git a/src/com/android/packageinstaller/permission/model/PermissionGroups.java b/src/com/android/packageinstaller/permission/model/PermissionGroups.java index 59eba856..c496e898 100644 --- a/src/com/android/packageinstaller/permission/model/PermissionGroups.java +++ b/src/com/android/packageinstaller/permission/model/PermissionGroups.java @@ -212,11 +212,12 @@ public final class PermissionGroups implements LoaderCallbacks<List<PermissionGr } private Drawable loadItemInfoIcon(PackageItemInfo itemInfo) { - final Drawable icon; + Drawable icon = null; if (itemInfo.icon > 0) { icon = Utils.loadDrawable(getContext().getPackageManager(), itemInfo.packageName, itemInfo.icon); - } else { + } + if (icon == null) { icon = getContext().getDrawable(R.drawable.ic_perm_device_info); } return icon; diff --git a/src/com/android/packageinstaller/permission/model/PermissionStatusReceiver.java b/src/com/android/packageinstaller/permission/model/PermissionStatusReceiver.java index 2a46f1a6..810ae8ec 100644 --- a/src/com/android/packageinstaller/permission/model/PermissionStatusReceiver.java +++ b/src/com/android/packageinstaller/permission/model/PermissionStatusReceiver.java @@ -18,6 +18,7 @@ package com.android.packageinstaller.permission.model; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; +import android.content.pm.ApplicationInfo; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; @@ -30,37 +31,55 @@ import com.android.packageinstaller.permission.utils.Utils; import java.text.Collator; import java.util.ArrayList; import java.util.Collections; +import java.util.List; public class PermissionStatusReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { - int[] counts = new int[3]; - ArrayList<CharSequence> grantedGroups = new ArrayList<>(); - boolean succeeded = false; + if (Intent.ACTION_GET_PERMISSIONS_COUNT.equals(intent.getAction())) { + Intent responseIntent = new Intent(intent.getStringExtra( + Intent.EXTRA_GET_PERMISSIONS_RESPONSE_INTENT)); + responseIntent.setFlags(Intent.FLAG_RECEIVER_FOREGROUND); - boolean isForPackage = intent.hasExtra(Intent.EXTRA_PACKAGE_NAME); + int[] counts = new int[3]; + ArrayList<CharSequence> grantedGroups = new ArrayList<>(); + boolean succeeded = false; - Intent responseIntent = new Intent(intent.getStringExtra( - Intent.EXTRA_GET_PERMISSIONS_RESPONSE_INTENT)); - responseIntent.setFlags(Intent.FLAG_RECEIVER_FOREGROUND); - - - if (isForPackage) { - String pkg = intent.getStringExtra(Intent.EXTRA_PACKAGE_NAME); - succeeded = getPermissionsCount(context, pkg, counts, grantedGroups); - } else { - succeeded = getAppsWithPermissionsCount(context, counts); - } - if (succeeded) { - responseIntent.putExtra(Intent.EXTRA_GET_PERMISSIONS_COUNT_RESULT, counts); + boolean isForPackage = intent.hasExtra(Intent.EXTRA_PACKAGE_NAME); if (isForPackage) { - responseIntent.putExtra(Intent.EXTRA_GET_PERMISSIONS_GROUP_LIST_RESULT, - grantedGroups.toArray(new CharSequence[grantedGroups.size()])); + String pkg = intent.getStringExtra(Intent.EXTRA_PACKAGE_NAME); + succeeded = getPermissionsCount(context, pkg, counts, grantedGroups); + } else { + succeeded = getAppsWithPermissionsCount(context, counts); } - } + if (succeeded) { + responseIntent.putExtra(Intent.EXTRA_GET_PERMISSIONS_COUNT_RESULT, counts); - context.sendBroadcast(responseIntent); + if (isForPackage) { + responseIntent.putExtra(Intent.EXTRA_GET_PERMISSIONS_GROUP_LIST_RESULT, + grantedGroups.toArray(new CharSequence[grantedGroups.size()])); + } + } + context.sendBroadcast(responseIntent); + } else if (Intent.ACTION_GET_PERMISSIONS_PACKAGES.equals(intent.getAction())) { + Intent responseIntent = new Intent(intent.getStringExtra( + Intent.EXTRA_GET_PERMISSIONS_PACKAGES_RESPONSE_INTENT)); + responseIntent.setFlags(Intent.FLAG_RECEIVER_FOREGROUND); + + List<String> appsList = new ArrayList<>(); + List<CharSequence> appLabelsList = new ArrayList<>(); + List<Boolean> isSystemAppList = new ArrayList<>(); + if (getAppsWithRuntimePermissions(context, appsList, appLabelsList, isSystemAppList)) { + responseIntent.putExtra(Intent.EXTRA_GET_PERMISSIONS_APP_LIST_RESULT, + appsList.toArray(new String[appsList.size()])); + responseIntent.putExtra(Intent.EXTRA_GET_PERMISSIONS_APP_LABEL_LIST_RESULT, + appLabelsList.toArray(new String[appLabelsList.size()])); + responseIntent.putExtra(Intent.EXTRA_GET_PERMISSIONS_IS_SYSTEM_APP_LIST_RESULT, + toPrimitiveBoolArray(isSystemAppList)); + } + context.sendBroadcast(responseIntent); + } } public boolean getPermissionsCount(Context context, String pkg, int[] counts, @@ -105,6 +124,42 @@ public class PermissionStatusReceiver extends BroadcastReceiver { } } + public boolean getAppsWithRuntimePermissions(Context context, List<String> appsList, + List<CharSequence> appLabelsList, List<Boolean> isSystemAppList) { + final List<ApplicationInfo> appInfos = Utils.getAllInstalledApplications(context); + if (appInfos == null) { + return false; + } + final int appInfosSize = appInfos.size(); + try { + ArraySet<String> launcherPackages = Utils.getLauncherPackages(context); + for (int i = 0; i < appInfosSize; ++i) { + final String packageName = appInfos.get(i).packageName; + PackageInfo packageInfo = context.getPackageManager().getPackageInfo( + packageName, PackageManager.GET_PERMISSIONS); + AppPermissions appPermissions = + new AppPermissions(context, packageInfo, null, false, null); + + boolean shouldShow = false; + for (AppPermissionGroup group : appPermissions.getPermissionGroups()) { + if (Utils.shouldShowPermission(group, packageName)) { + shouldShow = true; + break; + } + } + if (shouldShow) { + appsList.add(packageName); + appLabelsList.add(appPermissions.getAppLabel()); + isSystemAppList.add(Utils.isSystem(appPermissions, launcherPackages)); + } + } + } catch (NameNotFoundException e) { + return false; + } + + return true; + } + public boolean getAppsWithPermissionsCount(Context context, int[] counts) { ArraySet<String> launcherPkgs = Utils.getLauncherPackages(context); // Indexed by uid. @@ -130,4 +185,14 @@ public class PermissionStatusReceiver extends BroadcastReceiver { counts[1] = allApps.size(); return true; } + + private boolean[] toPrimitiveBoolArray(final List<Boolean> list) { + final int count = list.size(); + final boolean[] result = new boolean[count]; + for (int i = 0; i < count; ++i) { + result[i] = list.get(i); + } + + return result; + } } diff --git a/src/com/android/packageinstaller/permission/ui/ButtonBarLayout.java b/src/com/android/packageinstaller/permission/ui/ButtonBarLayout.java new file mode 100644 index 00000000..59e54707 --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/ButtonBarLayout.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2015 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.packageinstaller.permission.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.View; +import android.widget.LinearLayout; +import com.android.packageinstaller.R; + +/** + * An extension of LinearLayout that automatically switches to vertical + * orientation when it can't fit its child views horizontally. + */ +public class ButtonBarLayout extends LinearLayout { + /** Whether the current configuration allows stacking. */ + private boolean mAllowStacking; + + private int mLastWidthSize = -1; + + public ButtonBarLayout(Context context, AttributeSet attrs) { + super(context, attrs); + mAllowStacking = true; + } + + public void setAllowStacking(boolean allowStacking) { + if (mAllowStacking != allowStacking) { + mAllowStacking = allowStacking; + if (!mAllowStacking && getOrientation() == LinearLayout.VERTICAL) { + setStacked(false); + } + requestLayout(); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + final int widthSize = MeasureSpec.getSize(widthMeasureSpec); + + if (mAllowStacking) { + if (widthSize > mLastWidthSize && isStacked()) { + // We're being measured wider this time, try un-stacking. + setStacked(false); + } + + mLastWidthSize = widthSize; + } + + boolean needsRemeasure = false; + + // If we're not stacked, make sure the measure spec is AT_MOST rather + // than EXACTLY. This ensures that we'll still get TOO_SMALL so that we + // know to stack the buttons. + final int initialWidthMeasureSpec; + if (!isStacked() && MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY) { + initialWidthMeasureSpec = MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.AT_MOST); + + // We'll need to remeasure again to fill excess space. + needsRemeasure = true; + } else { + initialWidthMeasureSpec = widthMeasureSpec; + } + + super.onMeasure(initialWidthMeasureSpec, heightMeasureSpec); + + if (mAllowStacking && !isStacked()) { + final int measuredWidth = getMeasuredWidthAndState(); + final int measuredWidthState = measuredWidth & MEASURED_STATE_MASK; + if (measuredWidthState == MEASURED_STATE_TOO_SMALL) { + setStacked(true); + + // Measure again in the new orientation. + needsRemeasure = true; + } + } + + if (needsRemeasure) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + } + + private void setStacked(boolean stacked) { + setOrientation(stacked ? LinearLayout.VERTICAL : LinearLayout.HORIZONTAL); + setGravity(stacked ? Gravity.RIGHT : Gravity.BOTTOM); + + final View spacer = findViewById(R.id.spacer); + if (spacer != null) { + spacer.setVisibility(stacked ? View.GONE : View.INVISIBLE); + } + + // Reverse the child order. This is specific to the Material button + // bar's layout XML and will probably not generalize. + final int childCount = getChildCount(); + for (int i = childCount - 2; i >= 0; i--) { + bringChildToFront(getChildAt(i)); + } + } + + private boolean isStacked() { + return getOrientation() == LinearLayout.VERTICAL; + } +} diff --git a/src/com/android/packageinstaller/permission/ui/GrantPermissionsActivity.java b/src/com/android/packageinstaller/permission/ui/GrantPermissionsActivity.java index 56b3f466..102fd6ef 100644 --- a/src/com/android/packageinstaller/permission/ui/GrantPermissionsActivity.java +++ b/src/com/android/packageinstaller/permission/ui/GrantPermissionsActivity.java @@ -26,11 +26,12 @@ import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.PermissionInfo; import android.content.res.Resources; +import android.graphics.Typeface; import android.graphics.drawable.Icon; import android.hardware.camera2.utils.ArrayUtils; import android.os.Bundle; import android.text.SpannableString; -import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; import android.util.Log; import android.view.KeyEvent; import android.view.MotionEvent; @@ -38,6 +39,7 @@ import android.view.View; import android.view.Window; import android.view.WindowManager; +import com.android.packageinstaller.DeviceUtils; import com.android.packageinstaller.R; import com.android.packageinstaller.permission.model.AppPermissionGroup; import com.android.packageinstaller.permission.model.AppPermissions; @@ -71,10 +73,14 @@ public class GrantPermissionsActivity extends OverlayTouchActivity setTitle(R.string.permission_request_title); - if (Utils.isTelevision(this)) { - mViewHandler = new GrantPermissionsTvViewHandler(this).setResultListener(this); + if (DeviceUtils.isTelevision(this)) { + mViewHandler = new com.android.packageinstaller.permission.ui.television + .GrantPermissionsViewHandlerImpl(this).setResultListener(this); + } else if (DeviceUtils.isWear(this)) { + mViewHandler = new GrantPermissionsWatchViewHandler(this).setResultListener(this); } else { - mViewHandler = new GrantPermissionsDefaultViewHandler(this).setResultListener(this); + mViewHandler = new com.android.packageinstaller.permission.ui.handheld + .GrantPermissionsViewHandlerImpl(this).setResultListener(this); } mRequestedPermissions = getIntent().getStringArrayExtra( @@ -206,8 +212,7 @@ public class GrantPermissionsActivity extends OverlayTouchActivity // Color the app name. int appLabelStart = message.toString().indexOf(appLabel.toString(), 0); int appLabelLength = appLabel.length(); - int color = getColor(R.color.grant_permissions_app_color); - message.setSpan(new ForegroundColorSpan(color), appLabelStart, + message.setSpan(new StyleSpan(Typeface.BOLD), appLabelStart, appLabelStart + appLabelLength, 0); // Set the new grant view diff --git a/src/com/android/packageinstaller/permission/ui/GrantPermissionsViewHandler.java b/src/com/android/packageinstaller/permission/ui/GrantPermissionsViewHandler.java index 4032abb2..5e2259af 100644 --- a/src/com/android/packageinstaller/permission/ui/GrantPermissionsViewHandler.java +++ b/src/com/android/packageinstaller/permission/ui/GrantPermissionsViewHandler.java @@ -25,7 +25,7 @@ import android.view.WindowManager; * Class for managing the presentation and user interaction of the "grant * permissions" user interface. */ -interface GrantPermissionsViewHandler { +public interface GrantPermissionsViewHandler { /** * Listener interface for getting notified when the user responds to a diff --git a/src/com/android/packageinstaller/permission/ui/GrantPermissionsWatchViewHandler.java b/src/com/android/packageinstaller/permission/ui/GrantPermissionsWatchViewHandler.java new file mode 100644 index 00000000..21042f00 --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/GrantPermissionsWatchViewHandler.java @@ -0,0 +1,176 @@ +package com.android.packageinstaller.permission.ui; + +import android.content.Context; +import android.graphics.PixelFormat; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.view.WindowManager; + +import com.android.packageinstaller.R; +import com.android.packageinstaller.permission.ui.wear.ConfirmationViewHandler; + +/** + * Watch-specific view handler for the grant permissions activity. + */ +final class GrantPermissionsWatchViewHandler extends ConfirmationViewHandler + implements GrantPermissionsViewHandler { + private static final String TAG = "GrantPermsWatchViewH"; + + private static final String ARG_GROUP_NAME = "ARG_GROUP_NAME"; + + private final Context mContext; + + private ResultListener mResultListener; + + private String mGroupName; + private boolean mShowDoNotAsk; + + private CharSequence mMessage; + private String mCurrentPageText; + private Icon mIcon; + + GrantPermissionsWatchViewHandler(Context context) { + super(context); + mContext = context; + } + + @Override + public GrantPermissionsWatchViewHandler setResultListener(ResultListener listener) { + mResultListener = listener; + return this; + } + + @Override + public View createView() { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "createView()"); + } + + mShowDoNotAsk = false; + + return super.createView(); + } + + @Override + public void updateWindowAttributes(WindowManager.LayoutParams outLayoutParams) { + outLayoutParams.width = WindowManager.LayoutParams.MATCH_PARENT; + outLayoutParams.height = WindowManager.LayoutParams.MATCH_PARENT; + outLayoutParams.format = PixelFormat.OPAQUE; + outLayoutParams.type = WindowManager.LayoutParams.TYPE_SYSTEM_DIALOG; + outLayoutParams.flags |= WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON; + } + + @Override + public void updateUi(String groupName, int groupCount, int groupIndex, Icon icon, + CharSequence message, boolean showDoNotAsk) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "updateUi() - groupName: " + groupName + + ", groupCount: " + groupCount + + ", groupIndex: " + groupIndex + + ", icon: " + icon + + ", message: " + message + + ", showDoNotAsk: " + showDoNotAsk); + } + + mGroupName = groupName; + mShowDoNotAsk = showDoNotAsk; + mMessage = message; + mIcon = icon; + mCurrentPageText = (groupCount > 1 ? + mContext.getString(R.string.current_permission_template, groupIndex + 1, groupCount) + : null); + + invalidate(); + } + + @Override + public void saveInstanceState(Bundle outState) { + outState.putString(ARG_GROUP_NAME, mGroupName); + } + + @Override + public void loadInstanceState(Bundle savedInstanceState) { + mGroupName = savedInstanceState.getString(ARG_GROUP_NAME); + } + + @Override + public void onBackPressed() { + if (mResultListener != null) { + mResultListener.onPermissionGrantResult(mGroupName, false, false); + } + } + + @Override // ConfirmationViewHandler + public void onButton1() { + onClick(true /* granted */, false /* doNotAskAgain */); + } + + @Override // ConfirmationViewHandler + public void onButton2() { + onClick(false /* granted */, false /* doNotAskAgain */); + } + + @Override // ConfirmationViewHandler + public void onButton3() { + onClick(false /* granted */, true /* doNotAskAgain */); + } + + @Override // ConfirmationViewHandler + public CharSequence getCurrentPageText() { + return mCurrentPageText; + } + + @Override // ConfirmationViewHandler + public Icon getPermissionIcon() { + return mIcon; + } + + @Override // ConfirmationViewHandler + public CharSequence getMessage() { + return mMessage; + } + + @Override // ConfirmationViewHandler + public int getButtonBarMode() { + return mShowDoNotAsk ? MODE_VERTICAL_BUTTONS : MODE_HORIZONTAL_BUTTONS; + } + + @Override // ConfirmationViewHandler + public CharSequence getVerticalButton1Text() { + return mContext.getString(R.string.grant_dialog_button_allow); + } + + @Override // ConfirmationViewHandler + public CharSequence getVerticalButton2Text() { + return mContext.getString(R.string.grant_dialog_button_deny); + } + + @Override // ConfirmationViewHandler + public CharSequence getVerticalButton3Text() { + return mContext.getString(R.string.grant_dialog_button_deny_dont_ask_again); + } + + @Override // ConfirmationViewHandler + public Drawable getVerticalButton1Icon(){ + return mContext.getDrawable(R.drawable.confirm_button); + } + + @Override // ConfirmationViewHandler + public Drawable getVerticalButton2Icon(){ + return mContext.getDrawable(R.drawable.cancel_button); + } + + @Override // ConfirmationViewHandler + public Drawable getVerticalButton3Icon(){ + return mContext.getDrawable(R.drawable.deny_button); + } + + private void onClick(boolean granted, boolean doNotAskAgain) { + if (mResultListener != null) { + mResultListener.onPermissionGrantResult(mGroupName, granted, doNotAskAgain); + } + } +} diff --git a/src/com/android/packageinstaller/permission/ui/ManagePermissionsActivity.java b/src/com/android/packageinstaller/permission/ui/ManagePermissionsActivity.java index 8ba6b127..38dbf8f5 100644 --- a/src/com/android/packageinstaller/permission/ui/ManagePermissionsActivity.java +++ b/src/com/android/packageinstaller/permission/ui/ManagePermissionsActivity.java @@ -21,6 +21,9 @@ import android.content.Intent; import android.os.Bundle; import android.util.Log; +import com.android.packageinstaller.permission.ui.wear.AppPermissionsFragmentWear; +import com.android.packageinstaller.DeviceUtils; + public final class ManagePermissionsActivity extends OverlayTouchActivity { private static final String LOG_TAG = "ManagePermissionsActivity"; @@ -37,7 +40,13 @@ public final class ManagePermissionsActivity extends OverlayTouchActivity { switch (action) { case Intent.ACTION_MANAGE_PERMISSIONS: { - fragment = ManagePermissionsFragment.newInstance(); + if (DeviceUtils.isTelevision(this)) { + fragment = com.android.packageinstaller.permission.ui.television + .ManagePermissionsFragment.newInstance(); + } else { + fragment = com.android.packageinstaller.permission.ui.handheld + .ManagePermissionsFragment.newInstance(); + } } break; case Intent.ACTION_MANAGE_APP_PERMISSIONS: { @@ -47,7 +56,15 @@ public final class ManagePermissionsActivity extends OverlayTouchActivity { finish(); return; } - fragment = AppPermissionsFragment.newInstance(packageName); + if (DeviceUtils.isWear(this)) { + fragment = AppPermissionsFragmentWear.newInstance(packageName); + } else if (DeviceUtils.isTelevision(this)) { + fragment = com.android.packageinstaller.permission.ui.television + .AppPermissionsFragment.newInstance(packageName); + } else { + fragment = com.android.packageinstaller.permission.ui.handheld + .AppPermissionsFragment.newInstance(packageName); + } } break; case Intent.ACTION_MANAGE_PERMISSION_APPS: { @@ -57,7 +74,13 @@ public final class ManagePermissionsActivity extends OverlayTouchActivity { finish(); return; } - fragment = PermissionAppsFragment.newInstance(permissionName); + if (DeviceUtils.isTelevision(this)) { + fragment = com.android.packageinstaller.permission.ui.television + .PermissionAppsFragment.newInstance(permissionName); + } else { + fragment = com.android.packageinstaller.permission.ui.handheld + .PermissionAppsFragment.newInstance(permissionName); + } } break; default: { diff --git a/src/com/android/packageinstaller/permission/ui/OverlayWarningDialog.java b/src/com/android/packageinstaller/permission/ui/OverlayWarningDialog.java index a7c1e2a1..61734b47 100644 --- a/src/com/android/packageinstaller/permission/ui/OverlayWarningDialog.java +++ b/src/com/android/packageinstaller/permission/ui/OverlayWarningDialog.java @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package com.android.packageinstaller.permission.ui; import android.app.Activity; diff --git a/src/com/android/packageinstaller/permission/ui/PreferenceImageView.java b/src/com/android/packageinstaller/permission/ui/PreferenceImageView.java new file mode 100644 index 00000000..c3f51674 --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/PreferenceImageView.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2014 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.packageinstaller.permission.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.ImageView; + +/** + * Extension of ImageView that correctly applies maxWidth and maxHeight. + */ +public class PreferenceImageView extends ImageView { + + public PreferenceImageView(Context context) { + this(context, null); + } + + public PreferenceImageView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public PreferenceImageView(Context context, AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public PreferenceImageView(Context context, AttributeSet attrs, int defStyleAttr, + int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + final int widthMode = MeasureSpec.getMode(widthMeasureSpec); + if (widthMode == MeasureSpec.AT_MOST || widthMode == MeasureSpec.UNSPECIFIED) { + final int widthSize = MeasureSpec.getSize(widthMeasureSpec); + final int maxWidth = getMaxWidth(); + if (maxWidth != Integer.MAX_VALUE + && (maxWidth < widthSize || widthMode == MeasureSpec.UNSPECIFIED)) { + widthMeasureSpec = MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.AT_MOST); + } + } + + final int heightMode = MeasureSpec.getMode(heightMeasureSpec); + if (heightMode == MeasureSpec.AT_MOST || heightMode == MeasureSpec.UNSPECIFIED) { + final int heightSize = MeasureSpec.getSize(heightMeasureSpec); + final int maxHeight = getMaxHeight(); + if (maxHeight != Integer.MAX_VALUE + && (maxHeight < heightSize || heightMode == MeasureSpec.UNSPECIFIED)) { + heightMeasureSpec = MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST); + } + } + + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } +} diff --git a/src/com/android/packageinstaller/permission/ui/handheld/AllAppPermissionsFragment.java b/src/com/android/packageinstaller/permission/ui/handheld/AllAppPermissionsFragment.java new file mode 100644 index 00000000..b3b0895c --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/handheld/AllAppPermissionsFragment.java @@ -0,0 +1,214 @@ +/* +* Copyright (C) 2015 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.packageinstaller.permission.ui.handheld; + +import android.app.ActionBar; +import android.app.AlertDialog; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageItemInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.pm.PermissionGroupInfo; +import android.content.pm.PermissionInfo; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Bundle; +import android.preference.Preference; +import android.preference.Preference.OnPreferenceClickListener; +import android.preference.PreferenceCategory; +import android.preference.PreferenceGroup; +import android.provider.Settings; +import android.util.Log; +import android.view.MenuItem; +import com.android.packageinstaller.R; +import com.android.packageinstaller.permission.utils.Utils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; + +public final class AllAppPermissionsFragment extends SettingsWithHeader { + + private static final String LOG_TAG = "AllAppPermissionsFragment"; + + private static final String KEY_OTHER = "other_perms"; + + public static AllAppPermissionsFragment newInstance(String packageName) { + AllAppPermissionsFragment instance = new AllAppPermissionsFragment(); + Bundle arguments = new Bundle(); + arguments.putString(Intent.EXTRA_PACKAGE_NAME, packageName); + instance.setArguments(arguments); + return instance; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + final ActionBar ab = getActivity().getActionBar(); + if (ab != null) { + ab.setTitle(R.string.all_permissions); + ab.setDisplayHomeAsUpEnabled(true); + } + } + + @Override + public void onResume() { + super.onResume(); + updateUi(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: { + getFragmentManager().popBackStack(); + return true; + } + } + return super.onOptionsItemSelected(item); + } + + private void updateUi() { + if (getPreferenceScreen() != null) { + getPreferenceScreen().removeAll(); + } + addPreferencesFromResource(R.xml.all_permissions); + PreferenceGroup otherGroup = (PreferenceGroup) findPreference(KEY_OTHER); + ArrayList<Preference> prefs = new ArrayList<>(); // Used for sorting. + prefs.add(otherGroup); + String pkg = getArguments().getString(Intent.EXTRA_PACKAGE_NAME); + otherGroup.removeAll(); + PackageManager pm = getContext().getPackageManager(); + + try { + PackageInfo info = pm.getPackageInfo(pkg, PackageManager.GET_PERMISSIONS); + + ApplicationInfo appInfo = info.applicationInfo; + final Drawable icon = appInfo.loadIcon(pm); + final CharSequence label = appInfo.loadLabel(pm); + Intent infoIntent = null; + if (!getActivity().getIntent().getBooleanExtra( + AppPermissionsFragment.EXTRA_HIDE_INFO_BUTTON, false)) { + infoIntent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.fromParts("package", pkg, null)); + } + setHeader(icon, label, infoIntent); + + if (info.requestedPermissions != null) { + for (int i = 0; i < info.requestedPermissions.length; i++) { + PermissionInfo perm; + try { + perm = pm.getPermissionInfo(info.requestedPermissions[i], 0); + } catch (NameNotFoundException e) { + Log.e(LOG_TAG, + "Can't get permission info for " + info.requestedPermissions[i], e); + continue; + } + + if ((perm.flags & PermissionInfo.FLAG_INSTALLED) == 0 + || (perm.flags & PermissionInfo.FLAG_HIDDEN) != 0) { + continue; + } + + if (perm.protectionLevel == PermissionInfo.PROTECTION_DANGEROUS) { + PermissionGroupInfo group = getGroup(perm.group, pm); + PreferenceGroup pref = + findOrCreate(group != null ? group : perm, pm, prefs); + pref.addPreference(getPreference(perm, group, pm)); + } else if (perm.protectionLevel == PermissionInfo.PROTECTION_NORMAL) { + PermissionGroupInfo group = getGroup(perm.group, pm); + otherGroup.addPreference(getPreference(perm, group, pm)); + } + } + } + } catch (NameNotFoundException e) { + Log.e(LOG_TAG, "Problem getting package info for " + pkg, e); + } + // Sort an ArrayList of the groups and then set the order from the sorting. + Collections.sort(prefs, new Comparator<Preference>() { + @Override + public int compare(Preference lhs, Preference rhs) { + String lKey = lhs.getKey(); + String rKey = rhs.getKey(); + if (lKey.equals(KEY_OTHER)) { + return 1; + } else if (rKey.equals(KEY_OTHER)) { + return -1; + } else if (Utils.isModernPermissionGroup(lKey) + != Utils.isModernPermissionGroup(rKey)) { + return Utils.isModernPermissionGroup(lKey) ? -1 : 1; + } + return lhs.getTitle().toString().compareTo(rhs.getTitle().toString()); + } + }); + for (int i = 0; i < prefs.size(); i++) { + prefs.get(i).setOrder(i); + } + } + + private PermissionGroupInfo getGroup(String group, PackageManager pm) { + try { + return pm.getPermissionGroupInfo(group, 0); + } catch (NameNotFoundException e) { + return null; + } + } + + private PreferenceGroup findOrCreate(PackageItemInfo group, PackageManager pm, + ArrayList<Preference> prefs) { + PreferenceGroup pref = (PreferenceGroup) findPreference(group.name); + if (pref == null) { + pref = new PreferenceCategory(getContext()); + pref.setKey(group.name); + pref.setTitle(group.loadLabel(pm)); + prefs.add(pref); + getPreferenceScreen().addPreference(pref); + } + return pref; + } + + private Preference getPreference(PermissionInfo perm, PermissionGroupInfo group, + PackageManager pm) { + Preference pref = new Preference(getContext()); + Drawable icon = null; + if (perm.icon != 0) { + icon = perm.loadIcon(pm); + } else if (group != null && group.icon != 0) { + icon = group.loadIcon(pm); + } else { + icon = getContext().getDrawable(R.drawable.ic_perm_device_info); + } + pref.setIcon(Utils.applyTint(getContext(), icon, android.R.attr.colorControlNormal)); + pref.setTitle(perm.loadLabel(pm)); + final CharSequence desc = perm.loadDescription(pm); + pref.setOnPreferenceClickListener(new OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + new AlertDialog.Builder(getContext()) + .setMessage(desc) + .setPositiveButton(android.R.string.ok, null) + .show(); + return true; + } + }); + + return pref; + } +}
\ No newline at end of file diff --git a/src/com/android/packageinstaller/permission/ui/handheld/AppPermissionsFragment.java b/src/com/android/packageinstaller/permission/ui/handheld/AppPermissionsFragment.java new file mode 100644 index 00000000..f56cba70 --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/handheld/AppPermissionsFragment.java @@ -0,0 +1,404 @@ +/* +* Copyright (C) 2015 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.packageinstaller.permission.ui.handheld; + +import android.annotation.Nullable; +import android.app.ActionBar; +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Fragment; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Bundle; +import android.preference.Preference; +import android.preference.Preference.OnPreferenceChangeListener; +import android.preference.Preference.OnPreferenceClickListener; +import android.preference.PreferenceScreen; +import android.preference.SwitchPreference; +import android.provider.Settings; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; +import com.android.packageinstaller.R; +import com.android.packageinstaller.permission.model.AppPermissionGroup; +import com.android.packageinstaller.permission.model.AppPermissions; +import com.android.packageinstaller.permission.ui.OverlayTouchActivity; +import com.android.packageinstaller.permission.utils.LocationUtils; +import com.android.packageinstaller.permission.utils.SafetyNetLogger; +import com.android.packageinstaller.permission.utils.Utils; + +import java.util.ArrayList; +import java.util.List; + +public final class AppPermissionsFragment extends SettingsWithHeader + implements OnPreferenceChangeListener { + + private static final String LOG_TAG = "ManagePermsFragment"; + + static final String EXTRA_HIDE_INFO_BUTTON = "hideInfoButton"; + + private static final int MENU_ALL_PERMS = 0; + + private List<AppPermissionGroup> mToggledGroups; + private AppPermissions mAppPermissions; + private PreferenceScreen mExtraScreen; + + private boolean mHasConfirmedRevoke; + + public static AppPermissionsFragment newInstance(String packageName) { + return setPackageName(new AppPermissionsFragment(), packageName); + } + + private static <T extends Fragment> T setPackageName(T fragment, String packageName) { + Bundle arguments = new Bundle(); + arguments.putString(Intent.EXTRA_PACKAGE_NAME, packageName); + fragment.setArguments(arguments); + return fragment; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setLoading(true /* loading */, false /* animate */); + setHasOptionsMenu(true); + final ActionBar ab = getActivity().getActionBar(); + if (ab != null) { + ab.setDisplayHomeAsUpEnabled(true); + } + + String packageName = getArguments().getString(Intent.EXTRA_PACKAGE_NAME); + Activity activity = getActivity(); + PackageInfo packageInfo = getPackageInfo(activity, packageName); + if (packageInfo == null) { + Toast.makeText(activity, R.string.app_not_found_dlg_title, Toast.LENGTH_LONG).show(); + activity.finish(); + return; + } + + mAppPermissions = new AppPermissions(activity, packageInfo, null, true, new Runnable() { + @Override + public void run() { + getActivity().finish(); + } + }); + loadPreferences(); + } + + @Override + public void onResume() { + super.onResume(); + mAppPermissions.refresh(); + setPreferencesCheckedState(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: { + getActivity().finish(); + return true; + } + + case MENU_ALL_PERMS: { + Fragment frag = AllAppPermissionsFragment.newInstance( + getArguments().getString(Intent.EXTRA_PACKAGE_NAME)); + getFragmentManager().beginTransaction() + .replace(android.R.id.content, frag) + .addToBackStack("AllPerms") + .commit(); + return true; + } + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + if (mAppPermissions != null) { + bindUi(this, mAppPermissions.getPackageInfo()); + } + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + menu.add(Menu.NONE, MENU_ALL_PERMS, Menu.NONE, R.string.all_permissions); + } + + private static void bindUi(SettingsWithHeader fragment, PackageInfo packageInfo) { + Activity activity = fragment.getActivity(); + PackageManager pm = activity.getPackageManager(); + ApplicationInfo appInfo = packageInfo.applicationInfo; + Intent infoIntent = null; + if (!activity.getIntent().getBooleanExtra(EXTRA_HIDE_INFO_BUTTON, false)) { + infoIntent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.fromParts("package", packageInfo.packageName, null)); + } + + Drawable icon = appInfo.loadIcon(pm); + CharSequence label = appInfo.loadLabel(pm); + fragment.setHeader(icon, label, infoIntent); + + ActionBar ab = activity.getActionBar(); + if (ab != null) { + ab.setTitle(R.string.app_permissions); + } + + ViewGroup rootView = (ViewGroup) fragment.getView(); + ImageView iconView = (ImageView) rootView.findViewById(R.id.lb_icon); + if (iconView != null) { + iconView.setImageDrawable(icon); + } + TextView titleView = (TextView) rootView.findViewById(R.id.lb_title); + if (titleView != null) { + titleView.setText(R.string.app_permissions); + } + TextView breadcrumbView = (TextView) rootView.findViewById(R.id.lb_breadcrumb); + if (breadcrumbView != null) { + breadcrumbView.setText(label); + } + } + + private void loadPreferences() { + Context context = getActivity(); + if (context == null) { + return; + } + + PreferenceScreen screen = getPreferenceScreen(); + if (screen == null) { + screen = getPreferenceManager().createPreferenceScreen(getActivity()); + setPreferenceScreen(screen); + } + + screen.removeAll(); + + if (mExtraScreen != null) { + mExtraScreen.removeAll(); + } + + final Preference extraPerms = new Preference(context); + extraPerms.setIcon(R.drawable.ic_toc); + extraPerms.setTitle(R.string.additional_permissions); + + for (AppPermissionGroup group : mAppPermissions.getPermissionGroups()) { + if (!Utils.shouldShowPermission(group, mAppPermissions.getPackageInfo().packageName)) { + continue; + } + + boolean isPlatform = group.getDeclaringPackage().equals(Utils.OS_PKG); + + SwitchPreference preference = new SwitchPreference(context); + preference.setOnPreferenceChangeListener(this); + preference.setKey(group.getName()); + Drawable icon = Utils.loadDrawable(context.getPackageManager(), + group.getIconPkg(), group.getIconResId()); + preference.setIcon(Utils.applyTint(getContext(), icon, + android.R.attr.colorControlNormal)); + preference.setTitle(group.getLabel()); + if (group.isPolicyFixed()) { + preference.setSummary(getString(R.string.permission_summary_enforced_by_policy)); + } + preference.setPersistent(false); + preference.setEnabled(!group.isPolicyFixed()); + preference.setChecked(group.areRuntimePermissionsGranted()); + + if (isPlatform) { + screen.addPreference(preference); + } else { + if (mExtraScreen == null) { + mExtraScreen = getPreferenceManager().createPreferenceScreen(context); + } + mExtraScreen.addPreference(preference); + } + } + + if (mExtraScreen != null) { + extraPerms.setOnPreferenceClickListener(new OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + AdditionalPermissionsFragment frag = new AdditionalPermissionsFragment(); + setPackageName(frag, getArguments().getString(Intent.EXTRA_PACKAGE_NAME)); + frag.setTargetFragment(AppPermissionsFragment.this, 0); + getFragmentManager().beginTransaction() + .replace(android.R.id.content, frag) + .addToBackStack(null) + .commit(); + return true; + } + }); + int count = mExtraScreen.getPreferenceCount(); + extraPerms.setSummary(getResources().getQuantityString( + R.plurals.additional_permissions_more, count, count)); + screen.addPreference(extraPerms); + } + + setLoading(false /* loading */, true /* animate */); + } + + @Override + public boolean onPreferenceChange(final Preference preference, Object newValue) { + String groupName = preference.getKey(); + final AppPermissionGroup group = mAppPermissions.getPermissionGroup(groupName); + + if (group == null) { + return false; + } + + OverlayTouchActivity activity = (OverlayTouchActivity) getActivity(); + if (activity.isObscuredTouch()) { + activity.showOverlayDialog(); + return false; + } + + addToggledGroup(group); + + if (LocationUtils.isLocationGroupAndProvider(group.getName(), group.getApp().packageName)) { + LocationUtils.showLocationDialog(getContext(), mAppPermissions.getAppLabel()); + return false; + } + if (newValue == Boolean.TRUE) { + group.grantRuntimePermissions(false); + } else { + final boolean grantedByDefault = group.hasGrantedByDefaultPermission(); + if (grantedByDefault || (!group.hasRuntimePermission() && !mHasConfirmedRevoke)) { + new AlertDialog.Builder(getContext()) + .setMessage(grantedByDefault ? R.string.system_warning + : R.string.old_sdk_deny_warning) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.grant_dialog_button_deny, + new OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + ((SwitchPreference) preference).setChecked(false); + group.revokeRuntimePermissions(false); + if (!grantedByDefault) { + mHasConfirmedRevoke = true; + } + } + }) + .show(); + return false; + } else { + group.revokeRuntimePermissions(false); + } + } + + return true; + } + + @Override + public void onPause() { + super.onPause(); + logToggledGroups(); + } + + private void addToggledGroup(AppPermissionGroup group) { + if (mToggledGroups == null) { + mToggledGroups = new ArrayList<>(); + } + // Double toggle is back to initial state. + if (mToggledGroups.contains(group)) { + mToggledGroups.remove(group); + } else { + mToggledGroups.add(group); + } + } + + private void logToggledGroups() { + if (mToggledGroups != null) { + String packageName = mAppPermissions.getPackageInfo().packageName; + SafetyNetLogger.logPermissionsToggled(packageName, mToggledGroups); + mToggledGroups = null; + } + } + + private void setPreferencesCheckedState() { + setPreferencesCheckedState(getPreferenceScreen()); + if (mExtraScreen != null) { + setPreferencesCheckedState(mExtraScreen); + } + } + + private void setPreferencesCheckedState(PreferenceScreen screen) { + int preferenceCount = screen.getPreferenceCount(); + for (int i = 0; i < preferenceCount; i++) { + Preference preference = screen.getPreference(i); + if (preference instanceof SwitchPreference) { + SwitchPreference switchPref = (SwitchPreference) preference; + AppPermissionGroup group = mAppPermissions.getPermissionGroup(switchPref.getKey()); + if (group != null) { + switchPref.setChecked(group.areRuntimePermissionsGranted()); + } + } + } + } + + private static PackageInfo getPackageInfo(Activity activity, String packageName) { + try { + return activity.getPackageManager().getPackageInfo( + packageName, PackageManager.GET_PERMISSIONS); + } catch (PackageManager.NameNotFoundException e) { + Log.i(LOG_TAG, "No package:" + activity.getCallingPackage(), e); + return null; + } + } + + public static class AdditionalPermissionsFragment extends SettingsWithHeader { + AppPermissionsFragment mOuterFragment; + + @Override + public void onCreate(Bundle savedInstanceState) { + mOuterFragment = (AppPermissionsFragment) getTargetFragment(); + super.onCreate(savedInstanceState); + setHeader(mOuterFragment.mIcon, mOuterFragment.mLabel, mOuterFragment.mInfoIntent); + setHasOptionsMenu(true); + setPreferenceScreen(mOuterFragment.mExtraScreen); + } + + @Override + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + String packageName = getArguments().getString(Intent.EXTRA_PACKAGE_NAME); + bindUi(this, getPackageInfo(getActivity(), packageName)); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + getFragmentManager().popBackStack(); + return true; + } + return super.onOptionsItemSelected(item); + } + } +} diff --git a/src/com/android/packageinstaller/permission/ui/GrantPermissionsDefaultViewHandler.java b/src/com/android/packageinstaller/permission/ui/handheld/GrantPermissionsViewHandlerImpl.java index c5d78784..2d27f069 100644 --- a/src/com/android/packageinstaller/permission/ui/GrantPermissionsDefaultViewHandler.java +++ b/src/com/android/packageinstaller/permission/ui/handheld/GrantPermissionsViewHandlerImpl.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.packageinstaller.permission.ui; +package com.android.packageinstaller.permission.ui.handheld; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; @@ -40,12 +40,14 @@ import android.widget.CheckBox; import android.widget.ImageView; import android.widget.TextView; -import com.android.internal.widget.ButtonBarLayout; import com.android.packageinstaller.R; +import com.android.packageinstaller.permission.ui.ButtonBarLayout; +import com.android.packageinstaller.permission.ui.GrantPermissionsViewHandler; +import com.android.packageinstaller.permission.ui.ManualLayoutFrame; import java.util.ArrayList; -final class GrantPermissionsDefaultViewHandler +public final class GrantPermissionsViewHandlerImpl implements GrantPermissionsViewHandler, OnClickListener { public static final String ARG_GROUP_NAME = "ARG_GROUP_NAME"; @@ -101,12 +103,12 @@ final class GrantPermissionsDefaultViewHandler } }; - GrantPermissionsDefaultViewHandler(Context context) { + public GrantPermissionsViewHandlerImpl(Context context) { mContext = context; } @Override - public GrantPermissionsDefaultViewHandler setResultListener(ResultListener listener) { + public GrantPermissionsViewHandlerImpl setResultListener(ResultListener listener) { mResultListener = listener; return this; } @@ -314,9 +316,7 @@ final class GrantPermissionsDefaultViewHandler public View createView() { mRootView = (ManualLayoutFrame) LayoutInflater.from(mContext) .inflate(R.layout.grant_permissions, null); - ((ButtonBarLayout) mRootView.findViewById(R.id.button_group)).setAllowStacking( - Resources.getSystem().getBoolean( - com.android.internal.R.bool.allow_stacked_button_bar)); + ((ButtonBarLayout) mRootView.findViewById(R.id.button_group)).setAllowStacking(true); mDialogContainer = (ViewGroup) mRootView.findViewById(R.id.dialog_container); mMessageView = (TextView) mRootView.findViewById(R.id.permission_message); diff --git a/src/com/android/packageinstaller/permission/ui/handheld/ManagePermissionsFragment.java b/src/com/android/packageinstaller/permission/ui/handheld/ManagePermissionsFragment.java new file mode 100644 index 00000000..c53da879 --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/handheld/ManagePermissionsFragment.java @@ -0,0 +1,268 @@ +/* + * Copyright (C) 2015 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.packageinstaller.permission.ui.handheld; + +import android.annotation.Nullable; +import android.app.ActionBar; +import android.app.FragmentTransaction; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.preference.Preference; +import android.preference.Preference.OnPreferenceClickListener; +import android.preference.PreferenceScreen; +import android.util.ArraySet; +import android.util.Log; +import android.view.MenuItem; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; +import com.android.packageinstaller.R; +import com.android.packageinstaller.permission.model.PermissionApps; +import com.android.packageinstaller.permission.model.PermissionApps.PmCache; +import com.android.packageinstaller.permission.model.PermissionGroup; +import com.android.packageinstaller.permission.model.PermissionGroups; +import com.android.packageinstaller.permission.utils.Utils; + +import java.util.List; + +public final class ManagePermissionsFragment extends PermissionsFrameFragment + implements PermissionGroups.PermissionsGroupsChangeCallback, + Preference.OnPreferenceClickListener { + private static final String LOG_TAG = "ManagePermissionsFragment"; + + private static final String OS_PKG = "android"; + + private static final String EXTRA_PREFS_KEY = "extra_prefs_key"; + + private ArraySet<String> mLauncherPkgs; + + private PermissionGroups mPermissions; + + private PreferenceScreen mExtraScreen; + + public static ManagePermissionsFragment newInstance() { + return new ManagePermissionsFragment(); + } + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + setLoading(true /* loading */, false /* animate */); + setHasOptionsMenu(true); + final ActionBar ab = getActivity().getActionBar(); + if (ab != null) { + ab.setDisplayHomeAsUpEnabled(true); + } + mLauncherPkgs = Utils.getLauncherPackages(getContext()); + mPermissions = new PermissionGroups(getActivity(), getLoaderManager(), this); + } + + @Override + public void onResume() { + super.onResume(); + mPermissions.refresh(); + updatePermissionsUi(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + getActivity().finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public boolean onPreferenceClick(Preference preference) { + String key = preference.getKey(); + + PermissionGroup group = mPermissions.getGroup(key); + if (group == null) { + return false; + } + + Intent intent = new Intent(Intent.ACTION_MANAGE_PERMISSION_APPS) + .putExtra(Intent.EXTRA_PERMISSION_NAME, key); + try { + getActivity().startActivity(intent); + } catch (ActivityNotFoundException e) { + Log.w(LOG_TAG, "No app to handle " + intent); + } + + return true; + } + + @Override + public void onPermissionGroupsChanged() { + updatePermissionsUi(); + } + + @Override + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + bindPermissionUi(getActivity(), getView()); + } + + private static void bindPermissionUi(@Nullable Context context, @Nullable View rootView) { + if (context == null || rootView == null) { + return; + } + + ImageView iconView = (ImageView) rootView.findViewById(R.id.lb_icon); + if (iconView != null) { + // Set the icon as the background instead of the image because ImageView + // doesn't properly scale vector drawables beyond their intrinsic size + Drawable icon = context.getDrawable(R.drawable.ic_lock); + icon.setTint(context.getColor(R.color.off_white)); + iconView.setBackground(icon); + } + TextView titleView = (TextView) rootView.findViewById(R.id.lb_title); + if (titleView != null) { + titleView.setText(R.string.app_permissions); + } + TextView breadcrumbView = (TextView) rootView.findViewById(R.id.lb_breadcrumb); + if (breadcrumbView != null) { + breadcrumbView.setText(R.string.app_permissions_breadcrumb); + } + } + + private void updatePermissionsUi() { + Context context = getActivity(); + if (context == null) { + return; + } + + List<PermissionGroup> groups = mPermissions.getGroups(); + PreferenceScreen screen = getPreferenceScreen(); + if (screen == null) { + screen = getPreferenceManager().createPreferenceScreen(getActivity()); + setPreferenceScreen(screen); + } + + // Use this to speed up getting the info for all of the PermissionApps below. + // Create a new one for each refresh to make sure it has fresh data. + PmCache cache = new PmCache(getContext().getPackageManager()); + for (PermissionGroup group : groups) { + boolean isSystemPermission = group.getDeclaringPackage().equals(OS_PKG); + + Preference preference = findPreference(group.getName()); + if (preference == null && mExtraScreen != null) { + preference = mExtraScreen.findPreference(group.getName()); + } + if (preference == null) { + preference = new Preference(context); + preference.setOnPreferenceClickListener(this); + preference.setKey(group.getName()); + preference.setIcon(Utils.applyTint(context, group.getIcon(), + android.R.attr.colorControlNormal)); + preference.setTitle(group.getLabel()); + // Set blank summary so that no resizing/jumping happens when the summary is loaded. + preference.setSummary(" "); + preference.setPersistent(false); + if (isSystemPermission) { + screen.addPreference(preference); + } else { + if (mExtraScreen == null) { + mExtraScreen = getPreferenceManager().createPreferenceScreen(context); + } + mExtraScreen.addPreference(preference); + } + } + final Preference finalPref = preference; + + new PermissionApps(getContext(), group.getName(), new PermissionApps.Callback() { + @Override + public void onPermissionsLoaded(PermissionApps permissionApps) { + if (getActivity() == null) { + return; + } + int granted = permissionApps.getGrantedCount(mLauncherPkgs); + int total = permissionApps.getTotalCount(mLauncherPkgs); + finalPref.setSummary(getString(R.string.app_permissions_group_summary, + granted, total)); + } + }, cache).refresh(false); + } + + if (mExtraScreen != null && mExtraScreen.getPreferenceCount() > 0 + && screen.findPreference(EXTRA_PREFS_KEY) == null) { + Preference extraScreenPreference = new Preference(context); + extraScreenPreference.setKey(EXTRA_PREFS_KEY); + extraScreenPreference.setIcon(Utils.applyTint(context, + R.drawable.ic_more_items, + android.R.attr.colorControlNormal)); + extraScreenPreference.setTitle(R.string.additional_permissions); + extraScreenPreference.setOnPreferenceClickListener(new OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + AdditionalPermissionsFragment frag = new AdditionalPermissionsFragment(); + frag.setTargetFragment(ManagePermissionsFragment.this, 0); + FragmentTransaction ft = getFragmentManager().beginTransaction(); + ft.replace(android.R.id.content, frag); + ft.addToBackStack(null); + ft.commit(); + return true; + } + }); + int count = mExtraScreen.getPreferenceCount(); + extraScreenPreference.setSummary(getResources().getQuantityString( + R.plurals.additional_permissions_more, count, count)); + screen.addPreference(extraScreenPreference); + } + if (screen.getPreferenceCount() != 0) { + setLoading(false /* loading */, true /* animate */); + } + } + + public static class AdditionalPermissionsFragment extends PermissionsFrameFragment { + @Override + public void onCreate(Bundle icicle) { + setLoading(true /* loading */, false /* animate */); + super.onCreate(icicle); + getActivity().setTitle(R.string.additional_permissions); + setHasOptionsMenu(true); + + setPreferenceScreen(((ManagePermissionsFragment) getTargetFragment()).mExtraScreen); + setLoading(false /* loading */, true /* animate */); + } + + @Override + public void onDestroy() { + getActivity().setTitle(R.string.app_permissions); + super.onDestroy(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + getFragmentManager().popBackStack(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + bindPermissionUi(getActivity(), getView()); + } + } +} diff --git a/src/com/android/packageinstaller/permission/ui/handheld/PermissionAppsFragment.java b/src/com/android/packageinstaller/permission/ui/handheld/PermissionAppsFragment.java new file mode 100644 index 00000000..eee2f716 --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/handheld/PermissionAppsFragment.java @@ -0,0 +1,428 @@ +/* + * Copyright (C) 2015 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.packageinstaller.permission.ui.handheld; + +import android.app.ActionBar; +import android.app.AlertDialog; +import android.app.Fragment; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.preference.Preference; +import android.preference.Preference.OnPreferenceClickListener; +import android.preference.PreferenceScreen; +import android.preference.SwitchPreference; +import android.util.ArrayMap; +import android.util.ArraySet; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; +import com.android.packageinstaller.DeviceUtils; +import com.android.packageinstaller.R; +import com.android.packageinstaller.permission.model.AppPermissionGroup; +import com.android.packageinstaller.permission.model.PermissionApps; +import com.android.packageinstaller.permission.model.PermissionApps.Callback; +import com.android.packageinstaller.permission.model.PermissionApps.PermissionApp; +import com.android.packageinstaller.permission.ui.OverlayTouchActivity; +import com.android.packageinstaller.permission.utils.LocationUtils; +import com.android.packageinstaller.permission.utils.SafetyNetLogger; +import com.android.packageinstaller.permission.utils.Utils; + +import java.util.ArrayList; +import java.util.List; + +public final class PermissionAppsFragment extends PermissionsFrameFragment implements Callback, + Preference.OnPreferenceChangeListener { + + private static final int MENU_SHOW_SYSTEM = Menu.FIRST; + private static final int MENU_HIDE_SYSTEM = Menu.FIRST + 1; + private static final String KEY_SHOW_SYSTEM_PREFS = "_showSystem"; + + public static PermissionAppsFragment newInstance(String permissionName) { + return setPermissionName(new PermissionAppsFragment(), permissionName); + } + + private static <T extends Fragment> T setPermissionName(T fragment, String permissionName) { + Bundle arguments = new Bundle(); + arguments.putString(Intent.EXTRA_PERMISSION_NAME, permissionName); + fragment.setArguments(arguments); + return fragment; + } + + private PermissionApps mPermissionApps; + + private PreferenceScreen mExtraScreen; + + private ArrayMap<String, AppPermissionGroup> mToggledGroups; + private ArraySet<String> mLauncherPkgs; + private boolean mHasConfirmedRevoke; + + private boolean mShowSystem; + private MenuItem mShowSystemMenu; + private MenuItem mHideSystemMenu; + + private Callback mOnPermissionsLoadedListener; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setLoading(true /* loading */, false /* animate */); + setHasOptionsMenu(true); + final ActionBar ab = getActivity().getActionBar(); + if (ab != null) { + ab.setDisplayHomeAsUpEnabled(true); + } + mLauncherPkgs = Utils.getLauncherPackages(getContext()); + + String groupName = getArguments().getString(Intent.EXTRA_PERMISSION_NAME); + mPermissionApps = new PermissionApps(getActivity(), groupName, this); + mPermissionApps.refresh(true); + } + + @Override + public void onResume() { + super.onResume(); + mPermissionApps.refresh(true); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + mShowSystemMenu = menu.add(Menu.NONE, MENU_SHOW_SYSTEM, Menu.NONE, + R.string.menu_show_system); + mHideSystemMenu = menu.add(Menu.NONE, MENU_HIDE_SYSTEM, Menu.NONE, + R.string.menu_hide_system); + updateMenu(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + getActivity().finish(); + return true; + case MENU_SHOW_SYSTEM: + case MENU_HIDE_SYSTEM: + mShowSystem = item.getItemId() == MENU_SHOW_SYSTEM; + if (mPermissionApps.getApps() != null) { + onPermissionsLoaded(mPermissionApps); + } + updateMenu(); + break; + } + return super.onOptionsItemSelected(item); + } + + private void updateMenu() { + mShowSystemMenu.setVisible(!mShowSystem); + mHideSystemMenu.setVisible(mShowSystem); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + bindUi(this, mPermissionApps); + } + + private static void bindUi(Fragment fragment, PermissionApps permissionApps) { + final Drawable icon = permissionApps.getIcon(); + final CharSequence label = permissionApps.getLabel(); + final ActionBar ab = fragment.getActivity().getActionBar(); + if (ab != null) { + ab.setTitle(fragment.getString(R.string.permission_title, label)); + } + + final ViewGroup rootView = (ViewGroup) fragment.getView(); + final ImageView iconView = (ImageView) rootView.findViewById(R.id.lb_icon); + if (iconView != null) { + // Set the icon as the background instead of the image because ImageView + // doesn't properly scale vector drawables beyond their intrinsic size + iconView.setBackground(icon); + } + final TextView titleView = (TextView) rootView.findViewById(R.id.lb_title); + if (titleView != null) { + titleView.setText(label); + } + final TextView breadcrumbView = (TextView) rootView.findViewById(R.id.lb_breadcrumb); + if (breadcrumbView != null) { + breadcrumbView.setText(R.string.app_permissions); + } + } + + private void setOnPermissionsLoadedListener(Callback callback) { + mOnPermissionsLoadedListener = callback; + } + + @Override + public void onPermissionsLoaded(PermissionApps permissionApps) { + Context context = getActivity(); + + if (context == null) { + return; + } + + boolean isTelevision = DeviceUtils.isTelevision(context); + PreferenceScreen screen = getPreferenceScreen(); + if (screen == null) { + screen = getPreferenceManager().createPreferenceScreen(getActivity()); + setPreferenceScreen(screen); + } + + ArraySet<String> preferencesToRemove = new ArraySet<>(); + for (int i = 0, n = screen.getPreferenceCount(); i < n; i++) { + preferencesToRemove.add(screen.getPreference(i).getKey()); + } + if (mExtraScreen != null) { + for (int i = 0, n = mExtraScreen.getPreferenceCount(); i < n; i++) { + preferencesToRemove.add(mExtraScreen.getPreference(i).getKey()); + } + } + + for (PermissionApp app : permissionApps.getApps()) { + if (!Utils.shouldShowPermission(app)) { + continue; + } + + String key = app.getKey(); + preferencesToRemove.remove(key); + Preference existingPref = screen.findPreference(key); + if (existingPref == null && mExtraScreen != null) { + existingPref = mExtraScreen.findPreference(key); + } + + boolean isSystemApp = Utils.isSystem(app, mLauncherPkgs); + if (isSystemApp && !isTelevision && !mShowSystem) { + if (existingPref != null) { + screen.removePreference(existingPref); + } + continue; + } + + if (existingPref != null) { + // If existing preference - only update its state. + if (app.isPolicyFixed()) { + existingPref.setSummary(getString( + R.string.permission_summary_enforced_by_policy)); + } + existingPref.setPersistent(false); + existingPref.setEnabled(!app.isPolicyFixed()); + if (existingPref instanceof SwitchPreference) { + ((SwitchPreference) existingPref) + .setChecked(app.areRuntimePermissionsGranted()); + } + continue; + } + + SwitchPreference pref = new SwitchPreference(context); + pref.setOnPreferenceChangeListener(this); + pref.setKey(app.getKey()); + pref.setIcon(app.getIcon()); + pref.setTitle(app.getLabel()); + if (app.isPolicyFixed()) { + pref.setSummary(getString(R.string.permission_summary_enforced_by_policy)); + } + pref.setPersistent(false); + pref.setEnabled(!app.isPolicyFixed()); + pref.setChecked(app.areRuntimePermissionsGranted()); + + if (isSystemApp && isTelevision) { + if (mExtraScreen == null) { + mExtraScreen = getPreferenceManager().createPreferenceScreen(context); + } + mExtraScreen.addPreference(pref); + } else { + screen.addPreference(pref); + } + } + + if (mExtraScreen != null) { + preferencesToRemove.remove(KEY_SHOW_SYSTEM_PREFS); + Preference pref = screen.findPreference(KEY_SHOW_SYSTEM_PREFS); + + if (pref == null) { + pref = new Preference(context); + pref.setKey(KEY_SHOW_SYSTEM_PREFS); + pref.setIcon(Utils.applyTint(context, R.drawable.ic_toc, + android.R.attr.colorControlNormal)); + pref.setTitle(R.string.preference_show_system_apps); + pref.setOnPreferenceClickListener(new OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + SystemAppsFragment frag = new SystemAppsFragment(); + setPermissionName(frag, getArguments().getString(Intent.EXTRA_PERMISSION_NAME)); + frag.setTargetFragment(PermissionAppsFragment.this, 0); + getFragmentManager().beginTransaction() + .replace(android.R.id.content, frag) + .addToBackStack("SystemApps") + .commit(); + return true; + } + }); + screen.addPreference(pref); + } + + int grantedCount = 0; + for (int i = 0, n = mExtraScreen.getPreferenceCount(); i < n; i++) { + if (((SwitchPreference) mExtraScreen.getPreference(i)).isChecked()) { + grantedCount++; + } + } + pref.setSummary(getString(R.string.app_permissions_group_summary, + grantedCount, mExtraScreen.getPreferenceCount())); + } + + for (String key : preferencesToRemove) { + Preference pref = screen.findPreference(key); + if (pref != null) { + screen.removePreference(pref); + } else if (mExtraScreen != null) { + pref = mExtraScreen.findPreference(key); + if (pref != null) { + mExtraScreen.removePreference(pref); + } + } + } + + setLoading(false /* loading */, true /* animate */); + + if (mOnPermissionsLoadedListener != null) { + mOnPermissionsLoadedListener.onPermissionsLoaded(permissionApps); + } + } + + @Override + public boolean onPreferenceChange(final Preference preference, Object newValue) { + String pkg = preference.getKey(); + final PermissionApp app = mPermissionApps.getApp(pkg); + + if (app == null) { + return false; + } + + OverlayTouchActivity activity = (OverlayTouchActivity) getActivity(); + if (activity.isObscuredTouch()) { + activity.showOverlayDialog(); + return false; + } + + addToggledGroup(app.getPackageName(), app.getPermissionGroup()); + + if (LocationUtils.isLocationGroupAndProvider(mPermissionApps.getGroupName(), + app.getPackageName())) { + LocationUtils.showLocationDialog(getContext(), app.getLabel()); + return false; + } + if (newValue == Boolean.TRUE) { + app.grantRuntimePermissions(); + } else { + final boolean grantedByDefault = app.hasGrantedByDefaultPermissions(); + if (grantedByDefault || (!app.hasRuntimePermissions() && !mHasConfirmedRevoke)) { + new AlertDialog.Builder(getContext()) + .setMessage(grantedByDefault ? R.string.system_warning + : R.string.old_sdk_deny_warning) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.grant_dialog_button_deny, + new OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + ((SwitchPreference) preference).setChecked(false); + app.revokeRuntimePermissions(); + if (!grantedByDefault) { + mHasConfirmedRevoke = true; + } + } + }) + .show(); + return false; + } else { + app.revokeRuntimePermissions(); + } + } + return true; + } + + @Override + public void onPause() { + super.onPause(); + logToggledGroups(); + } + + private void addToggledGroup(String packageName, AppPermissionGroup group) { + if (mToggledGroups == null) { + mToggledGroups = new ArrayMap<>(); + } + // Double toggle is back to initial state. + if (mToggledGroups.containsKey(packageName)) { + mToggledGroups.remove(packageName); + } else { + mToggledGroups.put(packageName, group); + } + } + + private void logToggledGroups() { + if (mToggledGroups != null) { + final int groupCount = mToggledGroups.size(); + for (int i = 0; i < groupCount; i++) { + String packageName = mToggledGroups.keyAt(i); + List<AppPermissionGroup> groups = new ArrayList<>(); + groups.add(mToggledGroups.valueAt(i)); + SafetyNetLogger.logPermissionsToggled(packageName, groups); + } + mToggledGroups = null; + } + } + + public static class SystemAppsFragment extends PermissionsFrameFragment implements Callback { + PermissionAppsFragment mOuterFragment; + + @Override + public void onCreate(Bundle savedInstanceState) { + mOuterFragment = (PermissionAppsFragment) getTargetFragment(); + setLoading(true /* loading */, false /* animate */); + super.onCreate(savedInstanceState); + if (mOuterFragment.mExtraScreen != null) { + setPreferenceScreen(); + } else { + mOuterFragment.setOnPermissionsLoadedListener(this); + } + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + String groupName = getArguments().getString(Intent.EXTRA_PERMISSION_NAME); + PermissionApps permissionApps = new PermissionApps(getActivity(), groupName, null); + bindUi(this, permissionApps); + } + + @Override + public void onPermissionsLoaded(PermissionApps permissionApps) { + setPreferenceScreen(); + mOuterFragment.setOnPermissionsLoadedListener(null); + } + + private void setPreferenceScreen() { + setPreferenceScreen(mOuterFragment.mExtraScreen); + setLoading(false /* loading */, true /* animate */); + } + } +} diff --git a/src/com/android/packageinstaller/permission/ui/handheld/PermissionsFrameFragment.java b/src/com/android/packageinstaller/permission/ui/handheld/PermissionsFrameFragment.java new file mode 100644 index 00000000..e7f63b23 --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/handheld/PermissionsFrameFragment.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2015 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.packageinstaller.permission.ui.handheld; + +import android.os.Bundle; +import android.preference.PreferenceFragment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.Animation.AnimationListener; +import android.view.animation.AnimationUtils; +import android.widget.ListView; +import android.widget.TextView; +import com.android.packageinstaller.R; + +public abstract class PermissionsFrameFragment extends PreferenceFragment { + private ViewGroup mPreferencesContainer; + + private View mLoadingView; + private ViewGroup mPrefsView; + private boolean mIsLoading; + + /** + * Returns the view group that holds the preferences objects. This will + * only be set after {@link #onCreateView} has been called. + */ + protected final ViewGroup getPreferencesContainer() { + return mPreferencesContainer; + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.permissions_frame, container, + false); + mPrefsView = (ViewGroup) rootView.findViewById(R.id.prefs_container); + if (mPrefsView == null) { + mPrefsView = rootView; + } + mLoadingView = rootView.findViewById(R.id.loading_container); + mPreferencesContainer = (ViewGroup) super.onCreateView( + inflater, mPrefsView, savedInstanceState); + setLoading(mIsLoading, false, true /* force */); + mPrefsView.addView(mPreferencesContainer); + return rootView; + } + + protected void setLoading(boolean loading, boolean animate) { + setLoading(loading, animate, false); + } + + private void setLoading(boolean loading, boolean animate, boolean force) { + if (mIsLoading != loading || force) { + mIsLoading = loading; + if (getView() == null) { + // If there is no created view, there is no reason to animate. + animate = false; + } + if (mPrefsView != null) { + setViewShown(mPrefsView, !loading, animate); + } + if (mLoadingView != null) { + setViewShown(mLoadingView, loading, animate); + } + } + } + + @Override + public ListView getListView() { + ListView listView = super.getListView(); + if (listView.getEmptyView() == null) { + TextView emptyView = (TextView) getView().findViewById(R.id.no_permissions); + listView.setEmptyView(emptyView); + } + return listView; + } + + private void setViewShown(final View view, boolean shown, boolean animate) { + if (animate) { + Animation animation = AnimationUtils.loadAnimation(getContext(), + shown ? android.R.anim.fade_in : android.R.anim.fade_out); + if (shown) { + view.setVisibility(View.VISIBLE); + } else { + animation.setAnimationListener(new AnimationListener() { + @Override + public void onAnimationStart(Animation animation) { + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + + @Override + public void onAnimationEnd(Animation animation) { + view.setVisibility(View.INVISIBLE); + } + }); + } + view.startAnimation(animation); + } else { + view.clearAnimation(); + view.setVisibility(shown ? View.VISIBLE : View.INVISIBLE); + } + } +} diff --git a/src/com/android/packageinstaller/permission/ui/SettingsWithHeader.java b/src/com/android/packageinstaller/permission/ui/handheld/SettingsWithHeader.java index 7b58fed1..c15a4287 100644 --- a/src/com/android/packageinstaller/permission/ui/SettingsWithHeader.java +++ b/src/com/android/packageinstaller/permission/ui/handheld/SettingsWithHeader.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.packageinstaller.permission.ui; +package com.android.packageinstaller.permission.ui.handheld; import android.content.Intent; import android.graphics.drawable.Drawable; @@ -26,6 +26,7 @@ import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; +import com.android.packageinstaller.DeviceUtils; import com.android.packageinstaller.R; import com.android.packageinstaller.permission.utils.Utils; @@ -42,7 +43,7 @@ public abstract class SettingsWithHeader extends PermissionsFrameFragment Bundle savedInstanceState) { ViewGroup root = (ViewGroup) super.onCreateView(inflater, container, savedInstanceState); - if (!Utils.isTelevision(getContext())) { + if (!DeviceUtils.isTelevision(getContext())) { mHeader = inflater.inflate(R.layout.header, root, false); getPreferencesContainer().addView(mHeader, 0); updateHeader(); @@ -81,5 +82,4 @@ public abstract class SettingsWithHeader extends PermissionsFrameFragment public void onClick(View v) { getActivity().startActivity(mInfoIntent); } - } diff --git a/src/com/android/packageinstaller/permission/ui/AllAppPermissionsFragment.java b/src/com/android/packageinstaller/permission/ui/television/AllAppPermissionsFragment.java index 2fb9a510..d4910128 100644 --- a/src/com/android/packageinstaller/permission/ui/AllAppPermissionsFragment.java +++ b/src/com/android/packageinstaller/permission/ui/television/AllAppPermissionsFragment.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.packageinstaller.permission.ui; +package com.android.packageinstaller.permission.ui.television; import android.app.ActionBar; import android.app.AlertDialog; diff --git a/src/com/android/packageinstaller/permission/ui/AppPermissionsFragment.java b/src/com/android/packageinstaller/permission/ui/television/AppPermissionsFragment.java index 6396c61e..42a2661c 100644 --- a/src/com/android/packageinstaller/permission/ui/AppPermissionsFragment.java +++ b/src/com/android/packageinstaller/permission/ui/television/AppPermissionsFragment.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.packageinstaller.permission.ui; +package com.android.packageinstaller.permission.ui.television; import android.annotation.Nullable; import android.app.ActionBar; @@ -50,6 +50,7 @@ import android.widget.Toast; import com.android.packageinstaller.R; import com.android.packageinstaller.permission.model.AppPermissionGroup; import com.android.packageinstaller.permission.model.AppPermissions; +import com.android.packageinstaller.permission.ui.OverlayTouchActivity; import com.android.packageinstaller.permission.utils.LocationUtils; import com.android.packageinstaller.permission.utils.SafetyNetLogger; import com.android.packageinstaller.permission.utils.Utils; diff --git a/src/com/android/packageinstaller/permission/ui/GrantPermissionsTvViewHandler.java b/src/com/android/packageinstaller/permission/ui/television/GrantPermissionsViewHandlerImpl.java index 0e979ab6..a2538821 100644 --- a/src/com/android/packageinstaller/permission/ui/GrantPermissionsTvViewHandler.java +++ b/src/com/android/packageinstaller/permission/ui/television/GrantPermissionsViewHandlerImpl.java @@ -1,4 +1,4 @@ -package com.android.packageinstaller.permission.ui; +package com.android.packageinstaller.permission.ui.television; import android.content.Context; import android.graphics.PixelFormat; @@ -15,11 +15,12 @@ import android.widget.LinearLayout; import android.widget.TextView; import com.android.packageinstaller.R; +import com.android.packageinstaller.permission.ui.GrantPermissionsViewHandler; /** * TV-specific view handler for the grant permissions activity. */ -final class GrantPermissionsTvViewHandler implements GrantPermissionsViewHandler, OnClickListener { +public final class GrantPermissionsViewHandlerImpl implements GrantPermissionsViewHandler, OnClickListener { private static final String ARG_GROUP_NAME = "ARG_GROUP_NAME"; @@ -37,12 +38,12 @@ final class GrantPermissionsTvViewHandler implements GrantPermissionsViewHandler private Button mSoftDenyButton; private Button mHardDenyButton; - GrantPermissionsTvViewHandler(Context context) { + public GrantPermissionsViewHandlerImpl(Context context) { mContext = context; } @Override - public GrantPermissionsTvViewHandler setResultListener(ResultListener listener) { + public GrantPermissionsViewHandlerImpl setResultListener(ResultListener listener) { mResultListener = listener; return this; } diff --git a/src/com/android/packageinstaller/permission/ui/ManagePermissionsFragment.java b/src/com/android/packageinstaller/permission/ui/television/ManagePermissionsFragment.java index e5e06e09..47301f48 100644 --- a/src/com/android/packageinstaller/permission/ui/ManagePermissionsFragment.java +++ b/src/com/android/packageinstaller/permission/ui/television/ManagePermissionsFragment.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.packageinstaller.permission.ui; +package com.android.packageinstaller.permission.ui.television; import android.annotation.Nullable; import android.app.ActionBar; @@ -202,7 +202,7 @@ public final class ManagePermissionsFragment extends PermissionsFrameFragment Preference extraScreenPreference = new Preference(context); extraScreenPreference.setKey(EXTRA_PREFS_KEY); extraScreenPreference.setIcon(Utils.applyTint(context, - com.android.internal.R.drawable.ic_more_items, + R.drawable.ic_more_items, android.R.attr.colorControlNormal)); extraScreenPreference.setTitle(R.string.additional_permissions); extraScreenPreference.setOnPreferenceClickListener(new OnPreferenceClickListener() { diff --git a/src/com/android/packageinstaller/permission/ui/PermissionAppsFragment.java b/src/com/android/packageinstaller/permission/ui/television/PermissionAppsFragment.java index 8dacd037..0f240bef 100644 --- a/src/com/android/packageinstaller/permission/ui/PermissionAppsFragment.java +++ b/src/com/android/packageinstaller/permission/ui/television/PermissionAppsFragment.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.packageinstaller.permission.ui; +package com.android.packageinstaller.permission.ui.television; import android.app.ActionBar; import android.app.AlertDialog; @@ -39,11 +39,13 @@ import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; +import com.android.packageinstaller.DeviceUtils; import com.android.packageinstaller.R; import com.android.packageinstaller.permission.model.AppPermissionGroup; import com.android.packageinstaller.permission.model.PermissionApps; import com.android.packageinstaller.permission.model.PermissionApps.Callback; import com.android.packageinstaller.permission.model.PermissionApps.PermissionApp; +import com.android.packageinstaller.permission.ui.OverlayTouchActivity; import com.android.packageinstaller.permission.utils.LocationUtils; import com.android.packageinstaller.permission.utils.SafetyNetLogger; import com.android.packageinstaller.permission.utils.Utils; @@ -185,7 +187,7 @@ public final class PermissionAppsFragment extends PermissionsFrameFragment imple return; } - boolean isTelevision = Utils.isTelevision(context); + boolean isTelevision = DeviceUtils.isTelevision(context); PreferenceScreen screen = getPreferenceScreen(); ArraySet<String> preferencesToRemove = new ArraySet<>(); diff --git a/src/com/android/packageinstaller/permission/ui/PermissionsFrameFragment.java b/src/com/android/packageinstaller/permission/ui/television/PermissionsFrameFragment.java index 40058f6d..e81aee86 100644 --- a/src/com/android/packageinstaller/permission/ui/PermissionsFrameFragment.java +++ b/src/com/android/packageinstaller/permission/ui/television/PermissionsFrameFragment.java @@ -1,4 +1,20 @@ -package com.android.packageinstaller.permission.ui; +/* + * Copyright (C) 2015 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.packageinstaller.permission.ui.television; import android.annotation.Nullable; import android.os.Bundle; @@ -15,6 +31,7 @@ import android.view.animation.Animation.AnimationListener; import android.view.animation.AnimationUtils; import android.widget.TextView; +import com.android.packageinstaller.DeviceUtils; import com.android.packageinstaller.R; import com.android.packageinstaller.permission.utils.Utils; @@ -117,7 +134,7 @@ public abstract class PermissionsFrameFragment extends PreferenceFragment { @Override public RecyclerView onCreateRecyclerView(LayoutInflater inflater, ViewGroup parent, Bundle savedInstanceState) { - if (Utils.isTelevision(getContext())) { + if (DeviceUtils.isTelevision(getContext())) { mGridView = (VerticalGridView) inflater.inflate( R.layout.leanback_preferences_list, parent, false); mGridView.setWindowAlignmentOffset(0); diff --git a/src/com/android/packageinstaller/permission/ui/television/SettingsWithHeader.java b/src/com/android/packageinstaller/permission/ui/television/SettingsWithHeader.java new file mode 100644 index 00000000..4dae629c --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/television/SettingsWithHeader.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2015 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.packageinstaller.permission.ui.television; + +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.packageinstaller.DeviceUtils; +import com.android.packageinstaller.R; +import com.android.packageinstaller.permission.utils.Utils; + +public abstract class SettingsWithHeader extends PermissionsFrameFragment + implements OnClickListener { + + private View mHeader; + protected Intent mInfoIntent; + protected Drawable mIcon; + protected CharSequence mLabel; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + ViewGroup root = (ViewGroup) super.onCreateView(inflater, container, savedInstanceState); + + if (!DeviceUtils.isTelevision(getContext())) { + mHeader = inflater.inflate(R.layout.header, root, false); + getPreferencesContainer().addView(mHeader, 0); + updateHeader(); + } + + return root; + } + + public void setHeader(Drawable icon, CharSequence label, Intent infoIntent) { + mIcon = icon; + mLabel = label; + mInfoIntent = infoIntent; + updateHeader(); + } + + private void updateHeader() { + if (mHeader != null) { + final ImageView appIcon = (ImageView) mHeader.findViewById(R.id.icon); + appIcon.setImageDrawable(mIcon); + + final TextView appName = (TextView) mHeader.findViewById(R.id.name); + appName.setText(mLabel); + + final View info = mHeader.findViewById(R.id.info); + if (mInfoIntent == null) { + info.setVisibility(View.GONE); + } else { + info.setVisibility(View.VISIBLE); + info.setClickable(true); + info.setOnClickListener(this); + } + } + } + + @Override + public void onClick(View v) { + getActivity().startActivity(mInfoIntent); + } + +} diff --git a/src/com/android/packageinstaller/permission/ui/wear/AppPermissionsFragmentWear.java b/src/com/android/packageinstaller/permission/ui/wear/AppPermissionsFragmentWear.java new file mode 100644 index 00000000..aba97fc8 --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/wear/AppPermissionsFragmentWear.java @@ -0,0 +1,335 @@ +/* +* Copyright (C) 2015 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.packageinstaller.permission.ui.wear; + +import android.Manifest; +import android.annotation.Nullable; +import android.app.Activity; +import android.app.Fragment; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.support.wearable.view.WearableListView; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import com.android.packageinstaller.R; +import com.android.packageinstaller.permission.model.AppPermissionGroup; +import com.android.packageinstaller.permission.model.AppPermissions; +import com.android.packageinstaller.permission.ui.OverlayTouchActivity; +import com.android.packageinstaller.permission.ui.wear.settings.PermissionsSettingsAdapter; +import com.android.packageinstaller.permission.ui.wear.settings.SettingsAdapter; +import com.android.packageinstaller.permission.utils.LocationUtils; +import com.android.packageinstaller.permission.utils.SafetyNetLogger; +import com.android.packageinstaller.permission.utils.Utils; + +import java.util.ArrayList; +import java.util.List; + +public final class AppPermissionsFragmentWear extends TitledSettingsFragment { + + private static final String LOG_TAG = "ManagePermsFragment"; + + private static final int WARNING_CONFIRMATION_REQUEST = 252; + private List<AppPermissionGroup> mToggledGroups; + private AppPermissions mAppPermissions; + private PermissionsSettingsAdapter mAdapter; + + private boolean mHasConfirmedRevoke; + + public static AppPermissionsFragmentWear newInstance(String packageName) { + return setPackageName(new AppPermissionsFragmentWear(), packageName); + } + + private static <T extends Fragment> T setPackageName(T fragment, String packageName) { + Bundle arguments = new Bundle(); + arguments.putString(Intent.EXTRA_PACKAGE_NAME, packageName); + fragment.setArguments(arguments); + return fragment; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + String packageName = getArguments().getString(Intent.EXTRA_PACKAGE_NAME); + Activity activity = getActivity(); + PackageManager pm = activity.getPackageManager(); + PackageInfo packageInfo; + + try { + packageInfo = pm.getPackageInfo(packageName, PackageManager.GET_PERMISSIONS); + } catch (PackageManager.NameNotFoundException e) { + Log.i(LOG_TAG, "No package:" + activity.getCallingPackage(), e); + packageInfo = null; + } + + if (packageInfo == null) { + Toast.makeText(activity, R.string.app_not_found_dlg_title, Toast.LENGTH_LONG).show(); + activity.finish(); + return; + } + + mAppPermissions = new AppPermissions(activity, packageInfo, null, true, new Runnable() { + @Override + public void run() { + getActivity().finish(); + } + }); + + mAdapter = new PermissionsSettingsAdapter(getContext()); + + initializePermissionGroupList(); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + return inflater.inflate(R.layout.settings, container, false); + } + + @Override + public void onResume() { + super.onResume(); + mAppPermissions.refresh(); + + // Also refresh the UI + final int count = mAdapter.getItemCount(); + for (int i = 0; i < count; ++i) { + updatePermissionGroupSetting(i); + } + } + + @Override + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + if (mAppPermissions != null) { + initializeLayout(mAdapter); + bindHeader(mAppPermissions.getPackageInfo()); + } + } + + private void bindHeader(PackageInfo packageInfo) { + Activity activity = getActivity(); + PackageManager pm = activity.getPackageManager(); + ApplicationInfo appInfo = packageInfo.applicationInfo; + CharSequence label = appInfo.loadLabel(pm); + mHeader.setText(label); + } + + private void initializePermissionGroupList() { + final String packageName = mAppPermissions.getPackageInfo().packageName; + List<AppPermissionGroup> groups = mAppPermissions.getPermissionGroups(); + List<SettingsAdapter.Setting<AppPermissionGroup>> nonSystemGroups = new ArrayList<>(); + + final int count = groups.size(); + for (int i = 0; i < count; ++i) { + final AppPermissionGroup group = groups.get(i); + if (!Utils.shouldShowPermission(group, packageName)) { + continue; + } + + boolean isPlatform = group.getDeclaringPackage().equals(Utils.OS_PKG); + + SettingsAdapter.Setting<AppPermissionGroup> setting = + new SettingsAdapter.Setting<AppPermissionGroup>( + group.getLabel(), + getPermissionGroupIcon(group), + i); + setting.data = group; + + // The UI shows System settings first, then non-system settings + if (isPlatform) { + mAdapter.addSetting(setting); + } else { + nonSystemGroups.add(setting); + } + } + + // Now add the non-system settings to the end of the list + final int nonSystemCount = nonSystemGroups.size(); + for (int i = 0; i < nonSystemCount; ++i) { + final SettingsAdapter.Setting<AppPermissionGroup> setting = nonSystemGroups.get(i); + mAdapter.addSetting(setting); + } + } + + @Override + public void onPause() { + super.onPause(); + logAndClearToggledGroups(); + } + + @Override + public void onClick(WearableListView.ViewHolder view) { + final int index = view.getPosition(); + SettingsAdapter.Setting<AppPermissionGroup> setting = mAdapter.get(index); + final AppPermissionGroup group = setting.data; + + if (group == null) { + Log.e(LOG_TAG, "Error: AppPermissionGroup is null"); + return; + } + + // The way WearableListView is designed, there is no way to avoid this click handler + // Since the policy is fixed, ignore the click as the user is not able to change the state + // of this permission group + if (group.isPolicyFixed()) { + return; + } + + OverlayTouchActivity activity = (OverlayTouchActivity) getActivity(); + if (activity.isObscuredTouch()) { + activity.showOverlayDialog(); + return; + } + + addToggledGroup(group); + + if (LocationUtils.isLocationGroupAndProvider(group.getName(), group.getApp().packageName)) { + LocationUtils.showLocationDialog(getContext(), mAppPermissions.getAppLabel()); + return; + } + + if (!group.areRuntimePermissionsGranted()) { + group.grantRuntimePermissions(false); + } else { + final boolean grantedByDefault = group.hasGrantedByDefaultPermission(); + if (grantedByDefault || (!group.hasRuntimePermission() && !mHasConfirmedRevoke)) { + Intent intent = new Intent(getActivity(), WarningConfirmationActivity.class); + intent.putExtra(WarningConfirmationActivity.EXTRA_WARNING_MESSAGE, + getString(grantedByDefault ? + R.string.system_warning : R.string.old_sdk_deny_warning)); + intent.putExtra(WarningConfirmationActivity.EXTRA_INDEX, index); + startActivityForResult(intent, WARNING_CONFIRMATION_REQUEST); + } else { + group.revokeRuntimePermissions(false); + } + } + + updatePermissionGroupSetting(index); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == WARNING_CONFIRMATION_REQUEST) { + if (resultCode == Activity.RESULT_OK) { + int index = data.getIntExtra(WarningConfirmationActivity.EXTRA_INDEX, -1); + if (index == -1) { + Log.e(LOG_TAG, "Warning confirmation request came back with no index."); + return; + } + + SettingsAdapter.Setting<AppPermissionGroup> setting = mAdapter.get(index); + final AppPermissionGroup group = setting.data; + group.revokeRuntimePermissions(false); + if (!group.hasGrantedByDefaultPermission()) { + mHasConfirmedRevoke = true; + } + + updatePermissionGroupSetting(index); + } + } else { + super.onActivityResult(requestCode, resultCode, data); + } + } + + private void updatePermissionGroupSetting(int index) { + SettingsAdapter.Setting<AppPermissionGroup> setting = mAdapter.get(index); + AppPermissionGroup group = setting.data; + mAdapter.updateSetting( + index, + group.getLabel(), + getPermissionGroupIcon(group), + group); + } + + private void addToggledGroup(AppPermissionGroup group) { + if (mToggledGroups == null) { + mToggledGroups = new ArrayList<>(); + } + // Double toggle is back to initial state. + if (mToggledGroups.contains(group)) { + mToggledGroups.remove(group); + } else { + mToggledGroups.add(group); + } + } + + private void logAndClearToggledGroups() { + if (mToggledGroups != null) { + String packageName = mAppPermissions.getPackageInfo().packageName; + SafetyNetLogger.logPermissionsToggled(packageName, mToggledGroups); + mToggledGroups = null; + } + } + + private int getPermissionGroupIcon(AppPermissionGroup group) { + String groupName = group.getName(); + boolean isEnabled = group.areRuntimePermissionsGranted(); + int resId; + + switch (groupName) { + case Manifest.permission_group.CALENDAR: + resId = isEnabled ? R.drawable.ic_permission_calendar + : R.drawable.ic_permission_calendardisable; + break; + case Manifest.permission_group.CAMERA: + resId = isEnabled ? R.drawable.ic_permission_camera + : R.drawable.ic_permission_cameradisable; + break; + case Manifest.permission_group.CONTACTS: + resId = isEnabled ? R.drawable.ic_permission_contact + : R.drawable.ic_permission_contactdisable; + break; + case Manifest.permission_group.LOCATION: + resId = isEnabled ? R.drawable.ic_permission_location + : R.drawable.ic_permission_locationdisable; + break; + case Manifest.permission_group.MICROPHONE: + resId = isEnabled ? R.drawable.ic_permission_mic + : R.drawable.ic_permission_micdisable; + break; + case Manifest.permission_group.PHONE: + resId = isEnabled ? R.drawable.ic_permission_call + : R.drawable.ic_permission_calldisable; + break; + case Manifest.permission_group.SENSORS: + resId = isEnabled ? R.drawable.ic_permission_sensor + : R.drawable.ic_permission_sensordisable; + break; + case Manifest.permission_group.SMS: + resId = isEnabled ? R.drawable.ic_permission_sms + : R.drawable.ic_permission_smsdisable; + break; + case Manifest.permission_group.STORAGE: + resId = isEnabled ? R.drawable.ic_permission_storage + : R.drawable.ic_permission_storagedisable; + break; + default: + resId = isEnabled ? R.drawable.ic_permission_shield + : R.drawable.ic_permission_shielddisable; + } + + return resId; + } +} diff --git a/src/com/android/packageinstaller/permission/ui/wear/ConfirmationViewHandler.java b/src/com/android/packageinstaller/permission/ui/wear/ConfirmationViewHandler.java new file mode 100644 index 00000000..1c55e1bd --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/wear/ConfirmationViewHandler.java @@ -0,0 +1,381 @@ +package com.android.packageinstaller.permission.ui.wear; + +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.animation.AnimationUtils; +import android.view.animation.Interpolator; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.ScrollView; +import android.widget.TextView; + +import com.android.packageinstaller.R; + +public abstract class ConfirmationViewHandler implements + Handler.Callback, + View.OnClickListener, + ViewTreeObserver.OnScrollChangedListener, + ViewTreeObserver.OnGlobalLayoutListener { + private static final String TAG = "ConfirmationViewHandler"; + + public static final int MODE_HORIZONTAL_BUTTONS = 0; + public static final int MODE_VERTICAL_BUTTONS = 1; + + private static final int MSG_SHOW_BUTTON_BAR = 1001; + private static final int MSG_HIDE_BUTTON_BAR = 1002; + private static final long HIDE_ANIM_DURATION = 500; + + private View mRoot; + private TextView mCurrentPageText; + private ImageView mIcon; + private TextView mMessage; + private ScrollView mScrollingContainer; + private ViewGroup mContent; + private ViewGroup mHorizontalButtonBar; + private ViewGroup mVerticalButtonBar; + private Button mVerticalButton1; + private Button mVerticalButton2; + private Button mVerticalButton3; + private View mButtonBarContainer; + + private Context mContext; + + private Handler mHideHandler; + private Interpolator mInterpolator; + private float mButtonBarFloatingHeight; + private ObjectAnimator mButtonBarAnimator; + private float mCurrentTranslation; + private boolean mHiddenBefore; + + // TODO: Move these into a builder + /** In the 2 button layout, this is allow button */ + public abstract void onButton1(); + /** In the 2 button layout, this is deny button */ + public abstract void onButton2(); + public abstract void onButton3(); + public abstract CharSequence getVerticalButton1Text(); + public abstract CharSequence getVerticalButton2Text(); + public abstract CharSequence getVerticalButton3Text(); + public abstract Drawable getVerticalButton1Icon(); + public abstract Drawable getVerticalButton2Icon(); + public abstract Drawable getVerticalButton3Icon(); + public abstract CharSequence getCurrentPageText(); + public abstract Icon getPermissionIcon(); + public abstract CharSequence getMessage(); + + public ConfirmationViewHandler(Context context) { + mContext = context; + } + + public View createView() { + mRoot = LayoutInflater.from(mContext).inflate(R.layout.confirmation_dialog, null); + + mMessage = (TextView) mRoot.findViewById(R.id.message); + mCurrentPageText = (TextView) mRoot.findViewById(R.id.current_page_text); + mIcon = (ImageView) mRoot.findViewById(R.id.icon); + mButtonBarContainer = mRoot.findViewById(R.id.button_bar_container); + mContent = (ViewGroup) mRoot.findViewById(R.id.content); + mScrollingContainer = (ScrollView) mRoot.findViewById(R.id.scrolling_container); + mHorizontalButtonBar = (ViewGroup) mRoot.findViewById(R.id.horizontal_button_bar); + mVerticalButtonBar = (ViewGroup) mRoot.findViewById(R.id.vertical_button_bar); + + Button horizontalAllow = (Button) mRoot.findViewById(R.id.permission_allow_button); + Button horizontalDeny = (Button) mRoot.findViewById(R.id.permission_deny_button); + horizontalAllow.setOnClickListener(this); + horizontalDeny.setOnClickListener(this); + + mVerticalButton1 = (Button) mRoot.findViewById(R.id.vertical_button1); + mVerticalButton2 = (Button) mRoot.findViewById(R.id.vertical_button2); + mVerticalButton3 = (Button) mRoot.findViewById(R.id.vertical_button3); + mVerticalButton1.setOnClickListener(this); + mVerticalButton2.setOnClickListener(this); + mVerticalButton3.setOnClickListener(this); + + mInterpolator = AnimationUtils.loadInterpolator(mContext, + android.R.interpolator.fast_out_slow_in); + mButtonBarFloatingHeight = mContext.getResources().getDimension( + R.dimen.conf_diag_floating_height); + mHideHandler = new Handler(Looper.getMainLooper(), this); + + mScrollingContainer.getViewTreeObserver().addOnScrollChangedListener(this); + mRoot.getViewTreeObserver().addOnGlobalLayoutListener(this); + + return mRoot; + } + + /** + * Child class should override this for other modes. Call invalidate() to update the UI to the + * new button mode. + * @return The current mode the layout should use for the buttons + */ + public int getButtonBarMode() { + return MODE_HORIZONTAL_BUTTONS; + } + + public void invalidate() { + CharSequence currentPageText = getCurrentPageText(); + if (!TextUtils.isEmpty(currentPageText)) { + mCurrentPageText.setText(currentPageText); + mCurrentPageText.setVisibility(View.VISIBLE); + } else { + mCurrentPageText.setVisibility(View.GONE); + } + + Icon icon = getPermissionIcon(); + if (icon != null) { + mIcon.setImageIcon(icon); + mIcon.setVisibility(View.VISIBLE); + } else { + mIcon.setVisibility(View.GONE); + } + mMessage.setText(getMessage()); + + switch (getButtonBarMode()) { + case MODE_HORIZONTAL_BUTTONS: + mHorizontalButtonBar.setVisibility(View.VISIBLE); + mVerticalButtonBar.setVisibility(View.GONE); + break; + case MODE_VERTICAL_BUTTONS: + mHorizontalButtonBar.setVisibility(View.GONE); + mVerticalButtonBar.setVisibility(View.VISIBLE); + + mVerticalButton1.setText(getVerticalButton1Text()); + mVerticalButton2.setText(getVerticalButton2Text()); + + mVerticalButton1.setCompoundDrawablesWithIntrinsicBounds( + getVerticalButton1Icon(), null, null, null); + mVerticalButton2.setCompoundDrawablesWithIntrinsicBounds( + getVerticalButton2Icon(), null, null, null); + + CharSequence verticalButton3Text = getVerticalButton3Text(); + if (TextUtils.isEmpty(verticalButton3Text)) { + mVerticalButton3.setVisibility(View.GONE); + } else { + mVerticalButton3.setText(getVerticalButton3Text()); + mVerticalButton3.setCompoundDrawablesWithIntrinsicBounds( + getVerticalButton3Icon(), null, null, null); + } + break; + } + + mScrollingContainer.scrollTo(0, 0); + + mHideHandler.removeMessages(MSG_HIDE_BUTTON_BAR); + mHideHandler.removeMessages(MSG_SHOW_BUTTON_BAR); + } + + @Override + public void onGlobalLayout() { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onGlobalLayout"); + Log.d(TAG, " contentHeight: " + mContent.getHeight()); + } + + if (mButtonBarAnimator != null) { + mButtonBarAnimator.cancel(); + } + + // In order to fake the buttons peeking at the bottom, need to do set the + // padding properly. + if (mContent.getPaddingBottom() != mButtonBarContainer.getHeight()) { + mContent.setPadding(mContent.getPaddingLeft(), mContent.getPaddingTop(), + mContent.getPaddingRight(), mButtonBarContainer.getHeight()); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, " set mContent.PaddingBottom: " + mButtonBarContainer.getHeight()); + } + } + + mButtonBarContainer.setTranslationY(mButtonBarContainer.getHeight()); + + // Give everything a chance to render + mHideHandler.removeMessages(MSG_HIDE_BUTTON_BAR); + mHideHandler.removeMessages(MSG_SHOW_BUTTON_BAR); + mHideHandler.sendEmptyMessageDelayed(MSG_SHOW_BUTTON_BAR, 50); + } + + @Override + public void onClick(View v) { + int id = v.getId(); + switch (id) { + case R.id.permission_allow_button: + case R.id.vertical_button1: + onButton1(); + break; + case R.id.permission_deny_button: + case R.id.vertical_button2: + onButton2(); + break; + case R.id.vertical_button3: + onButton3(); + break; + } + } + + @Override + public boolean handleMessage (Message msg) { + switch (msg.what) { + case MSG_SHOW_BUTTON_BAR: + showButtonBar(); + return true; + case MSG_HIDE_BUTTON_BAR: + hideButtonBar(); + return true; + } + return false; + } + + @Override + public void onScrollChanged () { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onScrollChanged"); + } + mHideHandler.removeMessages(MSG_HIDE_BUTTON_BAR); + hideButtonBar(); + } + + private void showButtonBar() { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "showButtonBar"); + } + + // Setup Button animation. + // pop the button bar back to full height, stop all animation + if (mButtonBarAnimator != null) { + mButtonBarAnimator.cancel(); + } + + // stop any calls to hide the button bar in the future + mHideHandler.removeMessages(MSG_HIDE_BUTTON_BAR); + mHiddenBefore = false; + + // Evaluate the max height the button bar can go + final int screenHeight = mRoot.getHeight(); + final int halfScreenHeight = screenHeight / 2; + final int buttonBarHeight = mButtonBarContainer.getHeight(); + final int contentHeight = mContent.getHeight() - buttonBarHeight; + final int buttonBarMaxHeight = + Math.min(buttonBarHeight, halfScreenHeight); + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, " screenHeight: " + screenHeight); + Log.d(TAG, " contentHeight: " + contentHeight); + Log.d(TAG, " buttonBarHeight: " + buttonBarHeight); + Log.d(TAG, " buttonBarMaxHeight: " + buttonBarMaxHeight); + } + + mButtonBarContainer.setTranslationZ(mButtonBarFloatingHeight); + + // Only hide the button bar if it is occluding the content or the button bar is bigger than + // half the screen + if (contentHeight > (screenHeight - buttonBarHeight) + || buttonBarHeight > halfScreenHeight) { + mHideHandler.sendEmptyMessageDelayed(MSG_HIDE_BUTTON_BAR, 3000); + } + + generateButtonBarAnimator(buttonBarHeight, + buttonBarHeight - buttonBarMaxHeight, 0, mButtonBarFloatingHeight, 1000); + } + + private void hideButtonBar() { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "hideButtonBar"); + } + + // The desired margin space between the button bar and the bottom of the dialog text + final int topMargin = mContext.getResources().getDimensionPixelSize( + R.dimen.conf_diag_button_container_top_margin); + final int contentHeight = mContent.getHeight() + topMargin; + final int screenHeight = mRoot.getHeight(); + final int buttonBarHeight = mButtonBarContainer.getHeight(); + + final int offset = screenHeight + buttonBarHeight + - contentHeight + Math.max(mScrollingContainer.getScrollY(), 0); + final int translationY = (offset > 0 ? + mButtonBarContainer.getHeight() - offset : mButtonBarContainer.getHeight()); + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, " topMargin: " + topMargin); + Log.d(TAG, " contentHeight: " + contentHeight); + Log.d(TAG, " screenHeight: " + screenHeight); + Log.d(TAG, " offset: " + offset); + Log.d(TAG, " buttonBarHeight: " + buttonBarHeight); + Log.d(TAG, " mContent.getPaddingBottom(): " + mContent.getPaddingBottom()); + Log.d(TAG, " mScrollingContainer.getScrollY(): " + mScrollingContainer.getScrollY()); + Log.d(TAG, " translationY: " + translationY); + } + + if (!mHiddenBefore || mButtonBarAnimator == null) { + // Remove previous call to MSG_SHOW_BUTTON_BAR if the user scrolled or something before + // the animation got a chance to play + mHideHandler.removeMessages(MSG_SHOW_BUTTON_BAR); + + if(mButtonBarAnimator != null) { + mButtonBarAnimator.cancel(); // stop current animation if there is one playing + } + + // hasn't hidden the bar yet, just hide now to the right height + generateButtonBarAnimator( + mButtonBarContainer.getTranslationY(), translationY, + mButtonBarFloatingHeight, 0, HIDE_ANIM_DURATION); + } else if (mButtonBarAnimator.isRunning()) { + // we are animating the button bar closing, change to animate to the right place + if (Math.abs(mCurrentTranslation - translationY) > 1e-2f) { + mButtonBarAnimator.cancel(); // stop current animation + + if (Math.abs(mButtonBarContainer.getTranslationY() - translationY) > 1e-2f) { + long duration = Math.max((long) ( + (float) HIDE_ANIM_DURATION + * (translationY - mButtonBarContainer.getTranslationY()) + / mButtonBarContainer.getHeight()), 0); + + generateButtonBarAnimator( + mButtonBarContainer.getTranslationY(), translationY, + mButtonBarFloatingHeight, 0, duration); + } else { + mButtonBarContainer.setTranslationY(translationY); + mButtonBarContainer.setTranslationZ(0); + } + } + } else { + // not currently animating, have already hidden, snap to the right offset + mButtonBarContainer.setTranslationY(translationY); + mButtonBarContainer.setTranslationZ(0); + } + + mHiddenBefore = true; + } + + private void generateButtonBarAnimator( + float startY, float endY, float startZ, float endZ, long duration) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "generateButtonBarAnimator"); + Log.d(TAG, " startY: " + startY); + Log.d(TAG, " endY: " + endY); + Log.d(TAG, " startZ: " + startZ); + Log.d(TAG, " endZ: " + endZ); + Log.d(TAG, " duration: " + duration); + } + + mButtonBarAnimator = + ObjectAnimator.ofPropertyValuesHolder( + mButtonBarContainer, + PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, startY, endY), + PropertyValuesHolder.ofFloat(View.TRANSLATION_Z, startZ, endZ)); + mCurrentTranslation = endY; + mButtonBarAnimator.setDuration(duration); + mButtonBarAnimator.setInterpolator(mInterpolator); + mButtonBarAnimator.start(); + } +} diff --git a/src/com/android/packageinstaller/permission/ui/wear/TitledSettingsFragment.java b/src/com/android/packageinstaller/permission/ui/wear/TitledSettingsFragment.java new file mode 100644 index 00000000..ef7efb28 --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/wear/TitledSettingsFragment.java @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2015 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.packageinstaller.permission.ui.wear; + +import android.app.Fragment; +import android.content.Context; +import android.os.Bundle; +import android.support.v7.widget.RecyclerView; +import android.support.wearable.view.WearableListView; +import android.text.Editable; +import android.text.TextWatcher; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowInsets; +import android.widget.FrameLayout; +import android.widget.TextView; + +import com.android.packageinstaller.permission.ui.wear.settings.ViewUtils; +import com.android.packageinstaller.R; + +/** + * Base settings Fragment that shows a title at the top of the page. + */ +public abstract class TitledSettingsFragment extends Fragment implements + View.OnLayoutChangeListener, WearableListView.ClickListener { + + private static final int ITEM_CHANGE_DURATION_MS = 120; + + private static final String TAG = "TitledSettingsFragment"; + private int mInitialHeaderHeight; + + protected TextView mHeader; + protected WearableListView mWheel; + + private int mCharLimitShortTitle; + private int mCharLimitLine; + private int mChinOffset; + + private TextWatcher mHeaderTextWatcher = new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + + @Override + public void afterTextChanged(Editable editable) { + adjustHeaderSize(); + } + }; + + private void adjustHeaderTranslation() { + int translation = 0; + if (mWheel.getChildCount() > 0) { + translation = mWheel.getCentralViewTop() - mWheel.getChildAt(0).getTop(); + } + + float newTranslation = Math.min(Math.max(-mInitialHeaderHeight, -translation), 0); + + int position = mWheel.getChildAdapterPosition(mWheel.getChildAt(0)); + if (position == 0 || newTranslation < 0) { + mHeader.setTranslationY(newTranslation); + } + } + + @Override + public void onTopEmptyRegionClick() { + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mCharLimitShortTitle = getResources().getInteger(R.integer.short_title_length); + mCharLimitLine = getResources().getInteger(R.integer.char_limit_per_line); + } + + @Override + public void onLayoutChange(View view, int left, int top, int right, int bottom, int oldLeft, + int oldTop, int oldRight, int oldBottom) { + if (view == mHeader) { + mInitialHeaderHeight = bottom - top; + if (ViewUtils.getIsCircular(getContext())) { + // We are adding more margin on circular screens, so we need to account for it and use + // it for hiding the header. + mInitialHeaderHeight += + ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).topMargin; + } + } else if (view == mWheel) { + adjustHeaderTranslation(); + } + } + + protected void initializeLayout(RecyclerView.Adapter adapter) { + View v = getView(); + mWheel = (WearableListView) v.findViewById(R.id.wheel); + + mHeader = (TextView) v.findViewById(R.id.header); + mHeader.addOnLayoutChangeListener(this); + mHeader.addTextChangedListener(mHeaderTextWatcher); + + mWheel.setAdapter(adapter); + mWheel.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrollStateChanged(RecyclerView recyclerView, int newState) { + } + + @Override + public void onScrolled(RecyclerView recyclerView, int dx, int dy) { + adjustHeaderTranslation(); + } + }); + mWheel.setClickListener(this); + mWheel.addOnLayoutChangeListener(this); + + // Decrease item change animation duration to approximately half of the default duration. + RecyclerView.ItemAnimator itemAnimator = mWheel.getItemAnimator(); + itemAnimator.setChangeDuration(ITEM_CHANGE_DURATION_MS); + + adjustHeaderSize(); + + positionOnCircular(getContext(), mHeader, mWheel); + } + + public void positionOnCircular(Context context, View header, final ViewGroup wheel) { + if (ViewUtils.getIsCircular(context)) { + FrameLayout.LayoutParams params = + (FrameLayout.LayoutParams) header.getLayoutParams(); + params.topMargin = (int) context.getResources().getDimension( + R.dimen.settings_header_top_margin_circular); + // Note that the margins are made symmetrical here. Since they're symmetrical we choose + // the smaller value to maximize usable width. + final int margin = (int) Math.min(context.getResources().getDimension( + R.dimen.round_content_padding_left), context.getResources().getDimension( + R.dimen.round_content_padding_right)); + params.leftMargin = margin; + params.rightMargin = margin; + params.gravity = Gravity.CENTER_HORIZONTAL; + header.setLayoutParams(params); + + if (header instanceof TextView) { + ((TextView) header).setGravity(Gravity.CENTER); + } + + final int leftPadding = (int) context.getResources().getDimension( + R.dimen.round_content_padding_left); + final int rightPadding = (int) context.getResources().getDimension( + R.dimen.round_content_padding_right); + final int topPadding = (int) context.getResources().getDimension( + R.dimen.settings_wearable_list_view_vertical_padding_round); + final int bottomPadding = (int) context.getResources().getDimension( + R.dimen.settings_wearable_list_view_vertical_padding_round); + wheel.setPadding(leftPadding, topPadding, rightPadding, mChinOffset + bottomPadding); + wheel.setClipToPadding(false); + + wheel.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { + @Override + public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { + mChinOffset = insets.getSystemWindowInsetBottom(); + wheel.setPadding(leftPadding, topPadding, rightPadding, + mChinOffset + bottomPadding); + // This listener is invoked after each time we navigate to SettingsActivity and + // it keeps adding padding. We need to disable it after the first update. + v.setOnApplyWindowInsetsListener(null); + return insets.consumeSystemWindowInsets(); + } + }); + } else { + int leftPadding = (int) context.getResources().getDimension( + R.dimen.content_padding_left); + wheel.setPadding(leftPadding, wheel.getPaddingTop(), wheel.getPaddingRight(), + wheel.getPaddingBottom()); + } + } + + private void adjustHeaderSize() { + int length = mHeader.length(); + + if (length <= mCharLimitShortTitle) { + mHeader.setTextSize(TypedValue.COMPLEX_UNIT_PX, + getResources().getDimensionPixelSize( + R.dimen.setting_short_header_text_size)); + } else { + mHeader.setTextSize(TypedValue.COMPLEX_UNIT_PX, + getResources().getDimensionPixelSize( + R.dimen.setting_long_header_text_size)); + } + + boolean singleLine = length <= mCharLimitLine; + + float height = getResources().getDimension(R.dimen.settings_header_base_height); + if (!singleLine) { + height += getResources().getDimension(R.dimen.setting_header_extra_line_height); + } + mHeader.setMinHeight((int) height); + + FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) mHeader.getLayoutParams(); + final Context context = getContext(); + if (!singleLine) { + // Make the top margin a little bit smaller so there is more space for the title. + if (ViewUtils.getIsCircular(context)) { + params.topMargin = getResources().getDimensionPixelSize( + R.dimen.settings_header_top_margin_circular_multiline); + } else { + params.topMargin = getResources().getDimensionPixelSize( + R.dimen.settings_header_top_margin_multiline); + } + } else { + if (ViewUtils.getIsCircular(context)) { + params.topMargin = getResources().getDimensionPixelSize( + R.dimen.settings_header_top_margin_circular); + } else { + params.topMargin = getResources().getDimensionPixelSize( + R.dimen.settings_header_top_margin); + } + } + mHeader.setLayoutParams(params); + } +} diff --git a/src/com/android/packageinstaller/permission/ui/wear/WarningConfirmationActivity.java b/src/com/android/packageinstaller/permission/ui/wear/WarningConfirmationActivity.java new file mode 100644 index 00000000..03713419 --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/wear/WarningConfirmationActivity.java @@ -0,0 +1,118 @@ +/* +* Copyright (C) 2015 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.packageinstaller.permission.ui.wear; + +import android.app.Activity; +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.os.Bundle; + +import com.android.packageinstaller.R; + +public final class WarningConfirmationActivity extends Activity { + public final static String EXTRA_WARNING_MESSAGE = "EXTRA_WARNING_MESSAGE"; + // Saved index that will be returned in the onActivityResult() callback + public final static String EXTRA_INDEX = "EXTRA_INDEX"; + + private ConfirmationViewHandler mViewHandler; + private String mMessage; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mMessage = getIntent().getStringExtra(EXTRA_WARNING_MESSAGE); + + mViewHandler = new ConfirmationViewHandler(this) { + @Override // ConfirmationViewHandler + public int getButtonBarMode() { + return MODE_VERTICAL_BUTTONS; + } + + @Override + public void onButton1() { + setResultAndFinish(Activity.RESULT_CANCELED); + } + + @Override + public void onButton2() { + setResultAndFinish(Activity.RESULT_OK); + } + + @Override + public void onButton3() { + // no-op + } + + @Override + public CharSequence getVerticalButton1Text() { + return getString(R.string.cancel); + } + + @Override + public CharSequence getVerticalButton2Text() { + return getString(R.string.grant_dialog_button_deny); + } + + @Override + public CharSequence getVerticalButton3Text() { + return null; + } + + @Override + public Drawable getVerticalButton1Icon() { + return getDrawable(R.drawable.cancel_button); + } + + @Override + public Drawable getVerticalButton2Icon() { + return getDrawable(R.drawable.confirm_button); + } + + @Override + public Drawable getVerticalButton3Icon() { + return null; + } + + @Override + public CharSequence getCurrentPageText() { + return null; + } + + @Override + public Icon getPermissionIcon() { + return null; + } + + @Override + public CharSequence getMessage() { + return mMessage; + } + }; + + setContentView(mViewHandler.createView()); + mViewHandler.invalidate(); + } + + private void setResultAndFinish(int result) { + Intent intent = new Intent(); + intent.putExtra(EXTRA_INDEX, getIntent().getIntExtra(EXTRA_INDEX, -1)); + setResult(result, intent); + finish(); + } +} diff --git a/src/com/android/packageinstaller/permission/ui/wear/settings/ExtendedOnCenterProximityListener.java b/src/com/android/packageinstaller/permission/ui/wear/settings/ExtendedOnCenterProximityListener.java new file mode 100644 index 00000000..02c203b3 --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/wear/settings/ExtendedOnCenterProximityListener.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2015 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.packageinstaller.permission.ui.wear.settings; + +import android.support.wearable.view.WearableListView; + +public interface ExtendedOnCenterProximityListener + extends WearableListView.OnCenterProximityListener { + float getProximityMinValue(); + + float getProximityMaxValue(); + + float getCurrentProximityValue(); + + void setScalingAnimatorValue(float value); +} diff --git a/src/com/android/packageinstaller/permission/ui/wear/settings/ExtendedViewHolder.java b/src/com/android/packageinstaller/permission/ui/wear/settings/ExtendedViewHolder.java new file mode 100644 index 00000000..6b725419 --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/wear/settings/ExtendedViewHolder.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2015 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.packageinstaller.permission.ui.wear.settings; + +import android.animation.ObjectAnimator; +import android.support.wearable.view.WearableListView; +import android.view.View; + + +public class ExtendedViewHolder extends WearableListView.ViewHolder { + public static final long DEFAULT_ANIMATION_DURATION = 150; + + private ObjectAnimator mScalingUpAnimator; + + private ObjectAnimator mScalingDownAnimator; + + private float mMinValue; + + private float mMaxValue; + + public ExtendedViewHolder(View itemView) { + super(itemView); + if (itemView instanceof ExtendedOnCenterProximityListener) { + ExtendedOnCenterProximityListener item = + (ExtendedOnCenterProximityListener) itemView; + mMinValue = item.getProximityMinValue(); + item.setScalingAnimatorValue(mMinValue); + mMaxValue = item.getProximityMaxValue(); + mScalingUpAnimator = ObjectAnimator.ofFloat(item, "scalingAnimatorValue", mMinValue, + mMaxValue); + mScalingUpAnimator.setDuration(DEFAULT_ANIMATION_DURATION); + mScalingDownAnimator = ObjectAnimator.ofFloat(item, "scalingAnimatorValue", + mMaxValue, mMinValue); + mScalingDownAnimator.setDuration(DEFAULT_ANIMATION_DURATION); + } + } + + public void onCenterProximity(boolean isCentralItem, boolean animate) { + if (!(itemView instanceof ExtendedOnCenterProximityListener)) { + return; + } + ExtendedOnCenterProximityListener item = (ExtendedOnCenterProximityListener) itemView; + if (isCentralItem) { + if (animate) { + mScalingDownAnimator.cancel(); + if (!mScalingUpAnimator.isRunning()) { + mScalingUpAnimator.setFloatValues(item.getCurrentProximityValue(), + mMaxValue); + mScalingUpAnimator.start(); + } + } else { + mScalingUpAnimator.cancel(); + item.setScalingAnimatorValue(item.getProximityMaxValue()); + } + } else { + mScalingUpAnimator.cancel(); + if (animate) { + if (!mScalingDownAnimator.isRunning()) { + mScalingDownAnimator.setFloatValues(item.getCurrentProximityValue(), + mMinValue); + mScalingDownAnimator.start(); + } + } else { + mScalingDownAnimator.cancel(); + item.setScalingAnimatorValue(item.getProximityMinValue()); + } + } + super.onCenterProximity(isCentralItem, animate); + } +} diff --git a/src/com/android/packageinstaller/permission/ui/wear/settings/PermissionsSettingsAdapter.java b/src/com/android/packageinstaller/permission/ui/wear/settings/PermissionsSettingsAdapter.java new file mode 100644 index 00000000..0e0adcbb --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/wear/settings/PermissionsSettingsAdapter.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2015 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.packageinstaller.permission.ui.wear.settings; + +import android.content.Context; +import android.content.res.Resources; +import android.support.wearable.view.WearableListView; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import com.android.packageinstaller.R; +import com.android.packageinstaller.permission.model.AppPermissionGroup; + +public final class PermissionsSettingsAdapter extends SettingsAdapter<AppPermissionGroup> { + private Resources mRes; + + public PermissionsSettingsAdapter(Context context) { + super(context, R.layout.permissions_settings_item); + mRes = context.getResources(); + } + + @Override + public WearableListView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + return new PermissionsViewHolder(new PermissionsSettingsItem(parent.getContext())); + } + + @Override + public void onBindViewHolder(WearableListView.ViewHolder holder, int position) { + super.onBindViewHolder(holder, position); + PermissionsViewHolder viewHolder = (PermissionsViewHolder) holder; + AppPermissionGroup group = get(position).data; + + if (group.isPolicyFixed()) { + viewHolder.imageView.setEnabled(false); + viewHolder.textView.setEnabled(false); + viewHolder.state.setEnabled(false); + viewHolder.state.setText( + mRes.getString(R.string.permission_summary_enforced_by_policy)); + } else { + viewHolder.imageView.setEnabled(true); + viewHolder.textView.setEnabled(true); + viewHolder.state.setEnabled(true); + + if (group.areRuntimePermissionsGranted()) { + viewHolder.state.setText(R.string.generic_enabled); + } else { + viewHolder.state.setText(R.string.generic_disabled); + } + } + } + + private static final class PermissionsViewHolder extends SettingsAdapter.SettingsItemHolder { + public final TextView state; + + public PermissionsViewHolder(View view) { + super(view); + state = (TextView) view.findViewById(R.id.state); + } + } + + private class PermissionsSettingsItem extends SettingsItem { + private final TextView mState; + private final float mCenteredAlpha = 1.0f; + private final float mNonCenteredAlpha = 0.5f; + + public PermissionsSettingsItem (Context context) { + super(context); + mState = (TextView) findViewById(R.id.state); + } + + @Override + public void onCenterPosition(boolean animate) { + mImage.setAlpha(mImage.isEnabled() ? mCenteredAlpha : mNonCenteredAlpha); + mText.setAlpha(mText.isEnabled() ? mCenteredAlpha : mNonCenteredAlpha); + mState.setAlpha(mState.isEnabled() ? mCenteredAlpha : mNonCenteredAlpha); + } + + @Override + public void onNonCenterPosition(boolean animate) { + mImage.setAlpha(mNonCenteredAlpha); + mText.setAlpha(mNonCenteredAlpha); + mState.setAlpha(mNonCenteredAlpha); + } + } +} + diff --git a/src/com/android/packageinstaller/permission/ui/wear/settings/SettingsAdapter.java b/src/com/android/packageinstaller/permission/ui/wear/settings/SettingsAdapter.java new file mode 100644 index 00000000..baf1a2b4 --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/wear/settings/SettingsAdapter.java @@ -0,0 +1,276 @@ +/* + * Copyright (C) 2015 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.packageinstaller.permission.ui.wear.settings; + +import android.content.Context; +import android.support.wearable.view.CircledImageView; +import android.support.wearable.view.WearableListView; +import android.util.Log; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; + +import com.android.packageinstaller.R; + +import java.util.ArrayList; + +/** + * Common adapter for settings views. Maintains a list of 'Settings', consisting of a name, + * icon and optional activity-specific data. + */ +public class SettingsAdapter<T> extends WearableListView.Adapter { + private static final String TAG = "SettingsAdapter"; + private final Context mContext; + + public static final class Setting<S> { + public static final int ID_INVALID = -1; + + public final int id; + public int nameResourceId; + public CharSequence name; + public int iconResource; + public boolean inProgress; + public S data; + + public Setting(CharSequence name, int iconResource, S data) { + this(name, iconResource, data, ID_INVALID); + } + + public Setting(CharSequence name, int iconResource, S data, int id) { + this.name = name; + this.iconResource = iconResource; + this.data = data; + this.inProgress = false; + this.id = id; + } + + public Setting(int nameResource, int iconResource, S data, int id) { + this.nameResourceId = nameResource; + this.iconResource = iconResource; + this.data = data; + this.inProgress = false; + this.id = id; + } + + public Setting(int nameResource, int iconResource, int id) { + this.nameResourceId = nameResource; + this.iconResource = iconResource; + this.data = null; + this.inProgress = false; + this.id = id; + } + + public Setting(CharSequence name, int iconResource, int id) { + this(name, iconResource, null, id); + } + + } + + private final int mItemLayoutId; + private final float mDefaultCircleRadiusPercent; + private final float mSelectedCircleRadiusPercent; + + protected ArrayList<Setting<T>> mSettings = new ArrayList<Setting<T>>(); + + public SettingsAdapter(Context context, int itemLayoutId) { + mContext = context; + mItemLayoutId = itemLayoutId; + mDefaultCircleRadiusPercent = context.getResources().getFraction( + R.dimen.default_settings_circle_radius_percent, 1, 1); + mSelectedCircleRadiusPercent = context.getResources().getFraction( + R.dimen.selected_settings_circle_radius_percent, 1, 1); + } + + @Override + public WearableListView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + return new SettingsItemHolder(new SettingsItem(parent.getContext())); + } + + @Override + public void onBindViewHolder(WearableListView.ViewHolder holder, int position) { + Setting<T> setting = mSettings.get(position); + if (setting.iconResource == -1) { + ((SettingsItemHolder) holder).imageView.setVisibility(View.GONE); + } else { + ((SettingsItemHolder) holder).imageView.setVisibility(View.VISIBLE); + ((SettingsItemHolder) holder).imageView.setImageResource( + mSettings.get(position).iconResource); + } + Log.d(TAG, "onBindViewHolder " + setting.name + " " + setting.id + " " + setting + .nameResourceId); + if (setting.name == null && setting.nameResourceId != 0) { + setting.name = mContext.getString(setting.nameResourceId); + } + ((SettingsItemHolder) holder).textView.setText(setting.name); + } + + @Override + public int getItemCount() { + return mSettings.size(); + } + + public void addSetting(CharSequence name, int iconResource) { + addSetting(name, iconResource, null); + } + + public void addSetting(CharSequence name, int iconResource, T intent) { + addSetting(mSettings.size(), name, iconResource, intent); + } + + public void addSetting(int index, CharSequence name, int iconResource, T intent) { + addSetting(Setting.ID_INVALID, index, name, iconResource, intent); + } + + public void addSetting(int id, int index, CharSequence name, int iconResource, T intent) { + mSettings.add(index, new Setting<T>(name, iconResource, intent, id)); + notifyItemInserted(index); + } + + public void addSettingDontNotify(Setting<T> setting) { + mSettings.add(setting); + } + + public void addSetting(Setting<T> setting) { + mSettings.add(setting); + notifyItemInserted(mSettings.size() - 1); + } + + public void addSetting(int index, Setting<T> setting) { + mSettings.add(index, setting); + notifyItemInserted(index); + } + + /** + * Returns the index of the setting in the adapter based on the ID supplied when it was + * originally added. + * @param id the setting's id + * @return index in the adapter of the setting. -1 if not found. + */ + public int findSetting(int id) { + for (int i = mSettings.size() - 1; i >= 0; --i) { + Setting setting = mSettings.get(i); + + if (setting.id == id) { + return i; + } + } + + return -1; + } + + /** + * Removes a setting at the given index. + * @param index the index of the setting to be removed + */ + public void removeSetting(int index) { + mSettings.remove(index); + notifyDataSetChanged(); + } + + public void clearSettings() { + mSettings.clear(); + notifyDataSetChanged(); + } + + /** + * Updates a setting in place. + * @param index the index of the setting + * @param name the updated setting name + * @param iconResource the update setting icon + * @param intent the updated intent for the setting + */ + public void updateSetting(int index, CharSequence name, int iconResource, T intent) { + Setting<T> setting = mSettings.get(index); + setting.iconResource = iconResource; + setting.name = name; + setting.data = intent; + notifyItemChanged(index); + } + + public Setting<T> get(int position) { + return mSettings.get(position); + } + + protected static class SettingsItemHolder extends ExtendedViewHolder { + public final CircledImageView imageView; + public final TextView textView; + + public SettingsItemHolder(View itemView) { + super(itemView); + + imageView = ((CircledImageView) itemView.findViewById(R.id.image)); + textView = ((TextView) itemView.findViewById(R.id.text)); + } + } + + protected class SettingsItem extends FrameLayout implements ExtendedOnCenterProximityListener { + + protected final CircledImageView mImage; + protected final TextView mText; + + public SettingsItem(Context context) { + super(context); + View view = View.inflate(context, mItemLayoutId, null); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT); + params.gravity = Gravity.CENTER_VERTICAL; + addView(view, params); + mImage = (CircledImageView) findViewById(R.id.image); + mText = (TextView) findViewById(R.id.text); + } + + @Override + public float getProximityMinValue() { + return mDefaultCircleRadiusPercent; + } + + @Override + public float getProximityMaxValue() { + return mSelectedCircleRadiusPercent; + } + + @Override + public float getCurrentProximityValue() { + return mImage.getCircleRadiusPressedPercent(); + } + + @Override + public void setScalingAnimatorValue(float value) { + mImage.setCircleRadiusPercent(value); + mImage.setCircleRadiusPressedPercent(value); + } + + @Override + public void onCenterPosition(boolean animate) { + mImage.setAlpha(1f); + mText.setAlpha(1f); + } + + @Override + public void onNonCenterPosition(boolean animate) { + mImage.setAlpha(0.5f); + mText.setAlpha(0.5f); + } + + TextView getTextView() { + return mText; + } + } +} diff --git a/src/com/android/packageinstaller/permission/ui/wear/settings/ViewUtils.java b/src/com/android/packageinstaller/permission/ui/wear/settings/ViewUtils.java new file mode 100644 index 00000000..cf1c0fd0 --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/wear/settings/ViewUtils.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2015 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.packageinstaller.permission.ui.wear.settings; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; + +/** + * Utility to determine screen shape + */ +public class ViewUtils { + + public static boolean getIsCircular(Context context) { + return context.getResources().getConfiguration().isScreenRound(); + } + + /** + * Set the given {@code view} and all descendants to the given {@code enabled} state. + * + * @param view the parent view of a subtree of components whose enabled state must be set + * @param enabled the new enabled state of the subtree of components + */ + public static void setEnabled(View view, boolean enabled) { + view.setEnabled(enabled); + + if (view instanceof ViewGroup) { + final ViewGroup viewGroup = (ViewGroup) view; + for (int i = 0; i < viewGroup.getChildCount(); i++) { + setEnabled(viewGroup.getChildAt(i), enabled); + } + } + } +} diff --git a/src/com/android/packageinstaller/permission/utils/LocationUtils.java b/src/com/android/packageinstaller/permission/utils/LocationUtils.java index 512fcf44..0296ae80 100644 --- a/src/com/android/packageinstaller/permission/utils/LocationUtils.java +++ b/src/com/android/packageinstaller/permission/utils/LocationUtils.java @@ -36,23 +36,9 @@ public class LocationUtils { public static final String LOCATION_PERMISSION = Manifest.permission_group.LOCATION; - public static ArrayList<String> getLocationProviders() { - ArrayList<String> providers = new ArrayList<>(); - Resources res = Resources.getSystem(); - providers.add(res.getString( - com.android.internal.R.string.config_networkLocationProviderPackageName)); - - for (String provider : - res.getStringArray(com.android.internal.R.array.config_locationProviderPackageNames)) { - providers.add(provider); - } - - return providers; - } - public static void showLocationDialog(final Context context, CharSequence label) { new AlertDialog.Builder(context) - .setIcon(com.android.internal.R.drawable.ic_dialog_alert_material) + .setIcon(R.drawable.ic_dialog_alert_material) .setTitle(android.R.string.dialog_alert_title) .setMessage(context.getString(R.string.location_warning, label)) .setNegativeButton(R.string.ok, null) @@ -83,5 +69,4 @@ public class LocationUtils { return false; } } - } diff --git a/src/com/android/packageinstaller/permission/utils/Utils.java b/src/com/android/packageinstaller/permission/utils/Utils.java index 2940a729..21830378 100644 --- a/src/com/android/packageinstaller/permission/utils/Utils.java +++ b/src/com/android/packageinstaller/permission/utils/Utils.java @@ -31,8 +31,11 @@ import android.util.Log; import android.util.TypedValue; import com.android.packageinstaller.permission.model.AppPermissionGroup; +import com.android.packageinstaller.permission.model.AppPermissions; import com.android.packageinstaller.permission.model.PermissionApps.PermissionApp; +import java.util.List; + public class Utils { private static final String LOG_TAG = "Utils"; @@ -127,14 +130,20 @@ public class Utils { return launcherPkgs; } + public static List<ApplicationInfo> getAllInstalledApplications(Context context) { + return context.getPackageManager().getInstalledApplications(0); + } + public static boolean isSystem(PermissionApp app, ArraySet<String> launcherPkgs) { - ApplicationInfo info = app.getAppInfo(); - return info.isSystemApp() && (info.flags & ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) == 0 - && !launcherPkgs.contains(info.packageName); + return isSystem(app.getAppInfo(), launcherPkgs); } - public static boolean isTelevision(Context context) { - int uiMode = context.getResources().getConfiguration().uiMode; - return (uiMode & Configuration.UI_MODE_TYPE_MASK) == Configuration.UI_MODE_TYPE_TELEVISION; + public static boolean isSystem(AppPermissions app, ArraySet<String> launcherPkgs) { + return isSystem(app.getPackageInfo().applicationInfo, launcherPkgs); + } + + public static boolean isSystem(ApplicationInfo info, ArraySet<String> launcherPkgs) { + return info.isSystemApp() && (info.flags & ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) == 0 + && !launcherPkgs.contains(info.packageName); } } diff --git a/src/com/android/packageinstaller/wear/WearPackageArgs.java b/src/com/android/packageinstaller/wear/WearPackageArgs.java new file mode 100644 index 00000000..67051da0 --- /dev/null +++ b/src/com/android/packageinstaller/wear/WearPackageArgs.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2015 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.packageinstaller.wear; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; + +/** + * Installation Util that contains a list of parameters that are needed for + * installing/uninstalling. + */ +public class WearPackageArgs { + private static final String KEY_ASSET_URI = + "com.google.android.clockwork.EXTRA_ASSET_URI"; + private static final String KEY_START_ID = + "com.google.android.clockwork.EXTRA_START_ID"; + private static final String KEY_PERM_URI = + "com.google.android.clockwork.EXTRA_PERM_URI"; + private static final String KEY_CHECK_PERMS = + "com.google.android.clockwork.EXTRA_CHECK_PERMS"; + private static final String KEY_SKIP_IF_SAME_VERSION = + "com.google.android.clockwork.EXTRA_SKIP_IF_SAME_VERSION"; + private static final String KEY_COMPRESSION_ALG = + "com.google.android.clockwork.EXTRA_KEY_COMPRESSION_ALG"; + private static final String KEY_COMPANION_SDK_VERSION = + "com.google.android.clockwork.EXTRA_KEY_COMPANION_SDK_VERSION"; + private static final String KEY_COMPANION_DEVICE_VERSION = + "com.google.android.clockwork.EXTRA_KEY_COMPANION_DEVICE_VERSION"; + private static final String KEY_SHOULD_CHECK_GMS_DEPENDENCY = + "com.google.android.clockwork.EXTRA_KEY_SHOULD_CHECK_GMS_DEPENDENCY"; + + public static String getPackageName(Bundle b) { + return b.getString(Intent.EXTRA_INSTALLER_PACKAGE_NAME); + } + + public static Uri getAssetUri(Bundle b) { + return b.getParcelable(KEY_ASSET_URI); + } + + public static Bundle setAssetUri(Bundle b, Uri assetUri) { + b.putParcelable(KEY_ASSET_URI, assetUri); + return b; + } + + public static Uri getPermUri(Bundle b) { + return b.getParcelable(KEY_PERM_URI); + } + + public static boolean checkPerms(Bundle b) { + return b.getBoolean(KEY_CHECK_PERMS); + } + + public static boolean skipIfSameVersion(Bundle b) { + return b.getBoolean(KEY_SKIP_IF_SAME_VERSION); + } + + public static int getCompanionSdkVersion(Bundle b) { + return b.getInt(KEY_COMPANION_SDK_VERSION); + } + + public static int getCompanionDeviceVersion(Bundle b) { + return b.getInt(KEY_COMPANION_DEVICE_VERSION); + } + + public static String getCompressionAlg(Bundle b) { + return b.getString(KEY_COMPRESSION_ALG); + } + + public static int getStartId(Bundle b) { + return b.getInt(KEY_START_ID); + } + + public static Bundle setStartId(Bundle b, int startId) { + b.putInt(KEY_START_ID, startId); + return b; + } +} diff --git a/src/com/android/packageinstaller/wear/WearPackageIconProvider.java b/src/com/android/packageinstaller/wear/WearPackageIconProvider.java new file mode 100644 index 00000000..02b9d298 --- /dev/null +++ b/src/com/android/packageinstaller/wear/WearPackageIconProvider.java @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2015 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.packageinstaller.wear; + +import android.annotation.TargetApi; +import android.app.ActivityManager; +import android.content.ContentProvider; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.net.Uri; +import android.os.Binder; +import android.os.Build; +import android.os.ParcelFileDescriptor; +import android.util.Log; + +import java.io.File; +import java.io.FileNotFoundException; +import java.util.List; + +import static android.content.pm.PackageManager.PERMISSION_GRANTED; + +public class WearPackageIconProvider extends ContentProvider { + private static final String TAG = "WearPackageIconProvider"; + public static final String AUTHORITY = "com.google.android.packageinstaller.wear.provider"; + + private static final String REQUIRED_PERMISSION = + "com.google.android.permission.INSTALL_WEARABLE_PACKAGES"; + + /** MIME types. */ + public static final String ICON_TYPE = "vnd.android.cursor.item/cw_package_icon"; + + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, + String sortOrder) { + throw new UnsupportedOperationException("Query is not supported."); + } + + @Override + public String getType(Uri uri) { + if (uri == null) { + throw new IllegalArgumentException("URI passed in is null."); + } + + if (AUTHORITY.equals(uri.getEncodedAuthority())) { + return ICON_TYPE; + } + return null; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + throw new UnsupportedOperationException("Insert is not supported."); + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + if (uri == null) { + throw new IllegalArgumentException("URI passed in is null."); + } + + enforcePermissions(uri); + + if (ICON_TYPE.equals(getType(uri))) { + final File file = WearPackageUtil.getIconFile( + this.getContext().getApplicationContext(), getPackageNameFromUri(uri)); + if (file != null) { + file.delete(); + } + } + + return 0; + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException("Update is not supported."); + } + + @Override + public ParcelFileDescriptor openFile( + Uri uri, @SuppressWarnings("unused") String mode) throws FileNotFoundException { + if (uri == null) { + throw new IllegalArgumentException("URI passed in is null."); + } + + enforcePermissions(uri); + + if (ICON_TYPE.equals(getType(uri))) { + final File file = WearPackageUtil.getIconFile( + this.getContext().getApplicationContext(), getPackageNameFromUri(uri)); + if (file != null) { + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); + } + } + return null; + } + + public static Uri getUriForPackage(final String packageName) { + return Uri.parse("content://" + AUTHORITY + "/icons/" + packageName + ".icon"); + } + + private String getPackageNameFromUri(Uri uri) { + if (uri == null) { + return null; + } + List<String> pathSegments = uri.getPathSegments(); + String packageName = pathSegments.get(pathSegments.size() - 1); + + if (packageName.endsWith(".icon")) { + packageName = packageName.substring(0, packageName.lastIndexOf(".")); + } + return packageName; + } + + /** + * Make sure the calling app is either a system app or the same app or has the right permission. + * @throws SecurityException if the caller has insufficient permissions. + */ + @TargetApi(Build.VERSION_CODES.BASE_1_1) + private void enforcePermissions(Uri uri) { + // Redo some of the permission check in {@link ContentProvider}. Just add an extra check to + // allow System process to access this provider. + Context context = getContext(); + final int pid = Binder.getCallingPid(); + final int uid = Binder.getCallingUid(); + final int myUid = android.os.Process.myUid(); + + if (uid == myUid || isSystemApp(context, pid)) { + return; + } + + if (context.checkPermission(REQUIRED_PERMISSION, pid, uid) == PERMISSION_GRANTED) { + return; + } + + // last chance, check against any uri grants + if (context.checkUriPermission(uri, pid, uid, Intent.FLAG_GRANT_READ_URI_PERMISSION) + == PERMISSION_GRANTED) { + return; + } + + throw new SecurityException("Permission Denial: reading " + + getClass().getName() + " uri " + uri + " from pid=" + pid + + ", uid=" + uid); + } + + /** + * From the pid of the calling process, figure out whether this is a system app or not. We do + * this by checking the application information corresponding to the pid and then checking if + * FLAG_SYSTEM is set. + */ + @TargetApi(Build.VERSION_CODES.CUPCAKE) + private boolean isSystemApp(Context context, int pid) { + // Get the Activity Manager Object + ActivityManager aManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + // Get the list of running Applications + List<ActivityManager.RunningAppProcessInfo> rapInfoList = + aManager.getRunningAppProcesses(); + for (ActivityManager.RunningAppProcessInfo rapInfo : rapInfoList) { + if (rapInfo.pid == pid) { + try { + PackageInfo pkgInfo = context.getPackageManager().getPackageInfo( + rapInfo.pkgList[0], 0); + if (pkgInfo != null && pkgInfo.applicationInfo != null && + (pkgInfo.applicationInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0) { + Log.d(TAG, pid + " is a system app."); + return true; + } + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "Could not find package information.", e); + return false; + } + } + } + return false; + } +} diff --git a/src/com/android/packageinstaller/wear/WearPackageInstallerService.java b/src/com/android/packageinstaller/wear/WearPackageInstallerService.java new file mode 100644 index 00000000..3874c0a4 --- /dev/null +++ b/src/com/android/packageinstaller/wear/WearPackageInstallerService.java @@ -0,0 +1,602 @@ +/* + * Copyright (C) 2015 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.packageinstaller.wear; + +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.FeatureInfo; +import android.content.pm.IPackageDeleteObserver; +import android.content.pm.IPackageInstallObserver; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageParser; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; +import android.os.Looper; +import android.os.Message; +import android.os.ParcelFileDescriptor; +import android.os.PowerManager; +import android.os.Process; +import android.text.TextUtils; +import android.util.Log; + +import com.android.packageinstaller.DeviceUtils; +import com.android.packageinstaller.PackageUtil; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Service that will install/uninstall packages. It will check for permissions and features as well. + * + * ----------- + * + * Debugging information: + * + * Install Action example: + * adb shell am startservice -a com.android.packageinstaller.wear.INSTALL_PACKAGE \ + * -t vnd.android.cursor.item/wearable_apk \ + * -d content://com.google.android.clockwork.home.provider/host/com.google.android.wearable.app/wearable/com.google.android.gms/apk \ + * --es android.intent.extra.INSTALLER_PACKAGE_NAME com.google.android.gms \ + * --ez com.google.android.clockwork.EXTRA_CHECK_PERMS false \ + * --eu com.google.android.clockwork.EXTRA_PERM_URI content://com.google.android.clockwork.home.provider/host/com.google.android.wearable.app/permissions \ + * com.android.packageinstaller/com.android.packageinstaller.wear.WearPackageInstallerService + * + * Retry GMS: + * adb shell am startservice -a com.android.packageinstaller.wear.RETRY_GMS \ + * com.android.packageinstaller/com.android.packageinstaller.wear.WearPackageInstallerService + */ +public class WearPackageInstallerService extends Service { + private static final String TAG = "WearPkgInstallerService"; + + private static final String KEY_PACKAGE_NAME = + "com.google.android.clockwork.EXTRA_PACKAGE_NAME"; + private static final String KEY_APP_LABEL = "com.google.android.clockwork.EXTRA_APP_LABEL"; + private static final String KEY_APP_ICON_URI = + "com.google.android.clockwork.EXTRA_APP_ICON_URI"; + private static final String KEY_PERMS_LIST = "com.google.android.clockwork.EXTRA_PERMS_LIST"; + private static final String KEY_HAS_LAUNCHER = + "com.google.android.clockwork.EXTRA_HAS_LAUNCHER"; + + private static final String HOME_APP_PACKAGE_NAME = "com.google.android.wearable.app"; + private static final String SHOW_PERMS_SERVICE_CLASS = + "com.google.android.clockwork.packagemanager.ShowPermsService"; + + /** + * Normally sent by the Play store (See http://go/playstore-gms_updated), we instead + * broadcast, ourselves. http://b/17387718 + */ + private static final String GMS_UPDATED_BROADCAST = "com.google.android.gms.GMS_UPDATED"; + public static final String GMS_PACKAGE_NAME = "com.google.android.gms"; + + private final int START_INSTALL = 1; + private final int START_UNINSTALL = 2; + + private final class ServiceHandler extends Handler { + public ServiceHandler(Looper looper) { + super(looper); + } + + public void handleMessage(Message msg) { + switch (msg.what) { + case START_INSTALL: + installPackage(msg.getData()); + break; + case START_UNINSTALL: + uninstallPackage(msg.getData()); + break; + } + } + } + private ServiceHandler mServiceHandler; + + private static volatile PowerManager.WakeLock lockStatic = null; + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onCreate() { + super.onCreate(); + HandlerThread thread = new HandlerThread("PackageInstallerThread", + Process.THREAD_PRIORITY_BACKGROUND); + thread.start(); + + mServiceHandler = new ServiceHandler(thread.getLooper()); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (!DeviceUtils.isWear(this)) { + Log.w(TAG, "Not running on wearable"); + return START_NOT_STICKY; + } + PowerManager.WakeLock lock = getLock(this.getApplicationContext()); + if (!lock.isHeld()) { + lock.acquire(); + } + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Got install/uninstall request " + intent); + } + if (intent != null) { + Bundle intentBundle = intent.getExtras(); + WearPackageArgs.setStartId(intentBundle, startId); + if (Intent.ACTION_INSTALL_PACKAGE.equals(intent.getAction())) { + final Message msg = mServiceHandler.obtainMessage(START_INSTALL); + WearPackageArgs.setAssetUri(intentBundle, intent.getData()); + msg.setData(intentBundle); + mServiceHandler.sendMessage(msg); + } else if (Intent.ACTION_UNINSTALL_PACKAGE.equals(intent.getAction())) { + Message msg = mServiceHandler.obtainMessage(START_UNINSTALL); + msg.setData(intentBundle); + mServiceHandler.sendMessage(msg); + } + } + return START_NOT_STICKY; + } + + private void installPackage(Bundle argsBundle) { + int startId = WearPackageArgs.getStartId(argsBundle); + final String packageName = WearPackageArgs.getPackageName(argsBundle); + final Uri assetUri = WearPackageArgs.getAssetUri(argsBundle); + final Uri permUri = WearPackageArgs.getPermUri(argsBundle); + boolean checkPerms = WearPackageArgs.checkPerms(argsBundle); + boolean skipIfSameVersion = WearPackageArgs.skipIfSameVersion(argsBundle); + int companionSdkVersion = WearPackageArgs.getCompanionSdkVersion(argsBundle); + int companionDeviceVersion = WearPackageArgs.getCompanionDeviceVersion(argsBundle); + String compressionAlg = WearPackageArgs.getCompressionAlg(argsBundle); + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Installing package: " + packageName + ", assetUri: " + assetUri + + ",permUri: " + permUri + ", startId: " + startId + ", checkPerms: " + + checkPerms + ", skipIfSameVersion: " + skipIfSameVersion + + ", compressionAlg: " + compressionAlg + ", companionSdkVersion: " + + companionSdkVersion + ", companionDeviceVersion: " + companionDeviceVersion); + } + final PackageManager pm = getPackageManager(); + File tempFile = null; + int installFlags = 0; + PowerManager.WakeLock lock = getLock(this.getApplicationContext()); + boolean messageSent = false; + try { + PackageInfo existingPkgInfo = null; + try { + existingPkgInfo = pm.getPackageInfo(packageName, + PackageManager.GET_UNINSTALLED_PACKAGES | PackageManager.GET_PERMISSIONS); + if(existingPkgInfo != null) { + installFlags |= PackageManager.INSTALL_REPLACE_EXISTING; + } + } catch (PackageManager.NameNotFoundException e) { + // Ignore this exception. We could not find the package, will treat as a new + // installation. + } + if((installFlags & PackageManager.INSTALL_REPLACE_EXISTING )!= 0) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Replacing package:" + packageName); + } + } + ParcelFileDescriptor parcelFd = getContentResolver() + .openFileDescriptor(assetUri, "r"); + tempFile = WearPackageUtil.getFileFromFd(WearPackageInstallerService.this, + parcelFd, packageName, compressionAlg); + if (tempFile == null) { + Log.e(TAG, "Could not create a temp file from FD for " + packageName); + return; + } + PackageParser.Package pkg = PackageUtil.getPackageInfo(tempFile); + if (pkg == null) { + Log.e(TAG, "Could not parse apk information for " + packageName); + return; + } + + if (!pkg.packageName.equals(packageName)) { + Log.e(TAG, "Wearable Package Name has to match what is provided for " + + packageName); + return; + } + + List<String> wearablePerms = pkg.requestedPermissions; + + // Log if the installed pkg has a higher version number. + if (existingPkgInfo != null) { + if (existingPkgInfo.versionCode == pkg.mVersionCode) { + if (skipIfSameVersion) { + Log.w(TAG, "Version number (" + pkg.mVersionCode + + ") of new app is equal to existing app for " + packageName + + "; not installing due to versionCheck"); + return; + } else { + Log.w(TAG, "Version number of new app (" + pkg.mVersionCode + + ") is equal to existing app for " + packageName); + } + } else if (existingPkgInfo.versionCode > pkg.mVersionCode) { + Log.w(TAG, "Version number of new app (" + pkg.mVersionCode + + ") is lower than existing app ( " + existingPkgInfo.versionCode + + ") for " + packageName); + } + + // Following the Android Phone model, we should only check for permissions for any + // newly defined perms. + if (existingPkgInfo.requestedPermissions != null) { + for (int i = 0; i < existingPkgInfo.requestedPermissions.length; ++i) { + // If the permission is granted, then we will not ask to request it again. + if ((existingPkgInfo.requestedPermissionsFlags[i] & + PackageInfo.REQUESTED_PERMISSION_GRANTED) != 0) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, existingPkgInfo.requestedPermissions[i] + + " is already granted for " + packageName); + } + wearablePerms.remove(existingPkgInfo.requestedPermissions[i]); + } + } + } + } + + // Check permissions on both the new wearable package and also on the already installed + // wearable package. + // If the app is targeting API level 23, we will also start a service in ClockworkHome + // which will ultimately prompt the user to accept/reject permissions. + if (checkPerms && !checkPermissions(pkg, companionSdkVersion, companionDeviceVersion, + permUri, wearablePerms, tempFile)) { + Log.w(TAG, "Wearable does not have enough permissions."); + return; + } + + // Check that the wearable has all the features. + boolean hasAllFeatures = true; + if (pkg.reqFeatures != null) { + for (FeatureInfo feature : pkg.reqFeatures) { + if (feature.name != null && !pm.hasSystemFeature(feature.name) && + (feature.flags & FeatureInfo.FLAG_REQUIRED) != 0) { + Log.e(TAG, "Wearable does not have required feature: " + feature + + " for " + packageName); + hasAllFeatures = false; + } + } + } + + if (!hasAllFeatures) { + return; + } + + // Finally install the package. + pm.installPackage(Uri.fromFile(tempFile), + new PackageInstallObserver(this, lock, startId, packageName), + installFlags, packageName); + + messageSent = true; + Log.i(TAG, "Sent installation request for " + packageName); + } catch (FileNotFoundException e) { + Log.e(TAG, "Could not find the file with URI " + assetUri, e); + } finally { + if (!messageSent) { + // Some error happened. If the message has been sent, we can wait for the observer + // which will finish the service. + if (tempFile != null) { + tempFile.delete(); + } + finishService(lock, startId); + } + } + } + + private void uninstallPackage(Bundle argsBundle) { + int startId = WearPackageArgs.getStartId(argsBundle); + final String packageName = WearPackageArgs.getPackageName(argsBundle); + + final PackageManager pm = getPackageManager(); + PowerManager.WakeLock lock = getLock(this.getApplicationContext()); + pm.deletePackage(packageName, new PackageDeleteObserver(lock, startId), + PackageManager.DELETE_ALL_USERS); + startPermsServiceForUninstall(packageName); + Log.i(TAG, "Sent delete request for " + packageName); + } + + private boolean checkPermissions(PackageParser.Package pkg, int companionSdkVersion, + int companionDeviceVersion, Uri permUri, List<String> wearablePermissions, + File apkFile) { + // If the Wear App is targeted for M-release, since the permission model has been changed, + // permissions may not be granted on the phone yet. We need a different flow for user to + // accept these permissions. + // + // Assumption: Code is running on E-release, so Wear is always running M. + // - Case 1: If the Wear App(WA) is targeting 23, always choose the M model (4 cases) + // - Case 2: Else if the Phone App(PA) is targeting 23 and Phone App(P) is running on M, + // show a Dialog so that the user can accept all perms (1 case) + // - Also show a warning to the developer if the watch is targeting M + // - Case 3: If Case 2 is false, then the behavior on the phone is pre-M. Stick to pre-M + // behavior on watch (as long as we don't hit case 1). + // - 3a: WA(22) PA(22) P(22) -> watch app is not targeting 23 + // - 3b: WA(22) PA(22) P(23) -> watch app is not targeting 23 + // - 3c: WA(22) PA(23) P(22) -> watch app is not targeting 23 + // - Case 4: We did not get Companion App's/Device's version, always show dialog to user to + // accept permissions. (This happens if the AndroidWear Companion App is really old). + boolean isWearTargetingM = + pkg.applicationInfo.targetSdkVersion > Build.VERSION_CODES.LOLLIPOP_MR1; + if (isWearTargetingM) { // Case 1 + // Install the app if Wear App is ready for the new perms model. + return true; + } + + List<String> unavailableWearablePerms = getWearPermsNotGrantedOnPhone(pkg.packageName, + permUri, wearablePermissions); + if (unavailableWearablePerms == null) { + return false; + } + + if (unavailableWearablePerms.size() == 0) { + // All permissions requested by the watch are already granted on the phone, no need + // to do anything. + return true; + } + + // Cases 2 and 4. + boolean isCompanionTargetingM = companionSdkVersion > Build.VERSION_CODES.LOLLIPOP_MR1; + boolean isCompanionRunningM = companionDeviceVersion > Build.VERSION_CODES.LOLLIPOP_MR1; + if (isCompanionTargetingM) { // Case 2 Warning + Log.w(TAG, "MNC: Wear app's targetSdkVersion should be at least 23, if " + + "phone app is targeting at least 23, will continue."); + } + if ((isCompanionTargetingM && isCompanionRunningM) || // Case 2 + companionSdkVersion == 0 || companionDeviceVersion == 0) { // Case 4 + startPermsServiceForInstall(pkg, apkFile, unavailableWearablePerms); + } + + // Case 3a-3c. + return false; + } + + /** + * Given a {@string packageName} corresponding to a phone app, query the provider for all the + * perms that are granted. + * @return null if there is an error retrieving this info + * else, a list of all the wearable perms that are not in the list of granted perms of + * the phone. + */ + private List<String> getWearPermsNotGrantedOnPhone(String packageName, Uri permUri, + List<String> wearablePermissions) { + if (permUri == null) { + Log.e(TAG, "Permission URI is null"); + return null; + } + Cursor permCursor = getContentResolver().query(permUri, null, null, null, null); + if (permCursor == null) { + Log.e(TAG, "Could not get the cursor for the permissions"); + return null; + } + + Set<String> grantedPerms = new HashSet<>(); + Set<String> ungrantedPerms = new HashSet<>(); + while(permCursor.moveToNext()) { + // Make sure that the MatrixCursor returned by the ContentProvider has 2 columns and + // verify their types. + if (permCursor.getColumnCount() == 2 + && Cursor.FIELD_TYPE_STRING == permCursor.getType(0) + && Cursor.FIELD_TYPE_INTEGER == permCursor.getType(1)) { + String perm = permCursor.getString(0); + Integer granted = permCursor.getInt(1); + if (granted == 1) { + grantedPerms.add(perm); + } else { + ungrantedPerms.add(perm); + } + } + } + permCursor.close(); + + ArrayList<String> unavailableWearablePerms = new ArrayList<>(); + for (String wearablePerm : wearablePermissions) { + if (!grantedPerms.contains(wearablePerm)) { + unavailableWearablePerms.add(wearablePerm); + if (!ungrantedPerms.contains(wearablePerm)) { + // This is an error condition. This means that the wearable has permissions that + // are not even declared in its host app. This is a developer error. + Log.e(TAG, "Wearable " + packageName + " has a permission \"" + wearablePerm + + "\" that is not defined in the host application's manifest."); + } else { + Log.w(TAG, "Wearable " + packageName + " has a permission \"" + wearablePerm + + "\" that is not granted in the host application."); + } + } + } + return unavailableWearablePerms; + } + + private void finishService(PowerManager.WakeLock lock, int startId) { + if (lock.isHeld()) { + lock.release(); + } + stopSelf(startId); + } + + private synchronized PowerManager.WakeLock getLock(Context context) { + if (lockStatic == null) { + PowerManager mgr = + (PowerManager) context.getSystemService(Context.POWER_SERVICE); + lockStatic = mgr.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, context.getClass().getSimpleName()); + lockStatic.setReferenceCounted(true); + } + return lockStatic; + } + + private void startPermsServiceForInstall(final PackageParser.Package pkg, final File apkFile, + List<String> unavailableWearablePerms) { + final String packageName = pkg.packageName; + + Intent showPermsIntent = new Intent() + .setComponent(new ComponentName(HOME_APP_PACKAGE_NAME, SHOW_PERMS_SERVICE_CLASS)) + .setAction(Intent.ACTION_INSTALL_PACKAGE); + final PackageManager pm = getPackageManager(); + pkg.applicationInfo.publicSourceDir = apkFile.getPath(); + final CharSequence label = pkg.applicationInfo.loadLabel(pm); + final Uri iconUri = getIconFileUri(packageName, pkg.applicationInfo.loadIcon(pm)); + if (TextUtils.isEmpty(label) || iconUri == null) { + Log.e(TAG, "MNC: Could not launch service since either label " + label + + ", or icon Uri " + iconUri + " is invalid."); + } else { + showPermsIntent.putExtra(KEY_APP_LABEL, label); + showPermsIntent.putExtra(KEY_APP_ICON_URI, iconUri); + showPermsIntent.putExtra(KEY_PACKAGE_NAME, packageName); + showPermsIntent.putExtra(KEY_PERMS_LIST, + unavailableWearablePerms.toArray(new String[0])); + showPermsIntent.putExtra(KEY_HAS_LAUNCHER, WearPackageUtil.hasLauncherActivity(pkg)); + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "MNC: Launching Intent " + showPermsIntent + " for " + packageName + + " with name " + label); + } + startService(showPermsIntent); + } + } + + private void startPermsServiceForUninstall(final String packageName) { + Intent showPermsIntent = new Intent() + .setComponent(new ComponentName(HOME_APP_PACKAGE_NAME, SHOW_PERMS_SERVICE_CLASS)) + .setAction(Intent.ACTION_UNINSTALL_PACKAGE); + showPermsIntent.putExtra(KEY_PACKAGE_NAME, packageName); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Launching Intent " + showPermsIntent + " for " + packageName); + } + startService(showPermsIntent); + } + + private Uri getIconFileUri(final String packageName, final Drawable d) { + if (d == null || !(d instanceof BitmapDrawable)) { + Log.e(TAG, "Drawable is not a BitmapDrawable for " + packageName); + return null; + } + File iconFile = WearPackageUtil.getIconFile(this, packageName); + + if (iconFile == null) { + Log.e(TAG, "Could not get icon file for " + packageName); + return null; + } + + FileOutputStream fos = null; + try { + // Convert bitmap to byte array + Bitmap bitmap = ((BitmapDrawable) d).getBitmap(); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 0, bos); + + // Write the bytes into the file + fos = new FileOutputStream(iconFile); + fos.write(bos.toByteArray()); + fos.flush(); + + return WearPackageIconProvider.getUriForPackage(packageName); + } catch (IOException e) { + Log.e(TAG, "Could not convert drawable to icon file for package " + packageName, e); + return null; + } finally { + if (fos != null) { + try { + fos.close(); + } catch (IOException e) { + // ignore + } + } + } + } + + private class PackageInstallObserver extends IPackageInstallObserver.Stub { + private Context mContext; + private PowerManager.WakeLock mWakeLock; + private int mStartId; + private String mApplicationPackageName; + private PackageInstallObserver(Context context, PowerManager.WakeLock wakeLock, + int startId, String applicationPackageName) { + mContext = context; + mWakeLock = wakeLock; + mStartId = startId; + mApplicationPackageName = applicationPackageName; + } + + public void packageInstalled(String packageName, int returnCode) { + try { + // If installation failed, bail out and remove the ShowPermsStore entry + if (returnCode < 0) { + Log.e(TAG, "Package install failed " + mApplicationPackageName + + ", returnCode " + returnCode); + WearPackageUtil.removeFromPermStore(mContext, mApplicationPackageName); + return; + } + + Log.i(TAG, "Package " + packageName + " was installed."); + + // Delete tempFile from the file system. + File tempFile = WearPackageUtil.getTemporaryFile(mContext, packageName); + if (tempFile != null) { + tempFile.delete(); + } + + // Broadcast the "UPDATED" gmscore intent, normally sent by play store. + // TODO: Remove this broadcast if/when we get the play store to do this for us. + if (GMS_PACKAGE_NAME.equals(packageName)) { + Intent gmsInstalledIntent = new Intent(GMS_UPDATED_BROADCAST); + gmsInstalledIntent.setPackage(GMS_PACKAGE_NAME); + mContext.sendBroadcast(gmsInstalledIntent); + } + } finally { + finishService(mWakeLock, mStartId); + } + } + } + + private class PackageDeleteObserver extends IPackageDeleteObserver.Stub { + private PowerManager.WakeLock mWakeLock; + private int mStartId; + + private PackageDeleteObserver(PowerManager.WakeLock wakeLock, int startId) { + mWakeLock = wakeLock; + mStartId = startId; + } + + public void packageDeleted(String packageName, int returnCode) { + try { + if (returnCode >= 0) { + Log.i(TAG, "Package " + packageName + " was uninstalled."); + } else { + Log.e(TAG, "Package uninstall failed " + packageName + ", returnCode " + + returnCode); + } + } finally { + finishService(mWakeLock, mStartId); + } + } + } +} diff --git a/src/com/android/packageinstaller/wear/WearPackageUtil.java b/src/com/android/packageinstaller/wear/WearPackageUtil.java new file mode 100644 index 00000000..688d6167 --- /dev/null +++ b/src/com/android/packageinstaller/wear/WearPackageUtil.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2015 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.packageinstaller.wear; + +import android.annotation.Nullable; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageParser; +import android.os.ParcelFileDescriptor; +import android.system.ErrnoException; +import android.system.Os; +import android.text.TextUtils; +import android.util.Log; + +import org.tukaani.xz.LZMAInputStream; +import org.tukaani.xz.XZInputStream; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; + +public class WearPackageUtil { + private static final String TAG = "WearablePkgInstaller"; + + private static final String COMPRESSION_LZMA = "lzma"; + private static final String COMPRESSION_XZ = "xz"; + + private static final String SHOW_PERMS_SERVICE_PKG_NAME = "com.google.android.wearable.app"; + private static final String SHOW_PERMS_SERVICE_CLASS_NAME = + "com.google.android.clockwork.packagemanager.ShowPermsService"; + private static final String EXTRA_PACKAGE_NAME + = "com.google.android.clockwork.EXTRA_PACKAGE_NAME"; + + public static File getTemporaryFile(Context context, String packageName) { + try { + File newFileDir = new File(context.getFilesDir(), "tmp"); + newFileDir.mkdirs(); + Os.chmod(newFileDir.getAbsolutePath(), 0771); + File newFile = new File(newFileDir, packageName + ".apk"); + return newFile; + } catch (ErrnoException e) { + Log.e(TAG, "Failed to open.", e); + return null; + } + } + + public static File getIconFile(final Context context, final String packageName) { + try { + File newFileDir = new File(context.getFilesDir(), "images/icons"); + newFileDir.mkdirs(); + Os.chmod(newFileDir.getAbsolutePath(), 0771); + return new File(newFileDir, packageName + ".icon"); + } catch (ErrnoException e) { + Log.e(TAG, "Failed to open.", e); + return null; + } + } + + /** + * In order to make sure that the Wearable Asset Manager has a reasonable apk that can be used + * by the PackageManager, we will parse it before sending it to the PackageManager. + * Unfortunately, PackageParser needs a file to parse. So, we have to temporarily convert the fd + * to a File. + * + * @param context + * @param fd FileDescriptor to convert to File + * @param packageName Name of package, will define the name of the file + * @param compressionAlg Can be null. For ALT mode the APK will be compressed. We will + * decompress it here + */ + public static File getFileFromFd(Context context, ParcelFileDescriptor fd, + String packageName, @Nullable String compressionAlg) { + File newFile = getTemporaryFile(context, packageName); + if (fd == null || fd.getFileDescriptor() == null) { + return null; + } + InputStream fr = new ParcelFileDescriptor.AutoCloseInputStream(fd); + try { + if (TextUtils.equals(compressionAlg, COMPRESSION_XZ)) { + fr = new XZInputStream(fr); + } else if (TextUtils.equals(compressionAlg, COMPRESSION_LZMA)) { + fr = new LZMAInputStream(fr); + } + } catch (IOException e) { + Log.e(TAG, "Compression was set to " + compressionAlg + ", but could not decode ", e); + return null; + } + + int nRead; + byte[] data = new byte[1024]; + try { + final FileOutputStream fo = new FileOutputStream(newFile); + while ((nRead = fr.read(data, 0, data.length)) != -1) { + fo.write(data, 0, nRead); + } + fo.flush(); + fo.close(); + Os.chmod(newFile.getAbsolutePath(), 0644); + return newFile; + } catch (IOException e) { + Log.e(TAG, "Reading from Asset FD or writing to temp file failed ", e); + return null; + } catch (ErrnoException e) { + Log.e(TAG, "Could not set permissions on file ", e); + return null; + } finally { + try { + fr.close(); + } catch (IOException e) { + Log.e(TAG, "Failed to close the file from FD ", e); + } + } + } + + public static boolean hasLauncherActivity(PackageParser.Package pkg) { + if (pkg == null || pkg.activities == null) { + return false; + } + + final int activityCount = pkg.activities.size(); + for (int i = 0; i < activityCount; ++i) { + if (pkg.activities.get(i).intents != null) { + ArrayList<PackageParser.ActivityIntentInfo> intents = + pkg.activities.get(i).intents; + final int intentsCount = intents.size(); + for (int j = 0; j < intentsCount; ++j) { + final PackageParser.ActivityIntentInfo intentInfo = intents.get(j); + if (intentInfo.hasAction(Intent.ACTION_MAIN)) { + if (intentInfo.hasCategory(Intent.CATEGORY_INFO) || + intentInfo .hasCategory(Intent.CATEGORY_LAUNCHER)) { + return true; + } + } + } + } + } + return false; + } + + public static void removeFromPermStore(Context context, String wearablePackageName) { + Intent newIntent = new Intent() + .setComponent(new ComponentName( + SHOW_PERMS_SERVICE_PKG_NAME, SHOW_PERMS_SERVICE_CLASS_NAME)) + .setAction(Intent.ACTION_UNINSTALL_PACKAGE); + newIntent.putExtra(EXTRA_PACKAGE_NAME, wearablePackageName); + Log.i(TAG, "Sending removeFromPermStore to ShowPermsService " + newIntent + + " for " + wearablePackageName); + context.startService(newIntent); + } +} |