diff --git a/gallery/src/main/java/cc/winboll/studio/gallery/CropActivity.java b/gallery/src/main/java/cc/winboll/studio/gallery/CropActivity.java
new file mode 100644
index 0000000..0027525
--- /dev/null
+++ b/gallery/src/main/java/cc/winboll/studio/gallery/CropActivity.java
@@ -0,0 +1,172 @@
+package cc.winboll.studio.gallery;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.RectF;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.MediaStore;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.Toast;
+import androidx.appcompat.app.AppCompatActivity;
+import cc.winboll.studio.libappbase.LogUtils;
+import java.io.File;
+import java.io.FileOutputStream;
+
+public class CropActivity extends AppCompatActivity {
+ public static final String TAG = "CropActivity";
+ public static final String EXTRA_IMAGE_URI = "image_uri";
+ public static final String EXTRA_IMAGE_PATH = "image_path";
+ public static final String EXTRA_ALBUM_PATH = "album_path";
+ public static final String EXTRA_CROP_WIDTH = "crop_width";
+ public static final String EXTRA_CROP_HEIGHT = "crop_height";
+
+ private CropCanvasView cropCanvasView;
+ private CropOverlayView cropOverlay;
+ private Bitmap originalBitmap;
+ private String imagePath;
+ private String albumPath;
+ private int cropWidth = 240;
+ private int cropHeight = 120;
+ private float cropRatio = 2.0f;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_crop);
+
+ imagePath = getIntent().getStringExtra(EXTRA_IMAGE_PATH);
+ albumPath = getIntent().getStringExtra(EXTRA_ALBUM_PATH);
+ cropWidth = getIntent().getIntExtra(EXTRA_CROP_WIDTH, 240);
+ cropHeight = getIntent().getIntExtra(EXTRA_CROP_HEIGHT, 120);
+
+ Preferences prefs = new Preferences(this);
+ if (cropWidth > 0 && cropHeight > 0) {
+ cropRatio = (float) cropWidth / cropHeight;
+ } else {
+ cropRatio = prefs.getCoverRatio();
+ cropWidth = (int) (120 * cropRatio);
+ cropHeight = 120;
+ }
+
+ findViewById(R.id.btn_close).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ finish();
+ }
+ });
+
+ findViewById(R.id.btn_done).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ saveCroppedCover();
+ }
+ });
+
+ cropCanvasView = findViewById(R.id.crop_canvas_view);
+ cropOverlay = findViewById(R.id.crop_overlay);
+ loadImage();
+ }
+
+ private void loadImage() {
+ try {
+ if (imagePath != null && new File(imagePath).exists()) {
+ originalBitmap = BitmapFactory.decodeFile(imagePath);
+ } else {
+ Uri imageUri = getIntent().getParcelableExtra(EXTRA_IMAGE_URI);
+ if (imageUri != null) {
+ originalBitmap = MediaStore.Images.Media.getBitmap(getContentResolver(), imageUri);
+ }
+ }
+
+ if (originalBitmap != null) {
+ cropCanvasView.setImageBitmap(originalBitmap);
+ cropCanvasView.post(new Runnable() {
+ @Override
+ public void run() {
+ initCropOverlay();
+ }
+ });
+ } else {
+ Toast.makeText(this, "Failed to load image", Toast.LENGTH_SHORT).show();
+ finish();
+ }
+ } catch (Exception e) {
+ LogUtils.e(TAG, "loadImage error: " + e.getMessage());
+ Toast.makeText(this, "Failed to load image", Toast.LENGTH_SHORT).show();
+ finish();
+ }
+ }
+
+ private void initCropOverlay() {
+ cropCanvasView.initCanvas(originalBitmap.getWidth(), originalBitmap.getHeight(), cropRatio);
+
+ int canvasW = cropCanvasView.getCanvasWidth();
+ int canvasH = cropCanvasView.getCanvasHeight();
+
+ cropOverlay.setTargetRatio(cropRatio);
+ cropOverlay.initCanvas(canvasW, canvasH);
+ }
+
+ private void saveCroppedCover() {
+ if (originalBitmap == null) {
+ Toast.makeText(this, "Failed to get image", Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ try {
+ RectF cropRect = cropOverlay.getCropRect();
+ RectF canvasBounds = cropCanvasView.getCanvasBounds();
+ RectF imageBounds = cropCanvasView.getImageBounds();
+
+ int imgW = originalBitmap.getWidth();
+ int imgH = originalBitmap.getHeight();
+
+ float scaleX = (float) imgW / canvasBounds.width();
+ float scaleY = (float) imgH / canvasBounds.height();
+
+ float canvasLeft = cropRect.left - canvasBounds.left;
+ float canvasTop = cropRect.top - canvasBounds.top;
+
+ int cropX = (int) ((canvasLeft - imageBounds.left) * scaleX);
+ int cropY = (int) ((canvasTop - imageBounds.top) * scaleY);
+ int cropW = (int) (cropRect.width() * scaleX);
+ int cropH = (int) (cropRect.height() * scaleY);
+
+ cropX = Math.max(0, Math.min(cropX, imgW - 1));
+ cropY = Math.max(0, Math.min(cropY, imgH - 1));
+ cropW = Math.max(1, Math.min(cropW, imgW - cropX));
+ cropH = Math.max(1, Math.min(cropH, imgH - cropY));
+
+ Bitmap cropped = Bitmap.createBitmap(originalBitmap, cropX, cropY, cropW, cropH);
+
+ 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();
+
+ cropped.recycle();
+
+ AlbumCoverDbHelper coverDbHelper = AlbumCoverDbHelper.getInstance(this);
+ coverDbHelper.setCoverWithCrop(albumPath, imagePath, coverFile.getAbsolutePath());
+
+ Toast.makeText(this, "封面已保存", Toast.LENGTH_SHORT).show();
+ setResult(RESULT_OK);
+ finish();
+ } catch (Exception e) {
+ LogUtils.e(TAG, "saveCroppedCover error: " + e.getMessage());
+ Toast.makeText(this, "Failed to save cover", Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ if (originalBitmap != null) {
+ originalBitmap.recycle();
+ originalBitmap = null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/gallery/src/main/java/cc/winboll/studio/gallery/CropCanvasView.java b/gallery/src/main/java/cc/winboll/studio/gallery/CropCanvasView.java
new file mode 100644
index 0000000..7f44b65
--- /dev/null
+++ b/gallery/src/main/java/cc/winboll/studio/gallery/CropCanvasView.java
@@ -0,0 +1,140 @@
+package cc.winboll.studio.gallery;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.util.AttributeSet;
+import android.view.View;
+
+public class CropCanvasView extends View {
+ private Paint imagePaint;
+
+ private int imageWidth;
+ private int imageHeight;
+ private float coverRatio = 2.0f;
+
+ private int extendHeight;
+ private int extendWidth;
+ private int canvasWidth;
+ private int canvasHeight;
+
+ private RectF imageBounds = new RectF();
+ private RectF canvasBounds = new RectF();
+ private Bitmap originalBitmap;
+
+ public CropCanvasView(Context context) {
+ super(context);
+ init();
+ }
+
+ public CropCanvasView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public CropCanvasView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ private void init() {
+ imagePaint = new Paint();
+ imagePaint.setFilterBitmap(true);
+ }
+
+ public void setImageBitmap(Bitmap bitmap) {
+ this.originalBitmap = bitmap;
+ }
+
+ public void initCanvas(int imgWidth, int imgHeight, float ratio) {
+ this.imageWidth = imgWidth;
+ this.imageHeight = imgHeight;
+ this.coverRatio = ratio;
+
+ float imgRatio = (float) imgWidth / imgHeight;
+
+ if (coverRatio >= imgRatio) {
+ extendHeight = imgHeight;
+ extendWidth = (int) (imgHeight * coverRatio);
+ } else {
+ extendWidth = imgWidth;
+ extendHeight = (int) (imgWidth / coverRatio);
+ }
+
+ canvasHeight = Math.max(imageHeight, extendHeight);
+ canvasWidth = Math.max(imageWidth, extendWidth);
+
+ float left = (canvasWidth - imageWidth) / 2f;
+ float top = (canvasHeight - imageHeight) / 2f;
+ imageBounds.set(left, top, left + imageWidth, top + imageHeight);
+ canvasBounds.set(0, 0, canvasWidth, canvasHeight);
+
+ requestLayout();
+ invalidate();
+ }
+
+ 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);
+ 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);
+
+ requestLayout();
+ invalidate();
+ }
+ }
+
+ public int getCanvasWidth() {
+ return canvasWidth;
+ }
+
+ public int getCanvasHeight() {
+ return canvasHeight;
+ }
+
+ public int getExtendWidth() {
+ return extendWidth;
+ }
+
+ public int getExtendHeight() {
+ return extendHeight;
+ }
+
+ public RectF getCanvasBounds() {
+ return new RectF(canvasBounds);
+ }
+
+ public RectF getImageBounds() {
+ return new RectF(imageBounds);
+ }
+
+ public float getCoverRatio() {
+ return coverRatio;
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ int w = canvasWidth > 0 ? canvasWidth : 0;
+ int h = canvasHeight > 0 ? canvasHeight : 0;
+ setMeasuredDimension(w, h);
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ canvas.drawColor(Color.GREEN);
+
+ if (originalBitmap != null && !originalBitmap.isRecycled()) {
+ canvas.drawBitmap(originalBitmap, imageBounds.left, imageBounds.top, imagePaint);
+ }
+ }
+}
\ No newline at end of file
diff --git a/gallery/src/main/java/cc/winboll/studio/gallery/CropOverlayView.java b/gallery/src/main/java/cc/winboll/studio/gallery/CropOverlayView.java
new file mode 100644
index 0000000..9d01dc3
--- /dev/null
+++ b/gallery/src/main/java/cc/winboll/studio/gallery/CropOverlayView.java
@@ -0,0 +1,224 @@
+package cc.winboll.studio.gallery;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+
+public class CropOverlayView extends View {
+ public interface OnCanvasSizeChangedListener {
+ void onCanvasSizeChanged(RectF canvasBounds);
+ }
+
+ private OnCanvasSizeChangedListener canvasSizeChangedListener;
+
+ private Paint borderPaint;
+ private Paint cornerPaint;
+ private RectF cropRect;
+ private int touchArea = 50;
+
+ private float lastX, lastY;
+ private int activeCorner = -1;
+ private static final int CORNER_TOP_LEFT = 0;
+ private static final int CORNER_TOP_RIGHT = 1;
+ private static final int CORNER_BOTTOM_LEFT = 2;
+ private static final int CORNER_BOTTOM_RIGHT = 3;
+ private static final int CORNER_CENTER = 4;
+
+ private float targetRatio = 2.0f;
+ private RectF canvasBounds = new RectF();
+ private float minSize = 50;
+
+ public CropOverlayView(Context context) {
+ super(context);
+ initPaints();
+ }
+
+ public CropOverlayView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initPaints();
+ }
+
+ public CropOverlayView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ initPaints();
+ }
+
+ private void initPaints() {
+ borderPaint = new Paint();
+ borderPaint.setColor(Color.parseColor("#CCAA00"));
+ borderPaint.setStyle(Paint.Style.STROKE);
+ borderPaint.setStrokeWidth(3);
+
+ cornerPaint = new Paint();
+ cornerPaint.setColor(Color.WHITE);
+ cornerPaint.setStyle(Paint.Style.FILL);
+ }
+
+ public void setTargetRatio(float ratio) {
+ this.targetRatio = ratio;
+ }
+
+ public float getTargetRatio() {
+ return targetRatio;
+ }
+
+ public void setOnCanvasSizeChangedListener(OnCanvasSizeChangedListener listener) {
+ this.canvasSizeChangedListener = listener;
+ }
+
+ public void initCanvas(int canvasW, int canvasH) {
+ canvasBounds.set(0, 0, canvasW, canvasH);
+
+ cropRect = new RectF(0, 0, canvasW, canvasH);
+
+ if (canvasSizeChangedListener != null) {
+ canvasSizeChangedListener.onCanvasSizeChanged(new RectF(canvasBounds));
+ }
+ invalidate();
+ }
+
+ public RectF getCropRect() {
+ return new RectF(cropRect);
+ }
+
+ public RectF getCanvasBounds() {
+ return new RectF(canvasBounds);
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ canvas.drawColor(Color.TRANSPARENT);
+
+ if (cropRect != null) {
+ canvas.drawRect(cropRect, borderPaint);
+
+ canvas.drawCircle(cropRect.left, cropRect.top, 12, cornerPaint);
+ canvas.drawCircle(cropRect.right, cropRect.top, 12, cornerPaint);
+ canvas.drawCircle(cropRect.left, cropRect.bottom, 12, cornerPaint);
+ canvas.drawCircle(cropRect.right, cropRect.bottom, 12, cornerPaint);
+ }
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ if (cropRect == null || canvasBounds.isEmpty()) return false;
+
+ float x = event.getX();
+ float y = event.getY();
+
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ lastX = x;
+ lastY = y;
+ activeCorner = getActiveCorner(x, y);
+ return true;
+
+ case MotionEvent.ACTION_MOVE:
+ float dx = x - lastX;
+ float dy = y - lastY;
+
+ if (activeCorner == CORNER_CENTER) {
+ float newLeft = cropRect.left + dx;
+ float newTop = cropRect.top + dy;
+ float newRight = cropRect.right + dx;
+ float newBottom = cropRect.bottom + dy;
+
+ if (newLeft >= canvasBounds.left && newRight <= canvasBounds.right) {
+ cropRect.left = newLeft;
+ cropRect.right = newRight;
+ }
+ if (newTop >= canvasBounds.top && newBottom <= canvasBounds.bottom) {
+ cropRect.top = newTop;
+ cropRect.bottom = newBottom;
+ }
+ } else if (activeCorner == CORNER_TOP_LEFT) {
+ adjustCorner(cropRect.left + dx, cropRect.top + dy, true, true);
+ } else if (activeCorner == CORNER_TOP_RIGHT) {
+ adjustCorner(cropRect.right + dx, cropRect.top + dy, false, true);
+ } else if (activeCorner == CORNER_BOTTOM_LEFT) {
+ adjustCorner(cropRect.left + dx, cropRect.bottom + dy, true, false);
+ } else if (activeCorner == CORNER_BOTTOM_RIGHT) {
+ adjustCorner(cropRect.right + dx, cropRect.bottom + dy, false, false);
+ }
+
+ lastX = x;
+ lastY = y;
+ invalidate();
+ return true;
+
+ case MotionEvent.ACTION_UP:
+ activeCorner = -1;
+ return true;
+ }
+ return super.onTouchEvent(event);
+ }
+
+ private void adjustCorner(float nx, float ny, boolean left, boolean top) {
+ float newWidth;
+ float newLeft = cropRect.left;
+ float newTop = cropRect.top;
+ float newRight = cropRect.right;
+ float newBottom = cropRect.bottom;
+
+ if (left) {
+ newWidth = cropRect.width() - (nx - cropRect.left);
+ newLeft = Math.max(canvasBounds.left, Math.min(nx, cropRect.right - minSize));
+ } else {
+ newWidth = nx - cropRect.left;
+ newRight = Math.min(canvasBounds.right, Math.max(nx, cropRect.left + minSize));
+ newLeft = cropRect.left;
+ }
+
+ float newHeight = newWidth / targetRatio;
+
+ if (top) {
+ newTop = Math.max(canvasBounds.top, Math.min(cropRect.bottom - minSize, cropRect.bottom - newHeight));
+ newBottom = newTop + newHeight;
+ } else {
+ newBottom = Math.min(canvasBounds.bottom, Math.max(cropRect.top + minSize, cropRect.top + newHeight));
+ newTop = newBottom - newHeight;
+ }
+
+ if (left) {
+ cropRect.left = newLeft;
+ cropRect.right = newLeft + newWidth;
+ } else {
+ cropRect.right = newRight;
+ cropRect.left = newRight - newWidth;
+ }
+
+ if (top) {
+ cropRect.top = newTop;
+ cropRect.bottom = newTop + newHeight;
+ } else {
+ cropRect.bottom = newBottom;
+ cropRect.top = newBottom - newHeight;
+ }
+ }
+
+ private int getActiveCorner(float x, float y) {
+ if (Math.abs(x - cropRect.left) <= touchArea && Math.abs(y - cropRect.top) <= touchArea) {
+ return CORNER_TOP_LEFT;
+ }
+ if (Math.abs(x - cropRect.right) <= touchArea && Math.abs(y - cropRect.top) <= touchArea) {
+ return CORNER_TOP_RIGHT;
+ }
+ if (Math.abs(x - cropRect.left) <= touchArea && Math.abs(y - cropRect.bottom) <= touchArea) {
+ return CORNER_BOTTOM_LEFT;
+ }
+ if (Math.abs(x - cropRect.right) <= touchArea && Math.abs(y - cropRect.bottom) <= touchArea) {
+ return CORNER_BOTTOM_RIGHT;
+ }
+ if (cropRect.contains(x, y)) {
+ return CORNER_CENTER;
+ }
+ return -1;
+ }
+}
\ No newline at end of file
diff --git a/gallery/src/main/res/drawable/ic_close.xml b/gallery/src/main/res/drawable/ic_close.xml
new file mode 100644
index 0000000..2789d9c
--- /dev/null
+++ b/gallery/src/main/res/drawable/ic_close.xml
@@ -0,0 +1,10 @@
+
+