From f01729f8f8d1f3aa70be529b376d9fd7ef6779f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=B0=94=E4=BF=8A?= Date: Sun, 1 Feb 2026 20:11:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=AC=94=E8=AE=B0?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD=E6=94=AF=E6=8C=81=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E3=80=81=E5=9B=BE=E7=89=87=E5=92=8CPDF=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增菜单项和按钮用于触发导出功能 - 实现单条笔记导出(文本、图片、PDF)及批量导出 - 添加 FileProvider 配置以支持文件分享 - 新增 PdfExportHelper 和 ImageExportHelper 工具类 - 修改 BackupUtils 支持单条笔记和批量导出 --- .../app/src/main/AndroidManifest.xml | 11 + .../java/net/micode/notes/MainActivity.java | 20 +- .../micode/notes/data/NotesRepository.java | 34 +++ .../net/micode/notes/tool/BackupUtils.java | 165 ++++++++++++++- .../micode/notes/tool/ImageExportHelper.java | 117 ++++++++++ .../micode/notes/tool/PdfExportHelper.java | 98 +++++++++ .../net/micode/notes/ui/NoteEditActivity.java | 154 ++++++++++++++ .../micode/notes/ui/NotesListActivity.java | 199 +++++++++++++++++- .../notes/viewmodel/NotesListViewModel.java | 20 ++ .../app/src/main/res/layout/activity_home.xml | 1 + .../app/src/main/res/menu/note_edit.xml | 4 + .../app/src/main/res/xml/file_paths.xml | 5 + 12 files changed, 815 insertions(+), 13 deletions(-) create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/tool/ImageExportHelper.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/tool/PdfExportHelper.java create mode 100644 src/Notesmaster/app/src/main/res/xml/file_paths.xml diff --git a/src/Notesmaster/app/src/main/AndroidManifest.xml b/src/Notesmaster/app/src/main/AndroidManifest.xml index d858d81..f20cdc7 100644 --- a/src/Notesmaster/app/src/main/AndroidManifest.xml +++ b/src/Notesmaster/app/src/main/AndroidManifest.xml @@ -62,6 +62,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Notesmaster" + android:requestLegacyExternalStorage="true" tools:targetApi="31"> @@ -155,6 +156,16 @@ android:authorities="micode_notes" android:multiprocess="true" /> + + + + callback) { + executor.execute(() -> { + try { + NoteInfo noteInfo = getFolderInfo(noteId); + callback.onSuccess(noteInfo); + } catch (Exception e) { + callback.onError(e); + } + }); + } + /** * 查询单个文件夹信息 * @@ -1542,6 +1559,23 @@ public class NotesRepository { }); } + /** + * 获取笔记完整内容(异步版本) + * + * @param noteId 笔记ID + * @param callback 回调接口 + */ + public void getNoteContent(long noteId, Callback callback) { + executor.execute(() -> { + try { + String content = getNoteContent(noteId); + callback.onSuccess(content); + } catch (Exception e) { + callback.onError(e); + } + }); + } + private String getNoteContent(long noteId) { String content = ""; Cursor cursor = contentResolver.query( diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/tool/BackupUtils.java b/src/Notesmaster/app/src/main/java/net/micode/notes/tool/BackupUtils.java index 10c3994..cf84b56 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/tool/BackupUtils.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/tool/BackupUtils.java @@ -116,6 +116,27 @@ public class BackupUtils { return mTextExport.exportToText(); } + /** + * 导出单条笔记为文本文件 + * + * @param noteId 笔记 ID + * @param title 笔记标题(用于文件名) + * @return 导出状态码 + */ + public int exportNoteToText(String noteId, String title) { + return mTextExport.exportNoteToText(noteId, title); + } + + /** + * 批量导出指定笔记为文本文件 + * + * @param noteIds 笔记 ID 列表 + * @return 导出状态码 + */ + public int exportNotesToText(java.util.List noteIds) { + return mTextExport.exportNotesToText(noteIds); + } + /** * 获取导出的文本文件名 * @@ -310,6 +331,72 @@ public class BackupUtils { } } + /** + * 导出单条笔记为文本文件 + * + * @param noteId 笔记 ID + * @param title 笔记标题 + * @return 导出状态码 + */ + public int exportNoteToText(String noteId, String title) { + if (!externalStorageAvailable()) { + Log.d(TAG, "Media was not mounted"); + return STATE_SD_CARD_UNMOUONTED; + } + + PrintStream ps = getExportNotePrintStream(title); + if (ps == null) { + Log.e(TAG, "get print stream error"); + return STATE_SYSTEM_ERROR; + } + + exportNoteToText(noteId, ps); + ps.close(); + + return STATE_SUCCESS; + } + + /** + * 批量导出指定笔记为文本文件 + * + * @param noteIds 笔记 ID 列表 + * @return 导出状态码 + */ + public int exportNotesToText(java.util.List noteIds) { + if (!externalStorageAvailable()) { + Log.d(TAG, "Media was not mounted"); + return STATE_SD_CARD_UNMOUONTED; + } + + PrintStream ps = getExportToTextPrintStream(); + if (ps == null) { + Log.e(TAG, "get print stream error"); + return STATE_SYSTEM_ERROR; + } + + for (Long noteId : noteIds) { + Cursor noteCursor = mContext.getContentResolver().query( + net.micode.notes.data.Notes.CONTENT_NOTE_URI, + NOTE_PROJECTION, + net.micode.notes.data.Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(noteId)}, + null); + + if (noteCursor != null) { + if (noteCursor.moveToFirst()) { + ps.println(String.format(getFormat(FORMAT_NOTE_DATE), DateFormat.format( + mContext.getString(R.string.format_datetime_mdhm), + noteCursor.getLong(NOTE_COLUMN_MODIFIED_DATE)))); + exportNoteToText(String.valueOf(noteId), ps); + } + noteCursor.close(); + } + } + ps.close(); + + return STATE_SUCCESS; + } + /** * 导出笔记数据为文本文件 *

