diff --git a/gallery/build.properties b/gallery/build.properties index cfdb87e..295cbaa 100644 --- a/gallery/build.properties +++ b/gallery/build.properties @@ -1,8 +1,8 @@ #Created by .winboll/winboll_app_build.gradle -#Sun Apr 26 23:52:51 HKT 2026 +#Mon Apr 27 15:42:17 CST 2026 stageCount=7 libraryProject= baseVersion=15.0 publishVersion=15.0.6 -buildCount=0 +buildCount=16 baseBetaVersion=15.0.7 diff --git a/gallery/src/main/java/cc/winboll/studio/gallery/CropActivity.java b/gallery/src/main/java/cc/winboll/studio/gallery/CropActivity.java index 187fa09..b7ab388 100644 --- a/gallery/src/main/java/cc/winboll/studio/gallery/CropActivity.java +++ b/gallery/src/main/java/cc/winboll/studio/gallery/CropActivity.java @@ -5,14 +5,17 @@ import android.graphics.BitmapFactory; import android.graphics.RectF; import android.net.Uri; import android.os.Bundle; +import android.content.Intent; import android.provider.MediaStore; import android.view.View; import android.widget.ImageView; +import android.widget.ScrollView; import android.widget.Toast; import androidx.appcompat.app.AppCompatActivity; import cc.winboll.studio.libappbase.LogUtils; import java.io.File; import java.io.FileOutputStream; +import java.io.IOException; public class CropActivity extends AppCompatActivity { public static final String TAG = "CropActivity"; @@ -23,6 +26,7 @@ public class CropActivity extends AppCompatActivity { public static final String EXTRA_CROP_HEIGHT = "crop_height"; private CropCanvasView cropCanvasView; + private ZoomContainerView zoomContainer; private Bitmap originalBitmap; private String imagePath; private String albumPath; @@ -73,6 +77,25 @@ public class CropActivity extends AppCompatActivity { } }); + zoomContainer = findViewById(R.id.zoom_container); + findViewById(R.id.btn_zoom_in).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (zoomContainer != null) { + zoomContainer.zoomIn(); + } + } + }); + + findViewById(R.id.btn_zoom_out).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (zoomContainer != null) { + zoomContainer.zoomOut(); + } + } + }); + cropCanvasView = findViewById(R.id.crop_canvas_view); loadImage(); } @@ -118,37 +141,57 @@ public class CropActivity extends AppCompatActivity { } private void saveCroppedCover() { - Bitmap canvasBitmap = cropCanvasView.getCanvasBitmap(); - if (canvasBitmap == null) { + if (originalBitmap == null || originalBitmap.isRecycled()) { Toast.makeText(this, "Failed to get image", Toast.LENGTH_SHORT).show(); return; } try { RectF cropRect = cropCanvasView.getCropRect(); + RectF imageBounds = cropCanvasView.getImageBounds(); - int cropX = (int) cropRect.left; - int cropY = (int) cropRect.top; - int cropW = (int) cropRect.width(); - int cropH = (int) cropRect.height(); + float cropX = cropRect.left - imageBounds.left; + float cropY = cropRect.top - imageBounds.top; + float cropW = cropRect.width(); + float cropH = cropRect.height(); - cropX = Math.max(0, Math.min(cropX, canvasBitmap.getWidth() - 1)); - cropY = Math.max(0, Math.min(cropY, canvasBitmap.getHeight() - 1)); - cropW = Math.max(1, Math.min(cropW, canvasBitmap.getWidth() - cropX)); - cropH = Math.max(1, Math.min(cropH, canvasBitmap.getHeight() - cropY)); + int bmpX = (int) cropX; + int bmpY = (int) cropY; + int bmpW = (int) cropW; + int bmpH = (int) cropH; - Bitmap cropped = Bitmap.createBitmap(canvasBitmap, cropX, cropY, cropW, cropH); + bmpX = Math.max(0, Math.min(bmpX, originalBitmap.getWidth() - 1)); + bmpY = Math.max(0, Math.min(bmpY, originalBitmap.getHeight() - 1)); + bmpW = Math.max(1, Math.min(bmpW, originalBitmap.getWidth() - bmpX)); + bmpH = Math.max(1, Math.min(bmpH, originalBitmap.getHeight() - bmpY)); + + LogUtils.d(TAG, "saveCroppedCover: crop=(" + bmpX + "," + bmpY + "," + bmpW + "," + bmpH + ")"); + LogUtils.d(TAG, "saveCroppedCover: original size=" + originalBitmap.getWidth() + "x" + originalBitmap.getHeight()); + + Bitmap cropped = Bitmap.createBitmap(originalBitmap, bmpX, bmpY, bmpW, bmpH); + LogUtils.d(TAG, "saveCroppedCover: cropped size=" + cropped.getWidth() + "x" + cropped.getHeight()); + + File coverDir = new File(getFilesDir(), "covers"); + if (!coverDir.exists()) { + coverDir.mkdirs(); + } + File coverFile = new File(coverDir, "cover_" + System.currentTimeMillis() + ".png"); + LogUtils.d(TAG, "saveCroppedCover: cover file=" + coverFile.getAbsolutePath()); - File cacheDir = getCacheDir(); - File coverFile = new File(cacheDir, "cover_" + System.currentTimeMillis() + ".png"); FileOutputStream fos = new FileOutputStream(coverFile); cropped.compress(Bitmap.CompressFormat.PNG, 100, fos); fos.close(); + LogUtils.d(TAG, "saveCroppedCover: file exists=" + coverFile.exists() + ", length=" + coverFile.length()); + cropped.recycle(); AlbumCoverDbHelper coverDbHelper = AlbumCoverDbHelper.getInstance(this); coverDbHelper.setCoverWithCrop(albumPath, imagePath, coverFile.getAbsolutePath()); + LogUtils.d(TAG, "saveCroppedCover: cover saved to db, albumPath=" + albumPath); + + Intent broadcastIntent = new Intent(Preferences.ACTION_COVER_UPDATED); + sendBroadcast(broadcastIntent); Toast.makeText(this, "封面已保存", Toast.LENGTH_SHORT).show(); setResult(RESULT_OK); diff --git a/gallery/src/main/java/cc/winboll/studio/gallery/CropCanvasView.java b/gallery/src/main/java/cc/winboll/studio/gallery/CropCanvasView.java index 470ba3c..105f845 100644 --- a/gallery/src/main/java/cc/winboll/studio/gallery/CropCanvasView.java +++ b/gallery/src/main/java/cc/winboll/studio/gallery/CropCanvasView.java @@ -4,6 +4,7 @@ import android.content.Context; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; +import android.graphics.Matrix; import android.graphics.Paint; import android.graphics.RectF; import android.util.AttributeSet; @@ -38,12 +39,18 @@ public class CropCanvasView extends View { private RectF imageBounds = new RectF(); private RectF canvasBounds = new RectF(); private Bitmap originalBitmap; - private Bitmap canvasBitmap; + private Bitmap displayBitmap; + private RectF initialSpanRect; private float initialSpan; private int backgroundColor = Color.GREEN; private boolean colorPickMode = false; private float pickX, pickY; + private float displayScale = 1.0f; + private float displayOffsetX = 0f; + private float displayOffsetY = 0f; + private static final int MAX_DISPLAY_SIZE = 2048; + public CropCanvasView(Context context) { super(context); init(); @@ -74,7 +81,43 @@ public class CropCanvasView extends View { } public void setImageBitmap(Bitmap bitmap) { + if (displayBitmap != null && displayBitmap != originalBitmap) { + displayBitmap.recycle(); + } this.originalBitmap = bitmap; + createDisplayBitmap(); + } + + private void createDisplayBitmap() { + if (displayBitmap != null && displayBitmap != originalBitmap) { + displayBitmap.recycle(); + displayBitmap = null; + } + if (originalBitmap == null || originalBitmap.isRecycled()) { + return; + } + int w = originalBitmap.getWidth(); + int h = originalBitmap.getHeight(); + float scale = 1.0f; + if (w > MAX_DISPLAY_SIZE || h > MAX_DISPLAY_SIZE) { + scale = Math.min((float) MAX_DISPLAY_SIZE / w, (float) MAX_DISPLAY_SIZE / h); + int newW = (int) (w * scale); + int newH = (int) (h * scale); + displayBitmap = Bitmap.createScaledBitmap(originalBitmap, newW, newH, true); + } else { + displayBitmap = originalBitmap; + } + displayBitmapScale = scale; + } + + private float displayBitmapScale = 1.0f; + + public Bitmap getOriginalBitmap() { + return originalBitmap; + } + + public float getDisplayBitmapScale() { + return displayBitmapScale; } public void initCanvas(int imgWidth, int imgHeight, float ratio) { @@ -102,31 +145,61 @@ public class CropCanvasView extends View { cropRect = new RectF(0, 0, canvasWidth, canvasHeight); - createCanvasBitmap(); - requestLayout(); invalidate(); } - private void createCanvasBitmap() { - if (canvasBitmap != null) { - canvasBitmap.recycle(); - } - canvasBitmap = Bitmap.createBitmap(canvasWidth, canvasHeight, Bitmap.Config.ARGB_8888); - android.graphics.Canvas canvas = new android.graphics.Canvas(canvasBitmap); - canvas.drawColor(backgroundColor); - if (originalBitmap != null && !originalBitmap.isRecycled()) { - canvas.drawBitmap(originalBitmap, imageBounds.left, imageBounds.top, imagePaint); + public void getDisplayMatrix(Matrix matrix) { + if (canvasWidth <= 0 || canvasHeight <= 0 || getWidth() <= 0 || getHeight() <= 0) { + return; } + matrix.reset(); + + float displayScaleX = (float) getWidth() / canvasWidth; + float displayScaleY = (float) getHeight() / canvasHeight; + displayScale = Math.min(displayScaleX, displayScaleY); + + displayOffsetX = (getWidth() - canvasWidth * displayScale) / 2f; + displayOffsetY = (getHeight() - canvasHeight * displayScale) / 2f; + + matrix.postTranslate(displayOffsetX, displayOffsetY); + matrix.postScale(displayScale, displayScale); } - public Bitmap getCanvasBitmap() { - return canvasBitmap; + public float screenToImageX(float screenX) { + return (screenX - displayOffsetX) / displayScale; + } + + public float screenToImageY(float screenY) { + return (screenY - displayOffsetY) / displayScale; + } + + public float imageToScreenX(float imageX) { + return imageX * displayScale + displayOffsetX; + } + + public float imageToScreenY(float imageY) { + return imageY * displayScale + displayOffsetY; + } + + public int getImageColorAt(float screenX, float screenY) { + Bitmap bmp = (displayBitmap != null) ? displayBitmap : originalBitmap; + if (bmp == null || bmp.isRecycled()) { + return Color.TRANSPARENT; + } + float imgX = screenToImageX(screenX); + float imgY = screenToImageY(screenY); + int bmpX = (int) ((imgX - imageBounds.left) * displayBitmapScale); + int bmpY = (int) ((imgY - imageBounds.top) * displayBitmapScale); + if (bmpX >= 0 && bmpX < bmp.getWidth() && + bmpY >= 0 && bmpY < bmp.getHeight()) { + return bmp.getPixel(bmpX, bmpY); + } + return Color.TRANSPARENT; } public void setBackgroundColor(int color) { this.backgroundColor = color; - createCanvasBitmap(); invalidate(); } @@ -148,30 +221,8 @@ public class CropCanvasView extends View { } public void scaleToView(int viewWidth, int viewHeight) { - if (viewWidth > 0 && viewHeight > 0 && canvasWidth > 0 && canvasHeight > 0) { - float scale = Math.max((float) viewWidth / canvasWidth, (float) viewHeight / canvasHeight); - int oldCanvasW = canvasWidth; - int oldCanvasH = canvasHeight; - canvasWidth = (int) (canvasWidth * scale); - canvasHeight = (int) (canvasHeight * scale); - - float left = (canvasWidth - imageWidth) / 2f; - float top = (canvasHeight - imageHeight) / 2f; - imageBounds.set(left, top, left + imageWidth, top + imageHeight); - canvasBounds.set(0, 0, canvasWidth, canvasHeight); - - if (cropRect != null) { - float scaleX = (float) canvasWidth / oldCanvasW; - float scaleY = (float) canvasHeight / oldCanvasH; - cropRect.left *= scaleX; - cropRect.top *= scaleY; - cropRect.right *= scaleX; - cropRect.bottom *= scaleY; - } - - requestLayout(); - invalidate(); - } + requestLayout(); + invalidate(); } public RectF getCropRect() { @@ -194,6 +245,14 @@ public class CropCanvasView extends View { return new RectF(imageBounds); } + public float getBitmapScale() { + return displayScale; + } + + public float getDisplayScale() { + return displayScale; + } + @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int w = canvasWidth > 0 ? canvasWidth : 0; @@ -201,15 +260,51 @@ public class CropCanvasView extends View { setMeasuredDimension(w, h); } + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + getDisplayMatrix(new Matrix()); + } + @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); - if (canvasBitmap != null && !canvasBitmap.isRecycled()) { - canvas.drawBitmap(canvasBitmap, 0, 0, null); - } + if (cropRect == null) return; - if (cropRect != null) { + if (displayBitmap != null && !displayBitmap.isRecycled()) { + canvas.save(); + + Matrix matrix = new Matrix(); + getDisplayMatrix(matrix); + canvas.concat(matrix); + + canvas.drawColor(backgroundColor); + + if (displayBitmap == originalBitmap) { + canvas.drawBitmap(displayBitmap, imageBounds.left, imageBounds.top, imagePaint); + } else { + float invScale = 1f / displayBitmapScale; + canvas.save(); + canvas.scale(invScale, invScale); + canvas.drawBitmap(displayBitmap, imageBounds.left * displayBitmapScale, + imageBounds.top * displayBitmapScale, imagePaint); + canvas.restore(); + } + + canvas.restore(); + + canvas.drawRect(cropRect, borderPaint); + + float scX = imageToScreenX(cropRect.left); + float scY = imageToScreenY(cropRect.top); + float scR = imageToScreenX(cropRect.right); + float scB = imageToScreenY(cropRect.bottom); + canvas.drawCircle(scX, scY, 12, cornerPaint); + canvas.drawCircle(scR, scY, 12, cornerPaint); + canvas.drawCircle(scX, scB, 12, cornerPaint); + canvas.drawCircle(scR, scB, 12, cornerPaint); + } else { canvas.drawRect(cropRect, borderPaint); canvas.drawCircle(cropRect.left, cropRect.top, 12, cornerPaint); @@ -387,14 +482,6 @@ public class CropCanvasView extends View { } public int getColorAt(float x, float y) { - if (canvasBitmap != null && !canvasBitmap.isRecycled()) { - int bitmapX = (int) x; - int bitmapY = (int) y; - if (bitmapX >= 0 && bitmapX < canvasBitmap.getWidth() && - bitmapY >= 0 && bitmapY < canvasBitmap.getHeight()) { - return canvasBitmap.getPixel(bitmapX, bitmapY); - } - } - return Color.TRANSPARENT; + return getImageColorAt(x, y); } } \ No newline at end of file diff --git a/gallery/src/main/java/cc/winboll/studio/gallery/MainActivity.java b/gallery/src/main/java/cc/winboll/studio/gallery/MainActivity.java index 3cfabaf..a90e6dc 100644 --- a/gallery/src/main/java/cc/winboll/studio/gallery/MainActivity.java +++ b/gallery/src/main/java/cc/winboll/studio/gallery/MainActivity.java @@ -1,8 +1,11 @@ package cc.winboll.studio.gallery; import android.Manifest; +import android.content.BroadcastReceiver; import android.content.ContentResolver; +import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.content.pm.PackageManager; import android.database.Cursor; import android.media.MediaScannerConnection; @@ -192,13 +195,16 @@ private void loadAlbums() { LogUtils.d(TAG, "scanning folder: " + subfolder.getName()); String albumPath = subfolder.getAbsolutePath(); String coverPath = coverDbHelper.getCover(albumPath); + LogUtils.d(TAG, "loadAlbums: album=" + albumPath + ", coverPath=" + coverPath); Uri coverUri = null; if (coverPath != null) { File coverFile = new File(coverPath); if (coverFile.exists()) { coverUri = Uri.fromFile(coverFile); + LogUtils.d(TAG, "loadAlbums: cover from file=" + coverFile.getAbsolutePath()); } else { coverUri = getUriFromPath(coverPath); + LogUtils.d(TAG, "loadAlbums: cover from media store path=" + coverPath); } } if (coverUri == null) { @@ -299,9 +305,19 @@ private void loadAlbums() { return super.onOptionsItemSelected(item); } + private BroadcastReceiver coverUpdatedReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (Preferences.ACTION_COVER_UPDATED.equals(intent.getAction())) { + loadAlbums(); + } + } + }; + @Override protected void onResume() { super.onResume(); + registerReceiver(coverUpdatedReceiver, new IntentFilter(Preferences.ACTION_COVER_UPDATED)); if (checkPermission()) { scanMediaStore(); loadAlbums(); @@ -313,6 +329,12 @@ private void loadAlbums() { } } + @Override + protected void onPause() { + super.onPause(); + unregisterReceiver(coverUpdatedReceiver); + } + private void scanMediaStore() { String folderPath = prefs.getFolderPath(); File baseFolder = new File(folderPath); diff --git a/gallery/src/main/java/cc/winboll/studio/gallery/Preferences.java b/gallery/src/main/java/cc/winboll/studio/gallery/Preferences.java index 5746194..d005cd9 100644 --- a/gallery/src/main/java/cc/winboll/studio/gallery/Preferences.java +++ b/gallery/src/main/java/cc/winboll/studio/gallery/Preferences.java @@ -13,6 +13,9 @@ public class Preferences { private static final String KEY_COVER_WIDTH = "cover_width"; private static final String KEY_COVER_HEIGHT = "cover_height"; private static final String KEY_COVER_RATIO = "cover_ratio"; + + public static final String ACTION_COVER_UPDATED = "cc.winboll.studio.gallery.COVER_UPDATED"; + private static final int DEFAULT_BG_TYPE = 0; private static final int DEFAULT_SORT_MODE = 0; private static final int DEFAULT_COVER_WIDTH = 240; diff --git a/gallery/src/main/java/cc/winboll/studio/gallery/ZoomContainerView.java b/gallery/src/main/java/cc/winboll/studio/gallery/ZoomContainerView.java new file mode 100644 index 0000000..86ab5c1 --- /dev/null +++ b/gallery/src/main/java/cc/winboll/studio/gallery/ZoomContainerView.java @@ -0,0 +1,124 @@ +package cc.winboll.studio.gallery; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.util.AttributeSet; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; +import android.view.View; +import android.widget.FrameLayout; + +public class ZoomContainerView extends FrameLayout { + private float scaleFactor = 1.0f; + private float minScale = 0.5f; + private float maxScale = 5.0f; + private static final float ZOOM_STEP = 0.25f; + + private ScaleGestureDetector scaleGestureDetector; + private GestureDetector gestureDetector; + private Paint borderPaint; + + public ZoomContainerView(Context context) { + super(context); + init(); + } + + public ZoomContainerView(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ZoomContainerView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + setWillNotDraw(false); + + borderPaint = new Paint(); + borderPaint.setColor(Color.parseColor("#333333")); + borderPaint.setStyle(Paint.Style.STROKE); + borderPaint.setStrokeWidth(2); + + scaleGestureDetector = new ScaleGestureDetector(getContext(), new ScaleGestureDetector.SimpleOnScaleGestureListener() { + @Override + public boolean onScale(ScaleGestureDetector detector) { + scaleFactor *= detector.getScaleFactor(); + scaleFactor = Math.max(minScale, Math.min(scaleFactor, maxScale)); + invalidate(); + requestLayout(); + return true; + } + }); + + gestureDetector = new GestureDetector(getContext(), new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onDoubleTap(MotionEvent e) { + if (scaleFactor > 1.5f) { + scaleFactor = 1.0f; + } else { + scaleFactor = 2.0f; + } + invalidate(); + requestLayout(); + return true; + } + }); + } + + public void zoomIn() { + scaleFactor = Math.min(maxScale, scaleFactor + ZOOM_STEP); + invalidate(); + requestLayout(); + } + + public void zoomOut() { + scaleFactor = Math.max(minScale, scaleFactor - ZOOM_STEP); + invalidate(); + requestLayout(); + } + + public float getScaleFactor() { + return scaleFactor; + } + + public void resetZoom() { + scaleFactor = 1.0f; + invalidate(); + requestLayout(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + int childCount = getChildCount(); + if (childCount > 0) { + View child = getChildAt(0); + measureChild(child, widthMeasureSpec, heightMeasureSpec); + int childW = (int) (child.getMeasuredWidth() * scaleFactor); + int childH = (int) (child.getMeasuredHeight() * scaleFactor); + setMeasuredDimension( + MeasureSpec.getSize(widthMeasureSpec), + MeasureSpec.getSize(heightMeasureSpec) + ); + } + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + boolean handled = scaleGestureDetector.onTouchEvent(event); + if (!scaleGestureDetector.isInProgress()) { + handled = gestureDetector.onTouchEvent(event) || handled; + } + return handled || super.onTouchEvent(event); + } +} \ No newline at end of file diff --git a/gallery/src/main/res/drawable/ic_done.xml b/gallery/src/main/res/drawable/ic_done.xml new file mode 100644 index 0000000..a7672f7 --- /dev/null +++ b/gallery/src/main/res/drawable/ic_done.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/gallery/src/main/res/drawable/ic_zoom_in.xml b/gallery/src/main/res/drawable/ic_zoom_in.xml new file mode 100644 index 0000000..45a16f6 --- /dev/null +++ b/gallery/src/main/res/drawable/ic_zoom_in.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/gallery/src/main/res/drawable/ic_zoom_out.xml b/gallery/src/main/res/drawable/ic_zoom_out.xml new file mode 100644 index 0000000..6fdbb4a --- /dev/null +++ b/gallery/src/main/res/drawable/ic_zoom_out.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/gallery/src/main/res/layout/activity_crop.xml b/gallery/src/main/res/layout/activity_crop.xml index e90e7df..f7456e8 100644 --- a/gallery/src/main/res/layout/activity_crop.xml +++ b/gallery/src/main/res/layout/activity_crop.xml @@ -34,23 +34,54 @@ android:layout_height="0dp" android:layout_weight="1"/> - + + + + - + android:layout_marginTop="56dp" + android:layout_marginBottom="56dp" + android:fillViewport="true"> + + + + + + + + \ No newline at end of file