1 /* 2 * Copyright (C) 2013 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.launcher3; 18 19 import android.annotation.TargetApi; 20 import android.app.ActionBar; 21 import android.app.Activity; 22 import android.app.WallpaperManager; 23 import android.content.Context; 24 import android.content.DialogInterface; 25 import android.content.Intent; 26 import android.content.SharedPreferences; 27 import android.content.res.Configuration; 28 import android.content.res.Resources; 29 import android.graphics.Bitmap; 30 import android.graphics.Matrix; 31 import android.graphics.Point; 32 import android.graphics.PointF; 33 import android.graphics.RectF; 34 import android.net.Uri; 35 import android.os.Build; 36 import android.os.Bundle; 37 import android.os.Handler; 38 import android.os.HandlerThread; 39 import android.os.Message; 40 import android.util.Log; 41 import android.view.Display; 42 import android.view.View; 43 import android.widget.Toast; 44 45 import com.android.gallery3d.common.BitmapCropTask; 46 import com.android.gallery3d.common.BitmapUtils; 47 import com.android.gallery3d.common.Utils; 48 import com.android.launcher3.base.BaseActivity; 49 import com.android.launcher3.util.Thunk; 50 import com.android.launcher3.util.WallpaperUtils; 51 import com.android.photos.BitmapRegionTileSource; 52 import com.android.photos.BitmapRegionTileSource.BitmapSource; 53 import com.android.photos.BitmapRegionTileSource.BitmapSource.InBitmapProvider; 54 import com.android.photos.views.TiledImageRenderer.TileSource; 55 56 import java.util.Collections; 57 import java.util.Set; 58 import java.util.WeakHashMap; 59 60 public class WallpaperCropActivity extends BaseActivity implements Handler.Callback { 61 private static final String LOGTAG = "Launcher3.CropActivity"; 62 63 protected static final String WALLPAPER_WIDTH_KEY = WallpaperUtils.WALLPAPER_WIDTH_KEY; 64 protected static final String WALLPAPER_HEIGHT_KEY = WallpaperUtils.WALLPAPER_HEIGHT_KEY; 65 66 /** 67 * The maximum bitmap size we allow to be returned through the intent. 68 * Intents have a maximum of 1MB in total size. However, the Bitmap seems to 69 * have some overhead to hit so that we go way below the limit here to make 70 * sure the intent stays below 1MB.We should consider just returning a byte 71 * array instead of a Bitmap instance to avoid overhead. 72 */ 73 public static final int MAX_BMAP_IN_INTENT = 750000; 74 public static final float WALLPAPER_SCREENS_SPAN = WallpaperUtils.WALLPAPER_SCREENS_SPAN; 75 76 private static final int MSG_LOAD_IMAGE = 1; 77 78 protected CropView mCropView; 79 protected View mProgressView; 80 protected Uri mUri; 81 protected View mSetWallpaperButton; 82 83 private HandlerThread mLoaderThread; 84 private Handler mLoaderHandler; 85 @Thunk LoadRequest mCurrentLoadRequest; 86 private byte[] mTempStorageForDecoding = new byte[16 * 1024]; 87 // A weak-set of reusable bitmaps 88 @Thunk Set<Bitmap> mReusableBitmaps = 89 Collections.newSetFromMap(new WeakHashMap<Bitmap, Boolean>()); 90 91 private final DialogInterface.OnCancelListener mOnDialogCancelListener = 92 new DialogInterface.OnCancelListener() { 93 @Override 94 public void onCancel(DialogInterface dialog) { 95 getActionBar().show(); 96 View wallpaperStrip = findViewById(R.id.wallpaper_strip); 97 if (wallpaperStrip != null) { 98 wallpaperStrip.setVisibility(View.VISIBLE); 99 } 100 } 101 }; 102 103 @Override 104 public void onCreate(Bundle savedInstanceState) { 105 super.onCreate(savedInstanceState); 106 107 mLoaderThread = new HandlerThread("wallpaper_loader"); 108 mLoaderThread.start(); 109 mLoaderHandler = new Handler(mLoaderThread.getLooper(), this); 110 111 init(); 112 if (!enableRotation()) { 113 setRequestedOrientation(Configuration.ORIENTATION_PORTRAIT); 114 } 115 } 116 117 protected void init() { 118 setContentView(R.layout.wallpaper_cropper); 119 120 mCropView = (CropView) findViewById(R.id.cropView); 121 mProgressView = findViewById(R.id.loading); 122 123 Intent cropIntent = getIntent(); 124 final Uri imageUri = cropIntent.getData(); 125 126 if (imageUri == null) { 127 Log.e(LOGTAG, "No URI passed in intent, exiting WallpaperCropActivity"); 128 finish(); 129 return; 130 } 131 132 // Action bar 133 // Show the custom action bar view 134 final ActionBar actionBar = getActionBar(); 135 actionBar.setCustomView(R.layout.actionbar_set_wallpaper); 136 actionBar.getCustomView().setOnClickListener( 137 new View.OnClickListener() { 138 @Override 139 public void onClick(View v) { 140 actionBar.hide(); 141 boolean finishActivityWhenDone = true; 142 // Never fade on finish because we return to the app that started us (e.g. 143 // Photos), not the home screen. 144 boolean shouldFadeOutOnFinish = false; 145 cropImageAndSetWallpaper(imageUri, null, finishActivityWhenDone, 146 shouldFadeOutOnFinish); 147 } 148 }); 149 mSetWallpaperButton = findViewById(R.id.set_wallpaper_button); 150 151 // Load image in background 152 final BitmapRegionTileSource.UriBitmapSource bitmapSource = 153 new BitmapRegionTileSource.UriBitmapSource(getContext(), imageUri); 154 mSetWallpaperButton.setEnabled(false); 155 Runnable onLoad = new Runnable() { 156 public void run() { 157 if (bitmapSource.getLoadingState() != BitmapSource.State.LOADED) { 158 Toast.makeText(getContext(), R.string.wallpaper_load_fail, 159 Toast.LENGTH_LONG).show(); 160 finish(); 161 } else { 162 mSetWallpaperButton.setEnabled(true); 163 } 164 } 165 }; 166 setCropViewTileSource(bitmapSource, true, false, null, onLoad); 167 } 168 169 @Override 170 public void onDestroy() { 171 if (mCropView != null) { 172 mCropView.destroy(); 173 } 174 if (mLoaderThread != null) { 175 mLoaderThread.quit(); 176 } 177 super.onDestroy(); 178 } 179 180 /** 181 * This is called on {@link #mLoaderThread} 182 */ 183 @Override 184 public boolean handleMessage(Message msg) { 185 if (msg.what == MSG_LOAD_IMAGE) { 186 final LoadRequest req = (LoadRequest) msg.obj; 187 try { 188 req.src.loadInBackground(new InBitmapProvider() { 189 190 @Override 191 public Bitmap forPixelCount(int count) { 192 Bitmap bitmapToReuse = null; 193 // Find the smallest bitmap that satisfies the pixel count limit 194 synchronized (mReusableBitmaps) { 195 int currentBitmapSize = Integer.MAX_VALUE; 196 for (Bitmap b : mReusableBitmaps) { 197 int bitmapSize = b.getWidth() * b.getHeight(); 198 if ((bitmapSize >= count) && (bitmapSize < currentBitmapSize)) { 199 bitmapToReuse = b; 200 currentBitmapSize = bitmapSize; 201 } 202 } 203 204 if (bitmapToReuse != null) { 205 mReusableBitmaps.remove(bitmapToReuse); 206 } 207 } 208 return bitmapToReuse; 209 } 210 }); 211 } catch (SecurityException securityException) { 212 if (isActivityDestroyed()) { 213 // Temporarily granted permissions are revoked when the activity 214 // finishes, potentially resulting in a SecurityException here. 215 // Even though {@link #isDestroyed} might also return true in different 216 // situations where the configuration changes, we are fine with 217 // catching these cases here as well. 218 return true; 219 } else { 220 // otherwise it had a different cause and we throw it further 221 throw securityException; 222 } 223 } 224 225 req.result = new BitmapRegionTileSource(getContext(), req.src, mTempStorageForDecoding); 226 runOnUiThread(new Runnable() { 227 228 @Override 229 public void run() { 230 if (req == mCurrentLoadRequest) { 231 onLoadRequestComplete(req, 232 req.src.getLoadingState() == BitmapSource.State.LOADED); 233 } else { 234 addReusableBitmap(req.result); 235 } 236 } 237 }); 238 return true; 239 } 240 return false; 241 } 242 243 @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) 244 protected boolean isActivityDestroyed() { 245 return Utilities.ATLEAST_JB_MR1 && isDestroyed(); 246 } 247 248 @Thunk void addReusableBitmap(TileSource src) { 249 synchronized (mReusableBitmaps) { 250 if (Utilities.ATLEAST_KITKAT && src instanceof BitmapRegionTileSource) { 251 Bitmap preview = ((BitmapRegionTileSource) src).getBitmap(); 252 if (preview != null && preview.isMutable()) { 253 mReusableBitmaps.add(preview); 254 } 255 } 256 } 257 } 258 259 public DialogInterface.OnCancelListener getOnDialogCancelListener() { 260 return mOnDialogCancelListener; 261 } 262 263 protected void onLoadRequestComplete(LoadRequest req, boolean success) { 264 mCurrentLoadRequest = null; 265 if (success) { 266 TileSource oldSrc = mCropView.getTileSource(); 267 mCropView.setTileSource(req.result, null); 268 mCropView.setTouchEnabled(req.touchEnabled); 269 if (req.moveToLeft) { 270 mCropView.moveToLeft(); 271 } 272 if (req.scaleAndOffsetProvider != null) { 273 req.scaleAndOffsetProvider.updateCropView(this, req.result); 274 } 275 276 // Free last image 277 if (oldSrc != null) { 278 // Call yield instead of recycle, as we only want to free GL resource. 279 // We can still reuse the bitmap for decoding any other image. 280 oldSrc.getPreview().yield(); 281 } 282 addReusableBitmap(oldSrc); 283 } 284 if (req.postExecute != null) { 285 req.postExecute.run(); 286 } 287 mProgressView.setVisibility(View.GONE); 288 } 289 290 public final void setCropViewTileSource(BitmapSource bitmapSource, boolean touchEnabled, 291 boolean moveToLeft, CropViewScaleAndOffsetProvider scaleProvider, Runnable postExecute) { 292 final LoadRequest req = new LoadRequest(); 293 req.moveToLeft = moveToLeft; 294 req.src = bitmapSource; 295 req.touchEnabled = touchEnabled; 296 req.postExecute = postExecute; 297 req.scaleAndOffsetProvider = scaleProvider; 298 mCurrentLoadRequest = req; 299 300 // Remove any pending requests 301 mLoaderHandler.removeMessages(MSG_LOAD_IMAGE); 302 Message.obtain(mLoaderHandler, MSG_LOAD_IMAGE, req).sendToTarget(); 303 304 // We don't want to show the spinner every time we load an image, because that would be 305 // annoying; instead, only start showing the spinner if loading the image has taken 306 // longer than 1 sec (ie 1000 ms) 307 mProgressView.postDelayed(new Runnable() { 308 public void run() { 309 if (mCurrentLoadRequest == req) { 310 mProgressView.setVisibility(View.VISIBLE); 311 } 312 } 313 }, 1000); 314 } 315 316 317 public boolean enableRotation() { 318 return getResources().getBoolean(R.bool.allow_rotation); 319 } 320 321 protected void setWallpaper(Uri uri, final boolean finishActivityWhenDone, 322 final boolean shouldFadeOutOnFinish) { 323 int rotation = BitmapUtils.getRotationFromExif(getContext(), uri); 324 BitmapCropTask cropTask = new BitmapCropTask( 325 getContext(), uri, null, rotation, 0, 0, true, false, null); 326 final Point bounds = cropTask.getImageBounds(); 327 BitmapCropTask.OnEndCropHandler onEndCrop = new BitmapCropTask.OnEndCropHandler() { 328 public void run(boolean cropSucceeded) { 329 updateWallpaperDimensions(bounds.x, bounds.y); 330 if (finishActivityWhenDone) { 331 setResult(Activity.RESULT_OK); 332 finish(); 333 if (cropSucceeded && shouldFadeOutOnFinish) { 334 overridePendingTransition(0, R.anim.fade_out); 335 } 336 } 337 } 338 }; 339 cropTask.setOnEndRunnable(onEndCrop); 340 cropTask.setNoCrop(true); 341 NycWallpaperUtils.executeCropTaskAfterPrompt(this, cropTask, getOnDialogCancelListener()); 342 } 343 344 protected void cropImageAndSetWallpaper(Resources res, int resId, 345 final boolean finishActivityWhenDone, final boolean shouldFadeOutOnFinish) { 346 // crop this image and scale it down to the default wallpaper size for 347 // this device 348 int rotation = BitmapUtils.getRotationFromExif(res, resId); 349 Point inSize = mCropView.getSourceDimensions(); 350 Point outSize = WallpaperUtils.getDefaultWallpaperSize(getResources(), getWindowManager()); 351 RectF crop = Utils.getMaxCropRect( 352 inSize.x, inSize.y, outSize.x, outSize.y, false); 353 BitmapCropTask.OnEndCropHandler onEndCrop = new BitmapCropTask.OnEndCropHandler() { 354 public void run(boolean cropSucceeded) { 355 // Passing 0, 0 will cause launcher to revert to using the 356 // default wallpaper size 357 updateWallpaperDimensions(0, 0); 358 if (finishActivityWhenDone) { 359 setResult(Activity.RESULT_OK); 360 finish(); 361 if (cropSucceeded && shouldFadeOutOnFinish) { 362 overridePendingTransition(0, R.anim.fade_out); 363 } 364 } 365 } 366 }; 367 BitmapCropTask cropTask = new BitmapCropTask(getContext(), res, resId, 368 crop, rotation, outSize.x, outSize.y, true, false, onEndCrop); 369 NycWallpaperUtils.executeCropTaskAfterPrompt(this, cropTask, getOnDialogCancelListener()); 370 } 371 372 @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) 373 protected void cropImageAndSetWallpaper(Uri uri, 374 BitmapCropTask.OnBitmapCroppedHandler onBitmapCroppedHandler, 375 final boolean finishActivityWhenDone, final boolean shouldFadeOutOnFinish) { 376 // Give some feedback so user knows something is happening. 377 mProgressView.setVisibility(View.VISIBLE); 378 379 boolean centerCrop = getResources().getBoolean(R.bool.center_crop); 380 // Get the crop 381 boolean ltr = mCropView.getLayoutDirection() == View.LAYOUT_DIRECTION_LTR; 382 383 Display d = getWindowManager().getDefaultDisplay(); 384 385 Point displaySize = new Point(); 386 d.getSize(displaySize); 387 boolean isPortrait = displaySize.x < displaySize.y; 388 389 Point defaultWallpaperSize = WallpaperUtils.getDefaultWallpaperSize(getResources(), 390 getWindowManager()); 391 // Get the crop 392 RectF cropRect = mCropView.getCrop(); 393 394 Point inSize = mCropView.getSourceDimensions(); 395 396 int cropRotation = mCropView.getImageRotation(); 397 float cropScale = mCropView.getWidth() / (float) cropRect.width(); 398 399 400 Matrix rotateMatrix = new Matrix(); 401 rotateMatrix.setRotate(cropRotation); 402 float[] rotatedInSize = new float[] { inSize.x, inSize.y }; 403 rotateMatrix.mapPoints(rotatedInSize); 404 rotatedInSize[0] = Math.abs(rotatedInSize[0]); 405 rotatedInSize[1] = Math.abs(rotatedInSize[1]); 406 407 408 // due to rounding errors in the cropview renderer the edges can be slightly offset 409 // therefore we ensure that the boundaries are sanely defined 410 cropRect.left = Math.max(0, cropRect.left); 411 cropRect.right = Math.min(rotatedInSize[0], cropRect.right); 412 cropRect.top = Math.max(0, cropRect.top); 413 cropRect.bottom = Math.min(rotatedInSize[1], cropRect.bottom); 414 415 // ADJUST CROP WIDTH 416 // Extend the crop all the way to the right, for parallax 417 // (or all the way to the left, in RTL) 418 float extraSpace; 419 if (centerCrop) { 420 extraSpace = 2f * Math.min(rotatedInSize[0] - cropRect.right, cropRect.left); 421 } else { 422 extraSpace = ltr ? rotatedInSize[0] - cropRect.right : cropRect.left; 423 } 424 // Cap the amount of extra width 425 float maxExtraSpace = defaultWallpaperSize.x / cropScale - cropRect.width(); 426 extraSpace = Math.min(extraSpace, maxExtraSpace); 427 428 if (centerCrop) { 429 cropRect.left -= extraSpace / 2f; 430 cropRect.right += extraSpace / 2f; 431 } else { 432 if (ltr) { 433 cropRect.right += extraSpace; 434 } else { 435 cropRect.left -= extraSpace; 436 } 437 } 438 439 // ADJUST CROP HEIGHT 440 if (isPortrait) { 441 cropRect.bottom = cropRect.top + defaultWallpaperSize.y / cropScale; 442 } else { // LANDSCAPE 443 float extraPortraitHeight = 444 defaultWallpaperSize.y / cropScale - cropRect.height(); 445 float expandHeight = 446 Math.min(Math.min(rotatedInSize[1] - cropRect.bottom, cropRect.top), 447 extraPortraitHeight / 2); 448 cropRect.top -= expandHeight; 449 cropRect.bottom += expandHeight; 450 } 451 final int outWidth = (int) Math.round(cropRect.width() * cropScale); 452 final int outHeight = (int) Math.round(cropRect.height() * cropScale); 453 454 BitmapCropTask.OnEndCropHandler onEndCrop = new BitmapCropTask.OnEndCropHandler() { 455 public void run(boolean cropSucceeded) { 456 updateWallpaperDimensions(outWidth, outHeight); 457 if (finishActivityWhenDone) { 458 setResult(Activity.RESULT_OK); 459 finish(); 460 } 461 if (cropSucceeded && shouldFadeOutOnFinish) { 462 overridePendingTransition(0, R.anim.fade_out); 463 } 464 } 465 }; 466 BitmapCropTask cropTask = new BitmapCropTask(getContext(), uri, 467 cropRect, cropRotation, outWidth, outHeight, true, false, onEndCrop); 468 if (onBitmapCroppedHandler != null) { 469 cropTask.setOnBitmapCropped(onBitmapCroppedHandler); 470 } 471 NycWallpaperUtils.executeCropTaskAfterPrompt(this, cropTask, getOnDialogCancelListener()); 472 } 473 474 protected void updateWallpaperDimensions(int width, int height) { 475 String spKey = LauncherFiles.WALLPAPER_CROP_PREFERENCES_KEY; 476 SharedPreferences sp = getContext().getSharedPreferences(spKey, Context.MODE_MULTI_PROCESS); 477 SharedPreferences.Editor editor = sp.edit(); 478 if (width != 0 && height != 0) { 479 editor.putInt(WALLPAPER_WIDTH_KEY, width); 480 editor.putInt(WALLPAPER_HEIGHT_KEY, height); 481 } else { 482 editor.remove(WALLPAPER_WIDTH_KEY); 483 editor.remove(WALLPAPER_HEIGHT_KEY); 484 } 485 editor.apply(); 486 WallpaperUtils.suggestWallpaperDimension(getResources(), 487 sp, getWindowManager(), WallpaperManager.getInstance(getContext()), true); 488 } 489 490 static class LoadRequest { 491 BitmapSource src; 492 boolean touchEnabled; 493 boolean moveToLeft; 494 Runnable postExecute; 495 CropViewScaleAndOffsetProvider scaleAndOffsetProvider; 496 497 TileSource result; 498 } 499 500 public static class CropViewScaleAndOffsetProvider { 501 public float getScale(Point wallpaperSize, RectF crop) { 502 return 1f; 503 } 504 505 public float getParallaxOffset() { 506 return 0.5f; 507 } 508 509 public void updateCropView(WallpaperCropActivity a, TileSource src) { 510 Point wallpaperSize = WallpaperUtils.getDefaultWallpaperSize( 511 a.getResources(), a.getWindowManager()); 512 RectF crop = Utils.getMaxCropRect(src.getImageWidth(), src.getImageHeight(), 513 wallpaperSize.x, wallpaperSize.y, false /* leftAligned */); 514 515 float scale = getScale(wallpaperSize, crop); 516 PointF center = a.mCropView.getCenter(); 517 518 // Offsets wallpaper preview according to the state it will be displayed in upon 519 // returning home. Offset ranges from 0 to 1, where 0 is the leftmost parallax and 520 // 1 is the rightmost. 521 // Make sure the offset is in the correct range. 522 float offset = Math.max(0, Math.min(getParallaxOffset(), 1)); 523 float screenWidth = a.mCropView.getWidth() / scale; 524 center.x = screenWidth / 2 + offset * (crop.width() - screenWidth) + crop.left; 525 a.mCropView.setScaleAndCenter(scale, center.x, center.y); 526 } 527 } 528 } 529