@@ -383,6 +470,31 @@ public class BackupUtils { return STATE_SUCCESS; } + /** + * 获取导出单条笔记文本文件的输出流 + * + * @param title 笔记标题 + * @return PrintStream 对象,如果创建失败则返回 null + */ + private PrintStream getExportNotePrintStream(String title) { + File file = generateFileWithTitle(mContext, R.string.file_path, title); + if (file == null) { + Log.e(TAG, "create file to exported failed"); + return null; + } + mFileName = file.getName(); + mFileDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getAbsolutePath(); + PrintStream ps = null; + try { + FileOutputStream fos = new FileOutputStream(file); + ps = new PrintStream(fos); + } catch (FileNotFoundException e) { + e.printStackTrace(); + return null; + } + return ps; + } + /** * 获取导出文本文件的输出流 *

@@ -399,7 +511,7 @@ public class BackupUtils { return null; } mFileName = file.getName(); - mFileDirectory = mContext.getString(R.string.file_path); + mFileDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getAbsolutePath(); PrintStream ps = null; try { FileOutputStream fos = new FileOutputStream(file); @@ -416,31 +528,62 @@ public class BackupUtils { } /** - * 在 SD 卡上生成导出文本文件 + * 在公用下载目录上生成指定标题的文本文件 + * + * @param context 应用上下文 + * @param filePathResId 文件路径资源 ID (不再使用,改为公用下载目录) + * @param title 笔记标题 + * @return 生成的文件对象,如果创建失败则返回 null + */ + private static File generateFileWithTitle(Context context, int filePathResId, String title) { + // 清理文件名中的非法字符 + String fileName = title.replaceAll("[\\\\/:*?\"<>|]", "_"); + if (TextUtils.isEmpty(fileName)) { + fileName = "untitled"; + } + fileName = fileName + "_" + System.currentTimeMillis() + ".txt"; + + File filedir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + File file = new File(filedir, fileName); + + try { + if (!filedir.exists()) { + filedir.mkdirs(); + } + if (!file.exists()) { + file.createNewFile(); + } + return file; + } catch (SecurityException | IOException e) { + e.printStackTrace(); + } + + return null; + } + + /** + * 在公用下载目录上生成导出文本文件 *

* 在指定的路径下创建导出文件,如果目录不存在则创建目录。 *

* * @param context 应用上下文 - * @param filePathResId 文件路径资源 ID + * @param filePathResId 文件路径资源 ID (不再使用,改为公用下载目录) * @param fileNameFormatResId 文件名格式资源 ID * @return 生成的文件对象,如果创建失败则返回 null */ private static File generateFileMountedOnSDcard(Context context, int filePathResId, int fileNameFormatResId) { - StringBuilder sb = new StringBuilder(); - sb.append(Environment.getExternalStorageDirectory()); - sb.append(context.getString(filePathResId)); - File filedir = new File(sb.toString()); - sb.append(context.getString( + File filedir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + String fileName = context.getString( fileNameFormatResId, DateFormat.format(context.getString(R.string.format_date_ymd), - System.currentTimeMillis()))); - File file = new File(sb.toString()); + System.currentTimeMillis())); + File file = new File(filedir, fileName); try { if (!filedir.exists()) { // 创建目录 - filedir.mkdir(); + filedir.mkdirs(); } if (!file.exists()) { // 创建文件 diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/tool/ImageExportHelper.java b/src/Notesmaster/app/src/main/java/net/micode/notes/tool/ImageExportHelper.java new file mode 100644 index 0000000..be043df --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/tool/ImageExportHelper.java @@ -0,0 +1,117 @@ +package net.micode.notes.tool; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.net.Uri; +import android.view.View; +import android.widget.ScrollView; + +import androidx.core.widget.NestedScrollView; +import androidx.core.content.FileProvider; + +import android.os.Environment; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; + +/** + * 图片导出工具类 + *

+ * 提供将 View 转换为图片并分享的功能。 + *

+ */ +public class ImageExportHelper { + private static final String TAG = "ImageExportHelper"; + + /** + * 将 View 转换为 Bitmap + * + * @param view 要转换的 View + * @return 转换后的 Bitmap + */ + public static Bitmap viewToBitmap(View view) { + if (view instanceof ScrollView || view instanceof NestedScrollView) { + int height = 0; + if (view instanceof ScrollView) { + ScrollView scrollView = (ScrollView) view; + for (int i = 0; i < scrollView.getChildCount(); i++) { + height += scrollView.getChildAt(i).getHeight(); + } + } else { + NestedScrollView nestedScrollView = (NestedScrollView) view; + for (int i = 0; i < nestedScrollView.getChildCount(); i++) { + height += nestedScrollView.getChildAt(i).getHeight(); + } + } + Bitmap bitmap = Bitmap.createBitmap(view.getWidth(), height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + canvas.drawColor(Color.WHITE); + view.draw(canvas); + return bitmap; + } else { + Bitmap bitmap = Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + canvas.drawColor(Color.WHITE); + view.draw(canvas); + return bitmap; + } + } + + /** + * 保存 Bitmap 到公用下载目录 + * + * @param context 应用上下文 + * @param bitmap 要保存的 Bitmap + * @param title 笔记标题 + * @return 生成的文件 Uri,如果保存失败则返回 null + */ + public static Uri saveBitmapToExternal(Context context, Bitmap bitmap, String title) { + String fileName = title.replaceAll("[\\\\/:*?\"<>|]", "_"); + if (fileName.isEmpty()) { + fileName = "untitled"; + } + fileName += "_" + System.currentTimeMillis() + ".png"; + + File filedir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + File file = new File(filedir, fileName); + + try { + if (!filedir.exists()) { + filedir.mkdirs(); + } + FileOutputStream stream = new FileOutputStream(file); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream); + stream.close(); + return FileProvider.getUriForFile(context, context.getPackageName() + ".fileprovider", file); + } catch (IOException e) { + e.printStackTrace(); + } + return null; + } + + /** + * 保存 Bitmap 到缓存目录并返回 Uri + * + * @param context 应用上下文 + * @param bitmap 要保存的 Bitmap + * @return 文件的 Uri,如果保存失败则返回 null + */ + public static Uri saveBitmapToCache(Context context, Bitmap bitmap) { + File cachePath = new File(context.getCacheDir(), "images"); + cachePath.mkdirs(); + try { + File file = new File(cachePath, "note_export_" + System.currentTimeMillis() + ".png"); + FileOutputStream stream = new FileOutputStream(file); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream); + stream.close(); + return FileProvider.getUriForFile(context, context.getPackageName() + ".fileprovider", file); + } catch (IOException e) { + e.printStackTrace(); + } + return null; + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/tool/PdfExportHelper.java b/src/Notesmaster/app/src/main/java/net/micode/notes/tool/PdfExportHelper.java new file mode 100644 index 0000000..d27806c --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/tool/PdfExportHelper.java @@ -0,0 +1,98 @@ +package net.micode.notes.tool; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.pdf.PdfDocument; +import android.os.Environment; +import android.text.Layout; +import android.text.StaticLayout; +import android.text.TextPaint; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; + +/** + * PDF 导出工具类 + *

+ * 使用 Android PdfDocument API 将笔记导出为 PDF 文件。 + *

+ */ +public class PdfExportHelper { + private static final String TAG = "PdfExportHelper"; + + /** + * 将笔记导出为 PDF + * + * @param context 应用上下文 + * @param title 笔记标题 + * @param content 笔记内容 + * @return 生成的 PDF 文件,如果失败则返回 null + */ + public static File exportToPdf(Context context, String title, String content) { + PdfDocument document = new PdfDocument(); + // A4 纸张大小 (595 x 842 points) + PdfDocument.PageInfo pageInfo = new PdfDocument.PageInfo.Builder(595, 842, 1).create(); + PdfDocument.Page page = document.startPage(pageInfo); + + Canvas canvas = page.getCanvas(); + TextPaint titlePaint = new TextPaint(); + titlePaint.setColor(Color.BLACK); + titlePaint.setTextSize(24); + titlePaint.setFakeBoldText(true); + + TextPaint contentPaint = new TextPaint(); + contentPaint.setColor(Color.BLACK); + contentPaint.setTextSize(14); + + int x = 50; + int y = 50; + + // 绘制标题 + canvas.drawText(title, x, y, titlePaint); + y += 40; + + // 绘制正文(自动换行) + StaticLayout staticLayout; + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + staticLayout = StaticLayout.Builder.obtain(content, 0, content.length(), contentPaint, pageInfo.getPageWidth() - 100) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setLineSpacing(0.0f, 1.2f) + .setIncludePad(false) + .build(); + } else { + staticLayout = new StaticLayout(content, contentPaint, pageInfo.getPageWidth() - 100, + Layout.Alignment.ALIGN_NORMAL, 1.2f, 0.0f, false); + } + + canvas.save(); + canvas.translate(x, y); + staticLayout.draw(canvas); + canvas.restore(); + + document.finishPage(page); + + // 生成文件名 + String fileName = title.replaceAll("[\\\\/:*?\"<>|]", "_"); + if (fileName.isEmpty()) { + fileName = "untitled"; + } + fileName += "_" + System.currentTimeMillis() + ".pdf"; + + File file = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), fileName); + + try { + FileOutputStream fos = new FileOutputStream(file); + document.writeTo(fos); + fos.close(); + } catch (IOException e) { + e.printStackTrace(); + file = null; + } finally { + document.close(); + } + + return file; + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java index 8cc84ae..4ef73ba 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java @@ -53,6 +53,8 @@ import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; +import android.graphics.Bitmap; +import android.net.Uri; import net.micode.notes.R; import net.micode.notes.data.Notes; @@ -61,7 +63,10 @@ import net.micode.notes.model.NoteCommand; import net.micode.notes.model.UndoRedoManager; import net.micode.notes.model.WorkingNote; import net.micode.notes.model.WorkingNote.NoteSettingChangedListener; +import net.micode.notes.tool.BackupUtils; import net.micode.notes.tool.DataUtils; +import net.micode.notes.tool.ImageExportHelper; +import net.micode.notes.tool.PdfExportHelper; import net.micode.notes.tool.ResourceParser; import net.micode.notes.tool.ResourceParser.TextAppearanceResources; import net.micode.notes.ui.DateTimePickerDialog.OnDateTimeSetListener; @@ -88,6 +93,12 @@ import net.micode.notes.tool.SmartParser; import net.micode.notes.data.FontManager; +import android.widget.RadioButton; +import android.widget.RadioGroup; + +import java.io.File; +import java.util.ArrayList; + public class NoteEditActivity extends BaseActivity implements OnClickListener, NoteSettingChangedListener, OnTextViewChangeListener { /** @@ -1035,6 +1046,9 @@ public class NoteEditActivity extends BaseActivity implements OnClickListener, getWorkingText(); sendTo(this, mWorkingNote.getContent()); break; + case R.id.menu_export: + showExportDialog(); + break; case R.id.menu_send_to_desktop: sendToDesktop(); break; @@ -1082,6 +1096,135 @@ public class NoteEditActivity extends BaseActivity implements OnClickListener, context.startActivity(intent); } + /** + * 显示导出选项对话框 + */ + private void showExportDialog() { + if (mWorkingNote.getNoteId() == 0) { + saveNote(); + } + getWorkingText(); + String content = mWorkingNote.getContent(); + if (TextUtils.isEmpty(content)) { + showToast(R.string.error_note_empty_for_send_to_desktop); + return; + } + + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("导出便签"); + + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(LinearLayout.VERTICAL); + layout.setPadding(50, 20, 50, 20); + + final RadioGroup group = new RadioGroup(this); + RadioButton rbText = new RadioButton(this); + rbText.setText("导出为文本 (.txt)"); + rbText.setId(View.generateViewId()); + group.addView(rbText); + + RadioButton rbImage = new RadioButton(this); + rbImage.setText("导出为图片 (.png)"); + rbImage.setId(View.generateViewId()); + group.addView(rbImage); + + RadioButton rbPdf = new RadioButton(this); + rbPdf.setText("导出为 PDF (.pdf)"); + rbPdf.setId(View.generateViewId()); + group.addView(rbPdf); + + group.check(rbText.getId()); + layout.addView(group); + + final CheckBox cbShare = new CheckBox(this); + cbShare.setText("导出后立即分享"); + layout.addView(cbShare); + + builder.setView(layout); + + builder.setPositiveButton("开始导出", (dialog, which) -> { + int checkedId = group.getCheckedRadioButtonId(); + boolean share = cbShare.isChecked(); + + if (checkedId == rbText.getId()) { + performExport(0, share); + } else if (checkedId == rbImage.getId()) { + performExport(1, share); + } else if (checkedId == rbPdf.getId()) { + performExport(2, share); + } + }); + + builder.setNegativeButton("取消", null); + builder.show(); + } + + private void performExport(int format, boolean share) { + String content = mWorkingNote.getContent(); + String title = getTitleFromContent(content); + String noteId = String.valueOf(mWorkingNote.getNoteId()); + + switch (format) { + case 0: // Text + BackupUtils backupUtils = BackupUtils.getInstance(this); + int state = backupUtils.exportNoteToText(noteId, title); + if (state == BackupUtils.STATE_SUCCESS) { + File file = new File(backupUtils.getExportedTextFileDir(), backupUtils.getExportedTextFileName()); + showToast("已导出至下载目录: " + file.getName()); + if (share) shareFile(file, "text/plain"); + } else { + showToast("导出文本失败"); + } + break; + case 1: // Image + binding.svNoteEditScroll.post(() -> { + Bitmap bitmap = ImageExportHelper.viewToBitmap(binding.cvEditorSurface); + Uri uri = ImageExportHelper.saveBitmapToExternal(NoteEditActivity.this, bitmap, title); + if (uri != null) { + showToast("已导出图片至下载目录"); + if (share) shareUri(uri, "image/png"); + } else { + showToast("生成图片失败"); + } + }); + break; + case 2: // PDF + File pdfFile = PdfExportHelper.exportToPdf(this, title, content); + if (pdfFile != null) { + showToast("已导出 PDF 至下载目录: " + pdfFile.getName()); + if (share) shareFile(pdfFile, "application/pdf"); + } else { + showToast("导出 PDF 失败"); + } + break; + } + } + + private String getTitleFromContent(String content) { + String title = content.trim(); + int firstNewLine = title.indexOf('\n'); + if (firstNewLine > 0) { + title = title.substring(0, firstNewLine); + } + if (title.length() > 30) { + title = title.substring(0, 30); + } + return title; + } + + private void shareFile(File file, String mimeType) { + Uri uri = androidx.core.content.FileProvider.getUriForFile(this, getPackageName() + ".fileprovider", file); + shareUri(uri, mimeType); + } + + private void shareUri(Uri uri, String mimeType) { + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType(mimeType); + intent.putExtra(Intent.EXTRA_STREAM, uri); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity(Intent.createChooser(intent, "分享便签")); + } + /** * 创建新笔记 *

@@ -1747,6 +1890,17 @@ public class NoteEditActivity extends BaseActivity implements OnClickListener, } } + /** + * 显示Toast提示 + *

+ * 显示指定文本的Toast提示消息。 + *

+ * @param text 提示文本 + */ + private void showToast(String text) { + Toast.makeText(this, text, Toast.LENGTH_SHORT).show(); + } + /** * 显示Toast提示 *

diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NotesListActivity.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NotesListActivity.java index 2be0b80..863997c 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NotesListActivity.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NotesListActivity.java @@ -3,14 +3,31 @@ package net.micode.notes.ui; import android.app.AlertDialog; import android.content.BroadcastReceiver; import android.content.Context; +import android.content.DialogInterface; import android.content.Intent; import android.content.IntentFilter; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.net.Uri; import android.os.Bundle; +import android.text.TextUtils; import android.util.Log; import android.view.View; +import android.widget.CheckBox; import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.RadioButton; +import android.widget.RadioGroup; import android.widget.Toast; +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import net.micode.notes.tool.ImageExportHelper; +import net.micode.notes.tool.PdfExportHelper; + import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.core.view.GravityCompat; @@ -24,6 +41,7 @@ import net.micode.notes.data.Notes; import net.micode.notes.data.NotesRepository; import net.micode.notes.databinding.ActivityHomeBinding; import net.micode.notes.sync.SyncManager; +import net.micode.notes.tool.BackupUtils; import net.micode.notes.tool.SecurityManager; import net.micode.notes.viewmodel.NotesListViewModel; @@ -138,6 +156,11 @@ public class NotesListActivity extends BaseActivity implements SidebarFragment.O viewModel.setIsSelectionMode(false); }); + binding.btnActionExport.setOnClickListener(v -> { + exportSelectedNotes(); + viewModel.setIsSelectionMode(false); + }); + binding.btnActionLock.setOnClickListener(v -> { SecurityManager securityManager = SecurityManager.getInstance(this); if (!securityManager.isPasswordSet()) { @@ -375,7 +398,11 @@ public class NotesListActivity extends BaseActivity implements SidebarFragment.O @Override public void onSyncSelected() { binding.drawerLayout.closeDrawer(GravityCompat.START); SyncManager.getInstance().syncNotes(null); } @Override public void onLoginSelected() { binding.drawerLayout.closeDrawer(GravityCompat.START); startActivity(new Intent(this, LoginActivity.class)); } @Override public void onLogoutSelected() { binding.drawerLayout.closeDrawer(GravityCompat.START); viewModel.refreshNotes(); } - @Override public void onExportSelected() { binding.drawerLayout.closeDrawer(GravityCompat.START); Toast.makeText(this, "导出功能待实现", Toast.LENGTH_SHORT).show(); } + @Override public void onExportSelected() { + binding.drawerLayout.closeDrawer(GravityCompat.START); + viewModel.setIsSelectionMode(true); + Toast.makeText(this, "请选择要导出的便签", Toast.LENGTH_SHORT).show(); + } @Override public void onTemplateSelected() { binding.drawerLayout.closeDrawer(GravityCompat.START); viewModel.enterFolder(Notes.ID_TEMPLATE_FOLDER); } @Override public void onSettingsSelected() { binding.drawerLayout.closeDrawer(GravityCompat.START); startActivity(new Intent(this, SettingsActivity.class)); } @Override public void onCapsuleSelected() { @@ -410,6 +437,176 @@ public class NotesListActivity extends BaseActivity implements SidebarFragment.O }); } + private void exportSelectedNotes() { + java.util.List selectedIds = viewModel.getSelectedNoteIds(); + if (selectedIds.isEmpty()) { + Toast.makeText(this, "请先选择要导出的便签", Toast.LENGTH_SHORT).show(); + return; + } + + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("批量导出便签"); + + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(LinearLayout.VERTICAL); + layout.setPadding(50, 20, 50, 20); + + final RadioGroup group = new RadioGroup(this); + RadioButton rbText = new RadioButton(this); + rbText.setText("导出为文本 (.txt)"); + rbText.setId(View.generateViewId()); + group.addView(rbText); + + RadioButton rbImage = new RadioButton(this); + rbImage.setText("导出为图片 (.png)"); + rbImage.setId(View.generateViewId()); + group.addView(rbImage); + + RadioButton rbPdf = new RadioButton(this); + rbPdf.setText("导出为 PDF (.pdf)"); + rbPdf.setId(View.generateViewId()); + group.addView(rbPdf); + + group.check(rbText.getId()); + layout.addView(group); + + final CheckBox cbShare = new CheckBox(this); + cbShare.setText("导出后立即分享"); + layout.addView(cbShare); + + builder.setView(layout); + + builder.setPositiveButton("开始导出", (dialog, which) -> { + int checkedId = group.getCheckedRadioButtonId(); + boolean share = cbShare.isChecked(); + + if (checkedId == rbText.getId()) { + performBatchExport(0, selectedIds, share); + } else if (checkedId == rbImage.getId()) { + performBatchExport(1, selectedIds, share); + } else if (checkedId == rbPdf.getId()) { + performBatchExport(2, selectedIds, share); + } + }); + + builder.setNegativeButton("取消", null); + builder.show(); + } + + private void performBatchExport(int format, java.util.List selectedIds, boolean share) { + if (format == 0) { // Text (Combined) + BackupUtils backupUtils = BackupUtils.getInstance(this); + int state = backupUtils.exportNotesToText(selectedIds); + if (state == BackupUtils.STATE_SUCCESS) { + File file = new File(backupUtils.getExportedTextFileDir(), backupUtils.getExportedTextFileName()); + Toast.makeText(this, "已导出至下载目录: " + file.getName(), Toast.LENGTH_SHORT).show(); + if (share) shareFile(file, "text/plain"); + } else { + Toast.makeText(this, "导出失败", Toast.LENGTH_SHORT).show(); + } + } else { + final ArrayList uris = new ArrayList<>(); + final int total = selectedIds.size(); + final int[] successCount = {0}; + final String mimeType = (format == 1) ? "image/png" : "application/pdf"; + + for (Long id : selectedIds) { + viewModel.getNoteContent(id, new NotesRepository.Callback() { + @Override + public void onSuccess(String content) { + if (!TextUtils.isEmpty(content)) { + String title = getTitleFromContent(content); + File exportedFile = null; + if (format == 1) { // Image + Bitmap bitmap = renderTextToBitmap(content); + Uri uri = ImageExportHelper.saveBitmapToExternal(NotesListActivity.this, bitmap, title); + if (uri != null) { + successCount[0]++; + uris.add(uri); + } + } else { // PDF + exportedFile = PdfExportHelper.exportToPdf(NotesListActivity.this, title, content); + if (exportedFile != null) { + successCount[0]++; + uris.add(androidx.core.content.FileProvider.getUriForFile(NotesListActivity.this, getPackageName() + ".fileprovider", exportedFile)); + } + } + } + checkBatchFinished(successCount[0], total, uris, mimeType, share); + } + + @Override + public void onError(Exception error) { + checkBatchFinished(successCount[0], total, uris, mimeType, share); + } + }); + } + } + } + + private Bitmap renderTextToBitmap(String text) { + android.widget.TextView textView = new android.widget.TextView(this); + textView.setText(text); + textView.setTextColor(Color.BLACK); + textView.setBackgroundColor(Color.WHITE); + textView.setTextSize(16); + textView.setPadding(40, 40, 40, 40); + textView.setWidth(800); + + int widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(800, View.MeasureSpec.EXACTLY); + int heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); + textView.measure(widthMeasureSpec, heightMeasureSpec); + textView.layout(0, 0, textView.getMeasuredWidth(), textView.getMeasuredHeight()); + + Bitmap bitmap = Bitmap.createBitmap(textView.getMeasuredWidth(), textView.getMeasuredHeight(), Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + textView.draw(canvas); + return bitmap; + } + + private synchronized void checkBatchFinished(int success, int total, ArrayList uris, String mimeType, boolean share) { + // Since we are doing this sequentially or with callbacks, we need to track progress + // For simplicity in this implementation, I'll just check if we have reached total count + if (uris.size() + (total - success) >= total) { + if (success > 0) { + Toast.makeText(this, "成功导出 " + success + " 个文件至下载目录", Toast.LENGTH_SHORT).show(); + if (share) shareUris(uris, mimeType); + } else { + Toast.makeText(this, "导出失败", Toast.LENGTH_SHORT).show(); + } + } + } + + private String getTitleFromContent(String content) { + if (TextUtils.isEmpty(content)) return "untitled"; + String title = content.trim(); + int firstNewLine = title.indexOf('\n'); + if (firstNewLine > 0) { + title = title.substring(0, firstNewLine); + } + if (title.length() > 30) { + title = title.substring(0, 30); + } + return title; + } + + private void shareFile(File file, String mimeType) { + Uri uri = androidx.core.content.FileProvider.getUriForFile(this, getPackageName() + ".fileprovider", file); + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType(mimeType); + intent.putExtra(Intent.EXTRA_STREAM, uri); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity(Intent.createChooser(intent, "分享便签")); + } + + private void shareUris(ArrayList uris, String mimeType) { + Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE); + intent.setType(mimeType); + intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity(Intent.createChooser(intent, "分享便签")); + } + private void showRenameFolderDialog(long folderId, String currentName) { final EditText input = new EditText(this); input.setText(currentName); diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/NotesListViewModel.java b/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/NotesListViewModel.java index 6265f9b..0307c24 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/NotesListViewModel.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/NotesListViewModel.java @@ -942,6 +942,26 @@ public class NotesListViewModel extends AndroidViewModel { }); } + /** + * 获取单个笔记的详细信息 + * + * @param noteId 笔记ID + * @param callback 回调接口 + */ + public void getNoteInfo(long noteId, NotesRepository.Callback callback) { + repository.getNoteInfo(noteId, callback); + } + + /** + * 获取单个笔记的完整内容 + * + * @param noteId 笔记ID + * @param callback 回调接口 + */ + public void getNoteContent(long noteId, NotesRepository.Callback callback) { + repository.getNoteContent(noteId, callback); + } + /** * 获取文件夹信息 *

diff --git a/src/Notesmaster/app/src/main/res/layout/activity_home.xml b/src/Notesmaster/app/src/main/res/layout/activity_home.xml index 3814b7e..675d757 100644 --- a/src/Notesmaster/app/src/main/res/layout/activity_home.xml +++ b/src/Notesmaster/app/src/main/res/layout/activity_home.xml @@ -196,6 +196,7 @@ + diff --git a/src/Notesmaster/app/src/main/res/menu/note_edit.xml b/src/Notesmaster/app/src/main/res/menu/note_edit.xml index 64e0fcc..ea281a5 100644 --- a/src/Notesmaster/app/src/main/res/menu/note_edit.xml +++ b/src/Notesmaster/app/src/main/res/menu/note_edit.xml @@ -71,6 +71,10 @@ android:id="@+id/menu_share" android:title="@string/menu_share"/> + + diff --git a/src/Notesmaster/app/src/main/res/xml/file_paths.xml b/src/Notesmaster/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..d68a56c --- /dev/null +++ b/src/Notesmaster/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + +