You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
xiaomi_notes_reading/src/notes/tool/BackupUtils.java

441 lines
20 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/*
* Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net)
*
* 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 net.micode.notes.tool;
import android.content.Context;
import android.database.Cursor;
import android.os.Environment;
import android.text.TextUtils;
import android.text.format.DateFormat;
import android.util.Log;
import net.micode.notes.R;
import net.micode.notes.data.Notes;
import net.micode.notes.data.Notes.DataColumns;
import net.micode.notes.data.Notes.DataConstants;
import net.micode.notes.data.Notes.NoteColumns;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.PrintStream;
/**
* 笔记备份工具类,采用单例模式设计
* 负责将笔记数据从数据库导出为可读的文本格式文件
*/
public class BackupUtils {
// 日志标签,用于调试时在 Logcat 中标识本类的日志输出
private static final String TAG = "BackupUtils";
// 单例模式:持有 BackupUtils 的唯一实例
private static BackupUtils sInstance;
/**
* 获取 BackupUtils 的全局唯一实例(线程安全)。
*
* <p>采用懒加载方式,首次调用时才创建实例。
* 注意:为避免内存泄漏,建议传入 Application Context
* 但即使传入 Activity Context内部也应转换为 ApplicationContext 使用(需在构造函数中处理)。
*
* @param context 上下文,用于初始化 BackupUtils 所需的系统服务或资源
* @return BackupUtils 的单例对象
*/
public static synchronized BackupUtils getInstance(Context context) {
if (sInstance == null) {
// 首次调用时创建实例
sInstance = new BackupUtils(context);
}
return sInstance;
}
/**
* Following states are signs to represents backup or restore
* status
*/
// Currently, the sdcard is not mounted
public static final int STATE_SD_CARD_UNMOUONTED = 0;
// The backup file not exist
public static final int STATE_BACKUP_FILE_NOT_EXIST = 1;
// The data is not well formated, may be changed by other programs
public static final int STATE_DATA_DESTROIED = 2;
// Some run-time exception which causes restore or backup fails
public static final int STATE_SYSTEM_ERROR = 3;
// Backup or restore success
public static final int STATE_SUCCESS = 4;
private TextExport mTextExport;
private BackupUtils(Context context) {
mTextExport = new TextExport(context);
}
//判断外部存储(如 SD 卡)是否已挂载且可读写
private static boolean externalStorageAvailable() {
return Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState());
}
//执行导出
public int exportToText() {
return mTextExport.exportToText();
}
//获取导出文件的文件名和目录路径
public String getExportedTextFileName() {
return mTextExport.mFileName;
}
public String getExportedTextFileDir() {
return mTextExport.mFileDirectory;
}
/**
* 负责将笔记数据从数据库导出为格式化纯文本文件的内部工具类。
*
* <p>该类通过 ContentProvider 查询笔记主表Notes和数据明细表Data
* 按照资源文件中定义的模板格式,将内容拼接并写入外部存储的 .txt 文件。
* 所有数据库查询均使用投影Projection限制字段提升性能与安全性
* 列索引通过命名常量定义,避免魔法数字,增强可读性与可维护性。
*/
private static class TextExport {
// === 笔记主表Notes Table查询配置 ===
/**
* 查询笔记主表时使用的字段投影(只获取必要字段)。
* 顺序必须与下方索引常量保持一致。
*/
private static final String[] NOTE_PROJECTION = {
NoteColumns.ID, // 笔记唯一ID
NoteColumns.MODIFIED_DATE, // 最后修改时间
NoteColumns.SNIPPET, // 笔记摘要/标题
NoteColumns.TYPE // 笔记类型(用于分组或过滤)
};
private static final int NOTE_COLUMN_ID = 0;
private static final int NOTE_COLUMN_MODIFIED_DATE = 1;
private static final int NOTE_COLUMN_SNIPPET = 2;
// === 数据明细表Data Table查询配置 ===
/**
* 查询笔记数据明细表时使用的字段投影。
* 采用类似 Android Contacts Provider 的 EAV 模型,
* 具体字段含义由 MIME_TYPE 决定(如文本、电话等)。
*/
private static final String[] DATA_PROJECTION = {
DataColumns.CONTENT, // 主内容(如文本)
DataColumns.MIME_TYPE, // 数据类型(决定 DATA1~DATA4 的语义)
DataColumns.DATA1,
DataColumns.DATA2,
DataColumns.DATA3,
DataColumns.DATA4,
};
private static final int DATA_COLUMN_CONTENT = 0;
private static final int DATA_COLUMN_MIME_TYPE = 1;
private static final int DATA_COLUMN_CALL_DATE = 2; //通话日期字段的列索引
private static final int DATA_COLUMN_PHONE_NUMBER = 4; //电话号码字段的列索引
// === 文本格式模板配置 ===
/**
* 从资源文件加载的导出文本格式模板数组,支持多语言。
* 定义在 res/values/arrays.xml 中的 R.array.format_for_exported_note。
*/
private final String [] TEXT_FORMAT;
private static final int FORMAT_FOLDER_NAME = 0;
private static final int FORMAT_NOTE_DATE = 1;
private static final int FORMAT_NOTE_CONTENT = 2;
private Context mContext; //保存上下文,用于访问系统服务
private String mFileName; //存储最近一次成功导出的文件名
private String mFileDirectory; //存储导出文件所在的目录路径
/**
* 构造一个 TextExport 实例,用于执行笔记数据的文本导出任务。
*
* <p>该方法从应用资源中加载多语言支持的导出格式模板R.array.format_for_exported_note
* 并初始化导出结果的文件名与目录路径为空字符串(将在导出成功后填充)。
*
* @param context 上下文对象,用于访问字符串数组资源;
*/
public TextExport(Context context) {
TEXT_FORMAT = context.getResources().getStringArray(R.array.format_for_exported_note);
mContext = context;
mFileName = "";
mFileDirectory = "";
}
/**
* 根据指定的格式类型 ID从资源加载的模板数组中获取对应的文本格式字符串。
*/
private String getFormat(int id) {
return TEXT_FORMAT[id];
}
/**
* Export the folder identified by folder id to text
*/
private void exportFolderToText(String folderId, PrintStream ps) {
// Query notes belong to this folder
Cursor notesCursor = mContext.getContentResolver().query(Notes.CONTENT_NOTE_URI,
NOTE_PROJECTION, NoteColumns.PARENT_ID + "=?", new String[] {
folderId
}, null);
// 检查查询结果是否有效(非 null
if (notesCursor != null) {
// 尝试将游标移动到第一条记录;若结果集为空,则 moveToFirst() 返回 false跳过循环
if (notesCursor.moveToFirst()) {
do {
// Print note's last modified date
ps.println(String.format(getFormat(FORMAT_NOTE_DATE), DateFormat.format(
mContext.getString(R.string.format_datetime_mdhm),
notesCursor.getLong(NOTE_COLUMN_MODIFIED_DATE))));
// Query data belong to this note
String noteId = notesCursor.getString(NOTE_COLUMN_ID);
exportNoteToText(noteId, ps);
} while (notesCursor.moveToNext()); // 移动到下一条笔记,直到遍历完所有记录
}
// 关闭 Cursor 以释放数据库资源,防止内存泄漏
notesCursor.close();
}
}
/**
* Export note identified by id to a print stream
*/
private void exportNoteToText(String noteId, PrintStream ps) {
// 查询属于该笔记的所有明细数据Data 表),每条数据可能代表文本、电话记录等
Cursor dataCursor = mContext.getContentResolver().query(Notes.CONTENT_DATA_URI,
DATA_PROJECTION, DataColumns.NOTE_ID + "=?", new String[] {
noteId
}, null);
if (dataCursor != null) {
// 遍历该笔记关联的所有数据行(一个笔记可能包含多条 Data 记录)
if (dataCursor.moveToFirst()) {
do {
// 根据 MIME 类型区分数据类型
String mimeType = dataCursor.getString(DATA_COLUMN_MIME_TYPE);
if (DataConstants.CALL_NOTE.equals(mimeType)) {
// 处理“通话笔记”类型:包含电话号码、通话时间、附加位置信息
// Print phone number
String phoneNumber = dataCursor.getString(DATA_COLUMN_PHONE_NUMBER);
long callDate = dataCursor.getLong(DATA_COLUMN_CALL_DATE);
String location = dataCursor.getString(DATA_COLUMN_CONTENT);
if (!TextUtils.isEmpty(phoneNumber)) {
ps.println(String.format(getFormat(FORMAT_NOTE_CONTENT),
phoneNumber));
}
// Print call date
ps.println(String.format(getFormat(FORMAT_NOTE_CONTENT), DateFormat
.format(mContext.getString(R.string.format_datetime_mdhm),
callDate)));
// Print call attachment location
if (!TextUtils.isEmpty(location)) {
ps.println(String.format(getFormat(FORMAT_NOTE_CONTENT),
location));
}
} else if (DataConstants.NOTE.equals(mimeType)) {
// 处理普通文本笔记
String content = dataCursor.getString(DATA_COLUMN_CONTENT);
if (!TextUtils.isEmpty(content)) {
ps.println(String.format(getFormat(FORMAT_NOTE_CONTENT),
content));
}
}
} while (dataCursor.moveToNext());
}
dataCursor.close();
}
// print a line separator between note
// 注意:当前实现存在两个问题:
// 1. Character.LINE_SEPARATOR 是 Unicode 行分隔符 '\u2028',多数文本编辑器不识别为换行;
// 2. Character.LETTER_NUMBER 实际值为字符 '1'ASCII 49会意外输出数字 "1"。
// 正确做法应使用标准换行符,例如:
// ps.println(); // 推荐:输出平台兼容换行
// 或 ps.write('\n'); // 简单 LF 换行(适用于纯文本导出)
// 建议后续重构此逻辑以避免导出文件包含乱码或多余字符。
try {
ps.write(new byte[] {
Character.LINE_SEPARATOR, Character.LETTER_NUMBER
});
} catch (IOException e) {
Log.e(TAG, e.toString());
}
}
/**
* Note will be exported as text which is user readable
*/
public int exportToText() {
// 检查外部存储是否可用(如 SD 卡是否挂载)
if (!externalStorageAvailable()) {
Log.d(TAG, "Media was not mounted");
return STATE_SD_CARD_UNMOUONTED;
}
// 获取用于写入导出文本的 PrintStream
PrintStream ps = getExportToTextPrintStream();
if (ps == null) {
Log.e(TAG, "get print stream error");
return STATE_SYSTEM_ERROR;
}
// First export folder and its notes
// 查询所有“非回收站”的文件夹 + 通话记录专用文件夹(即使它可能不满足普通文件夹条件)
// 条件说明:
// - 类型为 TYPE_FOLDER 且父 ID 不是回收站(排除回收站本身)
// - 或者是固定的通话记录文件夹ID = ID_CALL_RECORD_FOLDER
Cursor folderCursor = mContext.getContentResolver().query(
Notes.CONTENT_NOTE_URI,
NOTE_PROJECTION,
"(" + NoteColumns.TYPE + "=" + Notes.TYPE_FOLDER + " AND "
+ NoteColumns.PARENT_ID + "<>" + Notes.ID_TRASH_FOLER + ") OR "
+ NoteColumns.ID + "=" + Notes.ID_CALL_RECORD_FOLDER, null, null);
if (folderCursor != null) {
if (folderCursor.moveToFirst()) {
do {
// Print folder's name
// 获取文件夹名称:通话记录文件夹使用固定字符串,其他取自 SNIPPET 字段
String folderName = "";
if(folderCursor.getLong(NOTE_COLUMN_ID) == Notes.ID_CALL_RECORD_FOLDER) {
folderName = mContext.getString(R.string.call_record_folder_name);
} else {
folderName = folderCursor.getString(NOTE_COLUMN_SNIPPET);
}
if (!TextUtils.isEmpty(folderName)) {
// 使用预定义模板输出文件夹标题
ps.println(String.format(getFormat(FORMAT_FOLDER_NAME), folderName));
}
String folderId = folderCursor.getString(NOTE_COLUMN_ID);
// 递归导出该文件夹下的所有笔记
exportFolderToText(folderId, ps);
} while (folderCursor.moveToNext());
}
folderCursor.close();
}
// Export notes in root's folder
// 查询“根目录”下的普通笔记(即 PARENT_ID = 0 且类型为普通笔记)
Cursor noteCursor = mContext.getContentResolver().query(
Notes.CONTENT_NOTE_URI,
NOTE_PROJECTION,
NoteColumns.TYPE + "=" + +Notes.TYPE_NOTE + " AND " + NoteColumns.PARENT_ID
+ "=0", null, null);
if (noteCursor != null) {
if (noteCursor.moveToFirst()) {
do {
// 输出根目录笔记的最后修改时间
ps.println(String.format(getFormat(FORMAT_NOTE_DATE), DateFormat.format(
mContext.getString(R.string.format_datetime_mdhm),
noteCursor.getLong(NOTE_COLUMN_MODIFIED_DATE))));
// Query data belong to this note
String noteId = noteCursor.getString(NOTE_COLUMN_ID);
exportNoteToText(noteId, ps);
} while (noteCursor.moveToNext());
}
noteCursor.close();
}
ps.close();
return STATE_SUCCESS;
}
/**
* Get a print stream pointed to the file {@generateExportedTextFile}
*/
private PrintStream getExportToTextPrintStream() {
// 生成导出文件的完整路径(位于外部存储指定目录下)
File file = generateFileMountedOnSDcard(mContext, R.string.file_path,
R.string.file_name_txt_format);
if (file == null) {
Log.e(TAG, "create file to exported failed");
return null;
}
// 缓存文件名和目录路径
mFileName = file.getName();
mFileDirectory = mContext.getString(R.string.file_path);
PrintStream ps = null;
try {
// 创建指向该文件的输出流(覆盖写入模式)
FileOutputStream fos = new FileOutputStream(file);
// 包装为 PrintStream便于使用 println() 等文本写入方法
ps = new PrintStream(fos);
} catch (FileNotFoundException e) {
// 文件无法创建(如路径无效、权限不足、存储未挂载等)
e.printStackTrace();
return null;
} catch (NullPointerException e) {
// 通常由 generateFileMountedOnSDcard 返回 null 或上下文异常导致
e.printStackTrace();
return null;
}
return ps;
}
}
/**
* Generate the text file to store imported data
*/
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(
fileNameFormatResId,
DateFormat.format(context.getString(R.string.format_date_ymd),
System.currentTimeMillis())));
File file = new File(sb.toString());
try {
// 确保目标目录存在,不存在则尝试创建
if (!filedir.exists()) {
filedir.mkdir(); // 注意mkdir() 只能创建单层目录,若需多级应使用 mkdirs()
}
// 创建空文件(如果尚不存在)
if (!file.exists()) {
file.createNewFile();
}
return file;
} catch (SecurityException e) {
// 通常因缺少 WRITE_EXTERNAL_STORAGE 权限Android 6.0+ 需动态申请)
e.printStackTrace();
} catch (IOException e) {
// 可能原因:磁盘空间不足、路径为只读、文件被占用等
e.printStackTrace();
}
// 任一异常发生或创建失败时,返回 null 表示文件准备失败
return null;
}
}