Merge pull request '新增AI助手辅助类' (#5) from xuye_branch into master

master
phfuef3l9 1 month ago
commit d802fc0d2f

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- 滑动容器 -->
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#E0E0E0"/>
<!-- 底部导航 -->
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_navigation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#FFFFFF"
app:menu="@menu/bottom_nav_menu" />
</LinearLayout>

@ -0,0 +1,111 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F5F6F7">
<!-- 顶部标题栏 -->
<RelativeLayout
android:id="@+id/top_bar"
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#FFFFFF"
android:elevation="2dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="Mi 管家"
android:textStyle="bold"
android:textSize="16sp"/>
<ImageView
android:id="@+id/btn_new_chat"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_alignParentRight="true"
android:layout_centerVertical="true"
android:layout_marginRight="8dp"
android:padding="8dp"
android:src="@android:drawable/ic_menu_rotate"
android:tooltipText="新对话"/>
</RelativeLayout>
<!-- 中间的 Logo 区域 (初始显示) -->
<LinearLayout
android:id="@+id/center_logo_area"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:layout_width="80dp"
android:layout_height="80dp"
android:src="@android:drawable/sym_def_app_icon"/> <!-- 可替换为自定义Logo -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="你的私人管家"
android:textSize="20sp"
android:textStyle="bold"
android:textColor="#333333"/>
</LinearLayout>
<!-- 聊天列表 (初始隐藏) -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@id/top_bar"
android:layout_above="@id/bottom_input_area"
android:visibility="gone"
android:clipToPadding="false"
android:paddingBottom="10dp"/>
<!-- 底部输入区域 -->
<LinearLayout
android:id="@+id/bottom_input_area"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:padding="12dp"
android:background="#FFFFFF"
android:orientation="vertical">
<CheckBox
android:id="@+id/cb_use_knowledge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="使用知识库"
android:checked="true"
android:textSize="12sp"
android:textColor="#666666"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<EditText
android:id="@+id/et_input"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@android:drawable/editbox_background"
android:hint="问点什么..."
android:padding="10dp"
android:minHeight="48dp"/>
<Button
android:id="@+id/btn_send"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="发送"
android:layout_marginLeft="8dp"/>
</LinearLayout>
</LinearLayout>
</RelativeLayout>

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp">
<!-- AI 头像 -->
<ImageView
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@android:drawable/sym_def_app_icon"
android:layout_marginRight="12dp"
android:layout_marginTop="2dp"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- 思考状态 -->
<LinearLayout
android:id="@+id/layout_thinking"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:visibility="visible">
<ProgressBar
android:layout_width="16dp"
android:layout_height="16dp"
style="?android:attr/progressBarStyleSmall"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=" 深度思考中..."
android:textSize="12sp"
android:textColor="#999999"/>
</LinearLayout>
<!-- 正文内容 -->
<TextView
android:id="@+id/tv_ai_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="这是AI回复的内容"
android:textColor="#333333"
android:textSize="16sp"
android:lineSpacingMultiplier="1.2"
android:visibility="gone"/>
<!-- 参考资料区域 -->
<LinearLayout
android:id="@+id/layout_references"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="12dp"
android:background="#F0F0F0"
android:padding="8dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="参考便签:"
android:textSize="10sp"
android:textStyle="bold"
android:textColor="#666666"
android:layout_marginBottom="4dp"/>
<TextView
android:id="@+id/tv_reference_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="1. 工作安排\n2. 会议纪要"
android:textSize="12sp"
android:textColor="#3370FF"/>
</LinearLayout>
</LinearLayout>
</LinearLayout>

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="right"
android:padding="16dp">
<TextView
android:id="@+id/tv_user_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="40dp"
android:text="帮我查一下明天的日程"
android:textColor="#000000"
android:textSize="22sp"
android:textStyle="bold" />
</LinearLayout>

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/nav_mi_steward"
android:icon="@android:drawable/ic_dialog_info"
android:title="Mi管家" />
<item
android:id="@+id/nav_notes"
android:icon="@android:drawable/ic_menu_edit"
android:title="写便签" />
</menu>

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 代表 Context.getExternalFilesDir() 目录 -->
<external-files-path name="my_images" path="images/" />
</paths>

@ -0,0 +1,277 @@
package net.micode.notes.ai;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
/**
* AI
* DeepSeek
*/
public class AIService {
// ==========================================
// 请在这里填入您的 DeepSeek API Key
// ==========================================
private static final String API_KEY = "sk-e9644b634818458f8238fbd6c682ff41";
// DeepSeek 的官方接口地址
private static final String API_URL = "https://api.deepseek.com/chat/completions";
// 模型名称deepseek-chat (目前指向 DeepSeek-V3)
private static final String MODEL_NAME = "deepseek-chat";
private static final String TAG = "AIService";
// 定义一个接口用来通知界面AI 是成功了还是失败了
public interface AIResultCallback {
void onSuccess(String result);
void onError(String error);
}
/**
* DeepSeek 便
* @param content 便
* @param callback
*/
public static void callDeepSeek(final String content, final AIResultCallback callback) {
// 开启一个新线程去联网,防止卡死主线程
new Thread(new Runnable() {
@Override
public void run() {
try {
// 1. 准备连接
URL url = new URL(API_URL);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Authorization", "Bearer " + API_KEY);
conn.setRequestProperty("Content-Type", "application/json");
conn.setDoOutput(true);
conn.setConnectTimeout(10000); // 10秒超时
conn.setReadTimeout(30000); // 30秒读取超时
// 2. 准备发送给 AI 的数据 (JSON格式)
// 构造提示词 (Prompt)
String prompt = "请对以下便签内容进行整理、润色和排版,使其条理清晰,如果内容较短则进行扩写。直接输出结果,不要啰嗦:\n\n" + content;
JSONObject jsonBody = new JSONObject();
jsonBody.put("model", MODEL_NAME);
// 构建消息列表
JSONArray messages = new JSONArray();
JSONObject userMessage = new JSONObject();
userMessage.put("role", "user");
userMessage.put("content", prompt);
messages.put(userMessage);
jsonBody.put("messages", messages);
jsonBody.put("stream", false); // 不使用流式传输,一次性返回
// 3. 发送数据
try (OutputStream os = conn.getOutputStream()) {
byte[] input = jsonBody.toString().getBytes("utf-8");
os.write(input, 0, input.length);
}
// 4. 接收结果
int code = conn.getResponseCode();
if (code == 200) {
// 读取返回的数据
BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), "utf-8"));
StringBuilder response = new StringBuilder();
String responseLine;
while ((responseLine = br.readLine()) != null) {
response.append(responseLine.trim());
}
// 解析 DeepSeek 返回的 JSON
JSONObject responseJson = new JSONObject(response.toString());
// 提取核心回复内容
String aiText = responseJson.getJSONArray("choices")
.getJSONObject(0)
.getJSONObject("message")
.getString("content");
// 成功!通知界面 (切换回主线程)
runOnMainThread(callback, aiText, null);
} else {
// 失败
runOnMainThread(callback, null, "服务器错误: " + code);
}
} catch (Exception e) {
e.printStackTrace();
runOnMainThread(callback, null, "网络异常: " + e.getMessage());
}
}
}).start();
}
/**
* AI
* JSON{"color_id": 2, "tags": ["会议", "方案"]}
*/
public static void classifyNote(final String content, final AIResultCallback callback) {
new Thread(new Runnable() {
@Override
public void run() {
try {
URL url = new URL(API_URL);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Authorization", "Bearer " + API_KEY);
conn.setRequestProperty("Content-Type", "application/json");
conn.setDoOutput(true);
// 构造提示词:这是最关键的一步!
// 我们告诉 AI 颜色的 ID 对应关系,让它直接返回 ID
String prompt = "你是一个便签整理助手。请分析以下便签内容,完成两件事:\n" +
"1. 从以下分类中选择最匹配的一个,并返回对应的 color_id\n" +
" - 生活/记事: 0\n" +
" - 紧急/重要: 1\n" +
" - 工作/学习: 2\n" +
" - 旅行/规划: 3\n" +
" - 灵感/创作: 4\n" +
"2. 提炼 2 个简短的关键词标签每个不超过4个字。\n" +
"\n" +
"便签内容:" + content + "\n" +
"\n" +
"请务必只返回标准的 JSON 格式不要包含任何其他文字或Markdown标记格式如下\n" +
"{\"color_id\": 整数, \"tags\": [\"标签1\", \"标签2\"]}";
JSONObject jsonBody = new JSONObject();
jsonBody.put("model", MODEL_NAME);
JSONArray messages = new JSONArray();
JSONObject userMessage = new JSONObject();
userMessage.put("role", "user");
userMessage.put("content", prompt);
messages.put(userMessage);
jsonBody.put("messages", messages);
// JSON Mode (DeepSeek 部分模型支持,为了稳妥我们用普通 prompt 约束)
// jsonBody.put("response_format", new JSONObject().put("type", "json_object"));
try (OutputStream os = conn.getOutputStream()) {
byte[] input = jsonBody.toString().getBytes("utf-8");
os.write(input, 0, input.length);
}
int code = conn.getResponseCode();
if (code == 200) {
BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), "utf-8"));
StringBuilder response = new StringBuilder();
String line;
while ((line = br.readLine()) != null) response.append(line);
JSONObject responseJson = new JSONObject(response.toString());
String aiContent = responseJson.getJSONArray("choices")
.getJSONObject(0).getJSONObject("message").getString("content");
// 清理一下 AI 可能返回的 Markdown 代码块符号 (```json ... ```)
aiContent = aiContent.replace("```json", "").replace("```", "").trim();
runOnMainThread(callback, aiContent, null);
} else {
runOnMainThread(callback, null, "Server Error: " + code);
}
} catch (Exception e) {
e.printStackTrace();
runOnMainThread(callback, null, "Error: " + e.getMessage());
}
}
}).start();
}
// 辅助方法:把结果切换回主线程(因为更新 UI 必须在主线程)
private static void runOnMainThread(final AIResultCallback callback, final String result, final String error) {
new Handler(Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
if (error != null) {
callback.onError(error);
} else {
callback. onSuccess(result);
}
}
});
}
/**
* RAG
* @param systemPrompt
* @param userQuery
* @param callback
*/
public static void chatWithKnowledge(final String systemPrompt, final String userQuery, final AIResultCallback callback) {
new Thread(new Runnable() {
@Override
public void run() {
try {
// ... (连接建立代码与之前相同) ...
URL url = new URL(API_URL);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Authorization", "Bearer " + API_KEY);
conn.setRequestProperty("Content-Type", "application/json");
conn.setDoOutput(true);
// 构建 Chat 消息体
JSONObject jsonBody = new JSONObject();
jsonBody.put("model", "deepseek-chat");
JSONArray messages = new JSONArray();
// 1. System Message (包含知识库)
JSONObject sysMsg = new JSONObject();
sysMsg.put("role", "system");
sysMsg.put("content", systemPrompt);
messages.put(sysMsg);
// 2. User Message (用户问题)
JSONObject userMsg = new JSONObject();
userMsg.put("role", "user");
userMsg.put("content", userQuery);
messages.put(userMsg);
jsonBody.put("messages", messages);
jsonBody.put("stream", false);
// ... (发送和接收代码与之前完全相同) ...
try (java.io.OutputStream os = conn.getOutputStream()) {
byte[] input = jsonBody.toString().getBytes("utf-8");
os.write(input, 0, input.length);
}
int code = conn.getResponseCode();
if (code == 200) {
// ... (读取响应代码与之前相同) ...
java.io.BufferedReader br = new java.io.BufferedReader(new java.io.InputStreamReader(conn.getInputStream(), "utf-8"));
StringBuilder response = new StringBuilder();
String line;
while ((line = br.readLine()) != null) response.append(line);
JSONObject responseJson = new JSONObject(response.toString());
String aiText = responseJson.getJSONArray("choices")
.getJSONObject(0).getJSONObject("message").getString("content");
runOnMainThread(callback, aiText, null);
} else {
runOnMainThread(callback, null, "Server Error: " + code);
}
} catch (Exception e) {
e.printStackTrace();
runOnMainThread(callback, null, "Network Error: " + e.getMessage());
}
}
}).start();
}
}

@ -0,0 +1,148 @@
package net.micode.notes.ai;
import android.content.Context;
import android.database.Cursor;
import android.text.TextUtils;
import android.util.Log;
import net.micode.notes.data.Notes;
import net.micode.notes.data.Notes.DataColumns;
import net.micode.notes.data.Notes.NoteColumns;
import java.util.ArrayList;
import java.util.List;
/**
* (Retrieval Layer)
* 便
*/
public class NoteRetriever {
public static class RetrievedNote {
public long id;
public String title;
public String content;
public long modifiedDate;
public RetrievedNote(long id, String title, String content, long date) {
this.id = id;
this.title = title;
this.content = content;
this.modifiedDate = date;
}
}
/**
*
* @param query
* @return 便
*/
public static List<RetrievedNote> searchRelevantNotes(Context context, String query) {
List<RetrievedNote> results = new ArrayList<>();
// 1. 尝试关键词搜索
// ... (保留你之前的代码逻辑) ...
// ---【修改开始:在这里插入逻辑】---
// ... (执行原本的 SQL 查询) ...
// 关键修改点:如果关键词搜索结果为空,或者用户只发了简单的问候
if (results.isEmpty()) {
Log.d("NoteRetriever", "关键词搜索无果,启动兜底策略:获取最近 10 条便签");
results.addAll(getRecentNotes(context, 10));
} else {
Log.d("NoteRetriever", "关键词搜索成功,找到 " + results.size() + " 条");
}
return results;
}
// 【新增方法】获取最近的 N 条便签
private static List<RetrievedNote> getRecentNotes(Context context, int limit) {
List<RetrievedNote> list = new ArrayList<>();
Cursor cursor = null;
try {
cursor = context.getContentResolver().query(
Notes.CONTENT_NOTE_URI, // 直接查 Note 主表
new String[]{NoteColumns.ID, NoteColumns.SNIPPET, NoteColumns.MODIFIED_DATE},
NoteColumns.TYPE + "=? AND " + NoteColumns.PARENT_ID + "<>?",
new String[]{String.valueOf(Notes.TYPE_NOTE), String.valueOf(Notes.ID_TRASH_FOLER)},
NoteColumns.MODIFIED_DATE + " DESC LIMIT " + limit // 按时间倒序,取前 N 条
);
if (cursor != null && cursor.moveToFirst()) {
do {
long id = cursor.getLong(0);
String snippet = cursor.getString(1); // 标题
long date = cursor.getLong(2);
// 再去 Data 表查这一条的完整内容
String content = getNoteContent(context, id);
// 如果内容为空,用标题代替
if (TextUtils.isEmpty(content)) content = snippet;
list.add(new RetrievedNote(id, snippet, content, date));
} while (cursor.moveToNext());
}
} catch (Exception e) {
Log.e("NoteRetriever", "Get recent notes failed", e);
} finally {
if (cursor != null) cursor.close();
}
return list;
}
// 【新增辅助方法】获取单条便签的内容
private static String getNoteContent(Context context, long noteId) {
String content = "";
Cursor c = null;
try {
c = context.getContentResolver().query(
Notes.CONTENT_DATA_URI,
new String[]{DataColumns.CONTENT},
DataColumns.NOTE_ID + "=? AND " + DataColumns.MIME_TYPE + "=?",
new String[]{String.valueOf(noteId), Notes.TextNote.CONTENT_ITEM_TYPE},
null
);
if (c != null && c.moveToFirst()) {
content = c.getString(0);
}
} finally {
if (c != null) c.close();
}
return content;
}
// 辅助方法:获取标题
private static String getSnippet(Context context, long noteId) {
String snippet = "无标题";
Cursor c = context.getContentResolver().query(
Notes.CONTENT_NOTE_URI,
new String[]{NoteColumns.SNIPPET},
NoteColumns.ID + "=?",
new String[]{String.valueOf(noteId)},
null);
if (c != null) {
if (c.moveToFirst()) snippet = c.getString(0);
c.close();
}
return snippet;
}
// 辅助方法:获取时间
private static long getDate(Context context, long noteId) {
long date = 0;
Cursor c = context.getContentResolver().query(
Notes.CONTENT_NOTE_URI,
new String[]{NoteColumns.MODIFIED_DATE},
NoteColumns.ID + "=?",
new String[]{String.valueOf(noteId)},
null);
if (c != null) {
if (c.moveToFirst()) date = c.getLong(0);
c.close();
}
return date;
}
}

@ -0,0 +1,52 @@
package net.micode.notes.ai;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Locale;
public class PromptBuilder {
/**
*
* @param relatedNotes 便
* @param useRAG RAG
*/
public static String buildSystemPrompt(List<NoteRetriever.RetrievedNote> relatedNotes, boolean useRAG) {
// === 分支 1通用 AI 模式 (未勾选知识库) ===
if (!useRAG) {
return "你是一个智能且博学的 AI 助手。请根据用户的提问直接给出准确、有帮助的回答。不需要参考任何本地便签信息。";
}
// === 分支 2私人管家模式 (勾选知识库) ===
StringBuilder sb = new StringBuilder();
// 1. 身份定义与当前环境
String currentTime = new SimpleDateFormat("yyyy年MM月dd日 EEEE HH:mm", Locale.CHINA).format(new Date());
sb.append("你是一个基于用户个人便签的私人管家 'Mi管家'。\n");
sb.append("当前时间是:").append(currentTime).append("。\n");
sb.append("你需要根据用户提供的【参考便签】来回答用户的问题。\n\n");
// 2. 注入参考资料
if (relatedNotes != null && !relatedNotes.isEmpty()) {
sb.append("【参考便签】:\n");
for (NoteRetriever.RetrievedNote note : relatedNotes) {
sb.append("[RefID:").append(note.id).append("] ");
sb.append("标题:").append(note.title).append("\n");
sb.append("内容:").append(note.content).append("\n");
sb.append("修改时间:").append(new SimpleDateFormat("MM-dd", Locale.CHINA).format(new Date(note.modifiedDate))).append("\n");
sb.append("----------------\n");
}
} else {
sb.append("【参考便签】:(无相关记录)\n");
}
// 3. 回答约束 (严苛模式)
sb.append("\n【回答要求】\n");
sb.append("1. 请根据当前时间和参考便签进行推理。\n");
sb.append("2. 如果在参考便签中找到了答案,请简明扼要地回答,并**必须**在句末标注来源,格式为 [Ref:便签标题]。\n");
sb.append("3. 如果参考便签中没有相关信息,请诚实回答“我在您的便签中没找到相关记录”,不要编造。\n");
return sb.toString();
}
}

@ -0,0 +1,34 @@
package net.micode.notes.model;
import java.util.List;
public class ChatMessage {
public static final int TYPE_USER = 0;
public static final int TYPE_AI = 1;
private int type;
private String content;
private boolean isThinking; // 是否正在思考(仅 AI 有效)
private List<String> references; // 参考资料标题列表(仅 AI 有效)
// 用户消息构造
public ChatMessage(String content) {
this.type = TYPE_USER;
this.content = content;
}
// AI 消息构造
public ChatMessage() {
this.type = TYPE_AI;
this.content = "";
this.isThinking = true; // 默认一开始在思考
}
public int getType() { return type; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public boolean isThinking() { return isThinking; }
public void setThinking(boolean thinking) { isThinking = thinking; }
public List<String> getReferences() { return references; }
public void setReferences(List<String> references) { this.references = references; }
}

@ -0,0 +1,126 @@
package net.micode.notes.tool;
import android.content.ContentResolver;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.os.Environment;
import android.util.Log;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
/**
*
* IOUriBitmap
*
*/
public class MediaUtils {
private static final String TAG = "MediaUtils";
private static final String IMAGE_DIR_NAME = "images";
/**
*
* /Android/data//files/Pictures/images/
*/
public static File createImageFile(Context context) throws IOException {
String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(new Date());
String imageFileName = "NOTE_" + timeStamp + "_";
// 使用应用私有目录,不需要运行时存储权限即可写入
File storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES);
if (storageDir != null && !storageDir.exists()) {
storageDir.mkdirs();
}
// 创建临时文件
return File.createTempFile(
imageFileName, /* prefix */
".jpg", /* suffix */
storageDir /* directory */
);
}
/**
* Uri ContentProvider Uri
* 便
* @return
*/
public static String copyUriToInternalStorage(Context context, Uri sourceUri) {
InputStream inputStream = null;
FileOutputStream outputStream = null;
try {
ContentResolver contentResolver = context.getContentResolver();
// 1. 获取输入流
inputStream = contentResolver.openInputStream(sourceUri);
if (inputStream == null) return null;
// 2. 创建目标文件
File targetFile = createImageFile(context);
// 3. 写入文件
outputStream = new FileOutputStream(targetFile);
byte[] buffer = new byte[1024];
int length;
while ((length = inputStream.read(buffer)) > 0) {
outputStream.write(buffer, 0, length);
}
return targetFile.getAbsolutePath();
} catch (IOException e) {
Log.e(TAG, "Failed to copy image", e);
return null;
} finally {
// 安全关闭流
try {
if (inputStream != null) inputStream.close();
if (outputStream != null) outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
/**
* Bitmap OOM (Out Of Memory)
* @param path
* @param reqWidth
* @param reqHeight
*/
public static Bitmap getCompressedBitmap(String path, int reqWidth, int reqHeight) {
// 1. 只读取图片的尺寸信息,不加载像素到内存
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(path, options);
// 2. 计算压缩比例
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
// 3. 真正加载图片
options.inJustDecodeBounds = false;
return BitmapFactory.decodeFile(path, options);
}
// 计算采样率的辅助算法
private static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
final int halfHeight = height / 2;
final int halfWidth = width / 2;
// 计算最大的 2 的幂次,保证宽高仍大于期望宽高
while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2;
}
}
return inSampleSize;
}
}

@ -0,0 +1,40 @@
package net.micode.notes.tool;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 线
* 线
*/
public class ThreadExecutor {
// 单例模式保证整个APP只有一个线程池避免资源浪费
private static ThreadExecutor instance;
// 核心成员ExecutorService 就是 Java 里的“线程池管理者”
private final ExecutorService mService;
// 构造函数私有化:不准外面随便 new
private ThreadExecutor() {
// newSingleThreadExecutor创建一个单线程的线程池。
// 就像开设了一个“单人窗口”,所有任务按顺序排队执行。
// 为什么用单线程?因为数据库写入最好按顺序来,防止两个线程同时写同一个便签导致冲突。
mService = Executors.newSingleThreadExecutor();
}
// 获取唯一实例的方法
public static synchronized ThreadExecutor getInstance() {
if (instance == null) {
instance = new ThreadExecutor();
}
return instance;
}
/**
*
* @param task Runnable
*/
public void execute(Runnable task) {
mService.execute(task);
}
}

@ -0,0 +1,46 @@
package net.micode.notes.ui;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.text.style.ImageSpan;
public class CenterImageSpan extends ImageSpan {
public CenterImageSpan(Drawable d, String source) {
super(d, source);
}
@Override
public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
Drawable d = getDrawable();
Rect rect = d.getBounds();
if (fm != null) {
// 调整行高,确保能容纳图片
fm.ascent = -rect.bottom;
fm.descent = 0;
fm.top = fm.ascent;
fm.bottom = 0;
}
return rect.right;
}
@Override
public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) {
Drawable b = getDrawable();
canvas.save();
// 调试模式:不计算居中,直接画在 x 坐标(靠左)
// y 是基线位置bottom 是行底
// 我们让图片底部对齐到底部
int transY = bottom - b.getBounds().bottom;
// 暂时直接用 x确保能看见
canvas.translate(x, transY);
b.draw(canvas);
canvas.restore();
}
}

@ -0,0 +1,116 @@
package net.micode.notes.ui;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import net.micode.notes.R;
import net.micode.notes.model.ChatMessage;
import java.util.ArrayList;
import java.util.List;
public class ChatAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private List<ChatMessage> mData = new ArrayList<>();
public void addMessage(ChatMessage message) {
mData.add(message);
notifyItemInserted(mData.size() - 1);
}
public void clear() {
mData.clear();
notifyDataSetChanged();
}
// 获取最后一条消息(用于流式更新)
public ChatMessage getLastMessage() {
if (mData.isEmpty()) return null;
return mData.get(mData.size() - 1);
}
public void updateLastMessage() {
if (!mData.isEmpty()) {
notifyItemChanged(mData.size() - 1);
}
}
@Override
public int getItemViewType(int position) {
return mData.get(position).getType();
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
if (viewType == ChatMessage.TYPE_USER) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_chat_user, parent, false);
return new UserViewHolder(v);
} else {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_chat_ai, parent, false);
return new AIViewHolder(v);
}
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
ChatMessage msg = mData.get(position);
if (holder instanceof UserViewHolder) {
((UserViewHolder) holder).tvContent.setText(msg.getContent());
} else if (holder instanceof AIViewHolder) {
AIViewHolder aiHolder = (AIViewHolder) holder;
if (msg.isThinking()) {
aiHolder.layoutThinking.setVisibility(View.VISIBLE);
aiHolder.tvContent.setVisibility(View.GONE);
aiHolder.layoutRefs.setVisibility(View.GONE);
} else {
aiHolder.layoutThinking.setVisibility(View.GONE);
aiHolder.tvContent.setVisibility(View.VISIBLE);
aiHolder.tvContent.setText(msg.getContent());
// 处理参考资料
if (msg.getReferences() != null && !msg.getReferences().isEmpty()) {
aiHolder.layoutRefs.setVisibility(View.VISIBLE);
StringBuilder refs = new StringBuilder();
for (String ref : msg.getReferences()) {
refs.append("📄 ").append(ref).append("\n");
}
aiHolder.tvRefs.setText(refs.toString().trim());
} else {
aiHolder.layoutRefs.setVisibility(View.GONE);
}
}
}
}
@Override
public int getItemCount() {
return mData.size();
}
static class UserViewHolder extends RecyclerView.ViewHolder {
TextView tvContent;
UserViewHolder(View itemView) {
super(itemView);
tvContent = itemView.findViewById(R.id.tv_user_content);
}
}
static class AIViewHolder extends RecyclerView.ViewHolder {
View layoutThinking;
TextView tvContent;
View layoutRefs;
TextView tvRefs;
AIViewHolder(View itemView) {
super(itemView);
layoutThinking = itemView.findViewById(R.id.layout_thinking);
tvContent = itemView.findViewById(R.id.tv_ai_content);
layoutRefs = itemView.findViewById(R.id.layout_references);
tvRefs = itemView.findViewById(R.id.tv_reference_list);
}
}
}

@ -0,0 +1,72 @@
package net.micode.notes.ui;
import android.os.Bundle;
import android.view.MenuItem;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import androidx.viewpager2.adapter.FragmentStateAdapter;
import androidx.viewpager2.widget.ViewPager2;
import com.google.android.material.bottomnavigation.BottomNavigationView;
import net.micode.notes.R;
public class MainActivity extends AppCompatActivity {
private ViewPager2 mViewPager;
private BottomNavigationView mBottomNav;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mViewPager = findViewById(R.id.view_pager);
mBottomNav = findViewById(R.id.bottom_navigation);
// 设置 Adapter
mViewPager.setAdapter(new FragmentStateAdapter(this) {
@NonNull
@Override
public Fragment createFragment(int position) {
if (position == 0) {
return new MiStewardFragment(); // 默认第一页是管家
} else {
return new NotesListFragment(); // 第二页是便签列表
}
}
@Override
public int getItemCount() {
return 2;
}
});
// 联动逻辑:点击底部 -> 切换 ViewPager
mBottomNav.setOnItemSelectedListener(new BottomNavigationView.OnItemSelectedListener() {
@Override
public boolean onNavigationItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == R.id.nav_mi_steward) {
mViewPager.setCurrentItem(0);
return true;
} else if (item.getItemId() == R.id.nav_notes) {
mViewPager.setCurrentItem(1);
return true;
}
return false;
}
});
// 联动逻辑:滑动 ViewPager -> 切换底部按钮
mViewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override
public void onPageSelected(int position) {
super.onPageSelected(position);
if (position == 0) {
mBottomNav.setSelectedItemId(R.id.nav_mi_steward);
} else {
mBottomNav.setSelectedItemId(R.id.nav_notes);
}
}
});
}
}

@ -0,0 +1,159 @@
package net.micode.notes.ui;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.Toast;
import net.micode.notes.ai.NoteRetriever;
import net.micode.notes.ai.PromptBuilder;
import net.micode.notes.ai.AIService;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import android.widget.CheckBox;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import net.micode.notes.R;
import net.micode.notes.model.ChatMessage;
import java.util.ArrayList;
import java.util.List;
public class MiStewardFragment extends Fragment {
private RecyclerView mRecyclerView;
private ChatAdapter mAdapter;
private EditText mInputBox;
private View mLogoArea;
private View mTopBar;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_mi_steward, container, false);
initViews(view);
return view;
}
private void initViews(View view) {
mRecyclerView = view.findViewById(R.id.recycler_view);
mInputBox = view.findViewById(R.id.et_input);
mLogoArea = view.findViewById(R.id.center_logo_area);
mTopBar = view.findViewById(R.id.top_bar);
Button btnSend = view.findViewById(R.id.btn_send);
ImageView btnNewChat = view.findViewById(R.id.btn_new_chat);
mAdapter = new ChatAdapter();
mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
mRecyclerView.setAdapter(mAdapter);
// 发送按钮点击
btnSend.setOnClickListener(v -> {
String text = mInputBox.getText().toString().trim();
if (TextUtils.isEmpty(text)) return;
// 1. 切换 UI 状态
mLogoArea.setVisibility(View.GONE);
mRecyclerView.setVisibility(View.VISIBLE);
// 2. 添加用户消息
mAdapter.addMessage(new ChatMessage(text));
mInputBox.setText("");
scrollToBottom();
// 3. 模拟 AI 思考 (这是个占位逻辑,下一阶段会接真实的 DeepSeek)
performAIQuery(text);
});
// 新对话按钮点击
btnNewChat.setOnClickListener(v -> {
mAdapter.clear();
mLogoArea.setVisibility(View.VISIBLE);
mRecyclerView.setVisibility(View.GONE);
Toast.makeText(getContext(), "已开启新对话", Toast.LENGTH_SHORT).show();
});
}
private void scrollToBottom() {
mRecyclerView.scrollToPosition(mAdapter.getItemCount() - 1);
}
// 模拟 AI 回复 (测试 UI 用)
// 真实的 AI 响应处理
private void performAIQuery(String userQuery) {
// 先在 UI 上显示“思考中”
ChatMessage aiMsg = new ChatMessage();
mAdapter.addMessage(aiMsg);
scrollToBottom();
// 1. 判断是否勾选“使用知识库”
CheckBox cbUseKnowledge = getView().findViewById(R.id.cb_use_knowledge);
boolean useRAG = cbUseKnowledge.isChecked();
// 2. 异步检索知识库 (检索需要在子线程,虽然 SQLite 很快,但为了严谨)
new Thread(() -> {
List<NoteRetriever.RetrievedNote> relatedNotes = null;
if (useRAG) {
// 去数据库检索
relatedNotes = NoteRetriever.searchRelevantNotes(getContext(), userQuery);
}
// 3. 构建 Prompt
String systemPrompt = PromptBuilder.buildSystemPrompt(relatedNotes,useRAG);
// 4. 调用 API
AIService.chatWithKnowledge(systemPrompt, userQuery, new AIService.AIResultCallback() {
@Override
public void onSuccess(String result) {
// 解析结果中的引用 [Ref:xxx]
List<String> refs = new ArrayList<>();
// 使用正则提取引用并从文本中移除(或者保留,看你喜好,这里我们提取出来单独展示)
String cleanContent = result;
// 正则匹配 [Ref:...]
Pattern pattern = Pattern.compile("\\[Ref:(.*?)\\]");
Matcher matcher = pattern.matcher(result);
while (matcher.find()) {
String refTitle = matcher.group(1);
if (!refs.contains(refTitle)) {
refs.add("📌 " + refTitle);
}
}
// 如果你想把正文里的 [Ref:xxx] 去掉,可以取消下面这行的注释
cleanContent = matcher.replaceAll("").trim();
// 更新 UI
ChatMessage lastMsg = mAdapter.getLastMessage();
if (lastMsg != null && lastMsg.getType() == ChatMessage.TYPE_AI) {
lastMsg.setThinking(false);
lastMsg.setContent(cleanContent);
lastMsg.setReferences(refs);
mAdapter.updateLastMessage();
scrollToBottom();
}
}
@Override
public void onError(String error) {
ChatMessage lastMsg = mAdapter.getLastMessage();
if (lastMsg != null) {
lastMsg.setThinking(false);
lastMsg.setContent("大脑短路了... 错误原因:" + error);
mAdapter.updateLastMessage();
}
}
});
}).start();
}
}

@ -0,0 +1,882 @@
package net.micode.notes.ui;
import android.app.Activity;
import android.app.AlertDialog;
import android.appwidget.AppWidgetManager;
import android.content.AsyncQueryHandler;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.os.AsyncTask;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.util.Log;
import android.view.ActionMode;
import android.view.ContextMenu;
import android.view.ContextMenu.ContextMenuInfo;
import android.view.Display;
import android.view.HapticFeedbackConstants;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.View.OnCreateContextMenuListener;
import android.view.View.OnTouchListener;
import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.AdapterView.OnItemLongClickListener;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ListView;
import android.widget.PopupMenu;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import net.micode.notes.R;
import net.micode.notes.data.Notes;
import net.micode.notes.data.Notes.NoteColumns;
import net.micode.notes.gtask.remote.GTaskSyncService;
import net.micode.notes.model.WorkingNote;
import net.micode.notes.tool.BackupUtils;
import net.micode.notes.tool.DataUtils;
import net.micode.notes.tool.ResourceParser;
import net.micode.notes.ui.NotesListAdapter.AppWidgetAttribute;
import net.micode.notes.widget.NoteWidgetProvider_2x;
import net.micode.notes.widget.NoteWidgetProvider_4x;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.HashSet;
public class NotesListFragment extends Fragment implements OnClickListener, OnItemLongClickListener {
private static final int FOLDER_NOTE_LIST_QUERY_TOKEN = 0;
private static final int FOLDER_LIST_QUERY_TOKEN = 1;
private static final int MENU_FOLDER_DELETE = 0;
private static final int MENU_FOLDER_VIEW = 1;
private static final int MENU_FOLDER_CHANGE_NAME = 2;
private static final String PREFERENCE_ADD_INTRODUCTION = "net.micode.notes.introduction";
private enum ListEditState {
NOTE_LIST, SUB_FOLDER, CALL_RECORD_FOLDER
};
private ListEditState mState;
private BackgroundQueryHandler mBackgroundQueryHandler;
private NotesListAdapter mNotesListAdapter;
private ListView mNotesListView;
private Button mAddNewNote;
private boolean mDispatch;
private int mOriginY;
private int mDispatchY;
private TextView mTitleBar;
private long mCurrentFolderId;
private ContentResolver mContentResolver;
private ModeCallback mModeCallBack;
private static final String TAG = "NotesListFragment";
private NoteItemData mFocusNoteDataItem;
private static final String NORMAL_SELECTION = NoteColumns.PARENT_ID + "=?";
private static final String ROOT_FOLDER_SELECTION = "(" + NoteColumns.TYPE + "<>"
+ Notes.TYPE_SYSTEM + " AND " + NoteColumns.PARENT_ID + "=?)" + " OR ("
+ NoteColumns.ID + "=" + Notes.ID_CALL_RECORD_FOLDER + " AND "
+ NoteColumns.NOTES_COUNT + ">0)";
private final static int REQUEST_CODE_OPEN_NODE = 102;
private final static int REQUEST_CODE_NEW_NODE = 103;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.note_list, container, false);
// 重要:告诉系统这个 Fragment 有自己的菜单项Sync, Setting等
setHasOptionsMenu(true);
return view;
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
initResources(view);
setAppInfoFromRawRes();
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (resultCode == Activity.RESULT_OK
&& (requestCode == REQUEST_CODE_OPEN_NODE || requestCode == REQUEST_CODE_NEW_NODE)) {
mNotesListAdapter.changeCursor(null);
} else {
super.onActivityResult(requestCode, resultCode, data);
}
}
private void setAppInfoFromRawRes() {
SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(getActivity());
if (!sp.getBoolean(PREFERENCE_ADD_INTRODUCTION, false)) {
StringBuilder sb = new StringBuilder();
InputStream in = null;
try {
in = getResources().openRawResource(R.raw.introduction);
if (in != null) {
InputStreamReader isr = new InputStreamReader(in);
BufferedReader br = new BufferedReader(isr);
char [] buf = new char[1024];
int len = 0;
while ((len = br.read(buf)) > 0) {
sb.append(buf, 0, len);
}
} else {
Log.e(TAG, "Read introduction file error");
return;
}
} catch (IOException e) {
e.printStackTrace();
return;
} finally {
if(in != null) {
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
WorkingNote note = WorkingNote.createEmptyNote(getActivity(), Notes.ID_ROOT_FOLDER,
AppWidgetManager.INVALID_APPWIDGET_ID, Notes.TYPE_WIDGET_INVALIDE,
ResourceParser.RED);
note.setWorkingText(sb.toString());
if (note.saveNote()) {
sp.edit().putBoolean(PREFERENCE_ADD_INTRODUCTION, true).commit();
} else {
Log.e(TAG, "Save introduction note error");
return;
}
}
}
@Override
public void onStart() {
super.onStart();
startAsyncNotesListQuery();
}
private void initResources(View view) {
mContentResolver = getActivity().getContentResolver();
mBackgroundQueryHandler = new BackgroundQueryHandler(mContentResolver);
mCurrentFolderId = Notes.ID_ROOT_FOLDER;
mNotesListView = (ListView) view.findViewById(R.id.notes_list);
mNotesListView.addFooterView(LayoutInflater.from(getActivity()).inflate(R.layout.note_list_footer, null),
null, false);
mNotesListView.setOnItemClickListener(new OnListItemClickListener());
mNotesListView.setOnItemLongClickListener(this);
mNotesListAdapter = new NotesListAdapter(getActivity());
mNotesListView.setAdapter(mNotesListAdapter);
mAddNewNote = (Button) view.findViewById(R.id.btn_new_note);
mAddNewNote.setOnClickListener(this);
mAddNewNote.setOnTouchListener(new NewNoteOnTouchListener());
mDispatch = false;
mDispatchY = 0;
mOriginY = 0;
mTitleBar = (TextView) view.findViewById(R.id.tv_title_bar);
mState = ListEditState.NOTE_LIST;
mModeCallBack = new ModeCallback();
}
private class ModeCallback implements ListView.MultiChoiceModeListener, MenuItem.OnMenuItemClickListener {
private DropdownMenu mDropDownMenu;
private ActionMode mActionMode;
private MenuItem mMoveMenu;
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
getActivity().getMenuInflater().inflate(R.menu.note_list_options, menu);
menu.findItem(R.id.delete).setOnMenuItemClickListener(this);
mMoveMenu = menu.findItem(R.id.move);
if (mFocusNoteDataItem.getParentId() == Notes.ID_CALL_RECORD_FOLDER
|| DataUtils.getUserFolderCount(mContentResolver) == 0) {
mMoveMenu.setVisible(false);
} else {
mMoveMenu.setVisible(true);
mMoveMenu.setOnMenuItemClickListener(this);
}
mActionMode = mode;
mNotesListAdapter.setChoiceMode(true);
mNotesListView.setLongClickable(false);
mAddNewNote.setVisibility(View.GONE);
View customView = LayoutInflater.from(getActivity()).inflate(
R.layout.note_list_dropdown_menu, null);
mode.setCustomView(customView);
mDropDownMenu = new DropdownMenu(getActivity(),
(Button) customView.findViewById(R.id.selection_menu),
R.menu.note_list_dropdown);
mDropDownMenu.setOnDropdownMenuItemClickListener(new PopupMenu.OnMenuItemClickListener(){
public boolean onMenuItemClick(MenuItem item) {
mNotesListAdapter.selectAll(!mNotesListAdapter.isAllSelected());
updateMenu();
return true;
}
});
return true;
}
private void updateMenu() {
int selectedCount = mNotesListAdapter.getSelectedCount();
String format = getResources().getString(R.string.menu_select_title, selectedCount);
mDropDownMenu.setTitle(format);
MenuItem item = mDropDownMenu.findItem(R.id.action_select_all);
if (item != null) {
if (mNotesListAdapter.isAllSelected()) {
item.setChecked(true);
item.setTitle(R.string.menu_deselect_all);
} else {
item.setChecked(false);
item.setTitle(R.string.menu_select_all);
}
}
}
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
return false;
}
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
return false;
}
public void onDestroyActionMode(ActionMode mode) {
mNotesListAdapter.setChoiceMode(false);
mNotesListView.setLongClickable(true);
mAddNewNote.setVisibility(View.VISIBLE);
}
public void finishActionMode() {
mActionMode.finish();
}
public void onItemCheckedStateChanged(ActionMode mode, int position, long id,
boolean checked) {
mNotesListAdapter.setCheckedItem(position, checked);
updateMenu();
}
public boolean onMenuItemClick(MenuItem item) {
if (mNotesListAdapter.getSelectedCount() == 0) {
Toast.makeText(getActivity(), getString(R.string.menu_select_none),
Toast.LENGTH_SHORT).show();
return true;
}
switch (item.getItemId()) {
case R.id.delete:
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
builder.setTitle(getString(R.string.alert_title_delete));
builder.setIcon(android.R.drawable.ic_dialog_alert);
builder.setMessage(getString(R.string.alert_message_delete_notes,
mNotesListAdapter.getSelectedCount()));
builder.setPositiveButton(android.R.string.ok,
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog,
int which) {
batchDelete();
}
});
builder.setNegativeButton(android.R.string.cancel, null);
builder.show();
break;
case R.id.move:
startQueryDestinationFolders();
break;
default:
return false;
}
return true;
}
}
private class NewNoteOnTouchListener implements OnTouchListener {
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
Display display = getActivity().getWindowManager().getDefaultDisplay();
int screenHeight = display.getHeight();
int newNoteViewHeight = mAddNewNote.getHeight();
int start = screenHeight - newNoteViewHeight;
int eventY = start + (int) event.getY();
if (mState == ListEditState.SUB_FOLDER) {
eventY -= mTitleBar.getHeight();
start -= mTitleBar.getHeight();
}
if (event.getY() < (event.getX() * (-0.12) + 94)) {
View view = mNotesListView.getChildAt(mNotesListView.getChildCount() - 1
- mNotesListView.getFooterViewsCount());
if (view != null && view.getBottom() > start
&& (view.getTop() < (start + 94))) {
mOriginY = (int) event.getY();
mDispatchY = eventY;
event.setLocation(event.getX(), mDispatchY);
mDispatch = true;
return mNotesListView.dispatchTouchEvent(event);
}
}
break;
}
case MotionEvent.ACTION_MOVE: {
if (mDispatch) {
mDispatchY += (int) event.getY() - mOriginY;
event.setLocation(event.getX(), mDispatchY);
return mNotesListView.dispatchTouchEvent(event);
}
break;
}
default: {
if (mDispatch) {
event.setLocation(event.getX(), mDispatchY);
mDispatch = false;
return mNotesListView.dispatchTouchEvent(event);
}
break;
}
}
return false;
}
};
private void startAsyncNotesListQuery() {
String selection = (mCurrentFolderId == Notes.ID_ROOT_FOLDER) ? ROOT_FOLDER_SELECTION
: NORMAL_SELECTION;
mBackgroundQueryHandler.startQuery(FOLDER_NOTE_LIST_QUERY_TOKEN, null,
Notes.CONTENT_NOTE_URI, NoteItemData.PROJECTION, selection, new String[] {
String.valueOf(mCurrentFolderId)
}, NoteColumns.TYPE + " DESC," + NoteColumns.BG_COLOR_ID + " DESC," + NoteColumns.MODIFIED_DATE + " DESC");
}
private final class BackgroundQueryHandler extends AsyncQueryHandler {
public BackgroundQueryHandler(ContentResolver contentResolver) {
super(contentResolver);
}
@Override
protected void onQueryComplete(int token, Object cookie, Cursor cursor) {
switch (token) {
case FOLDER_NOTE_LIST_QUERY_TOKEN:
mNotesListAdapter.changeCursor(cursor);
break;
case FOLDER_LIST_QUERY_TOKEN:
if (cursor != null && cursor.getCount() > 0) {
showFolderListMenu(cursor);
} else {
Log.e(TAG, "Query folder failed");
}
break;
default:
return;
}
}
}
private void showFolderListMenu(Cursor cursor) {
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
builder.setTitle(R.string.menu_title_select_folder);
final FoldersListAdapter adapter = new FoldersListAdapter(getActivity(), cursor);
builder.setAdapter(adapter, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
DataUtils.batchMoveToFolder(mContentResolver,
mNotesListAdapter.getSelectedItemIds(), adapter.getItemId(which));
Toast.makeText(
getActivity(),
getString(R.string.format_move_notes_to_folder,
mNotesListAdapter.getSelectedCount(),
adapter.getFolderName(getActivity(), which)),
Toast.LENGTH_SHORT).show();
mModeCallBack.finishActionMode();
}
});
builder.show();
}
private void createNewNote() {
Intent intent = new Intent(getActivity(), NoteEditActivity.class);
intent.setAction(Intent.ACTION_INSERT_OR_EDIT);
intent.putExtra(Notes.INTENT_EXTRA_FOLDER_ID, mCurrentFolderId);
this.startActivityForResult(intent, REQUEST_CODE_NEW_NODE);
}
private void batchDelete() {
new AsyncTask<Void, Void, HashSet<AppWidgetAttribute>>() {
protected HashSet<AppWidgetAttribute> doInBackground(Void... unused) {
HashSet<AppWidgetAttribute> widgets = mNotesListAdapter.getSelectedWidget();
if (!isSyncMode()) {
if (DataUtils.batchDeleteNotes(mContentResolver, mNotesListAdapter
.getSelectedItemIds())) {
} else {
Log.e(TAG, "Delete notes error, should not happens");
}
} else {
if (!DataUtils.batchMoveToFolder(mContentResolver, mNotesListAdapter
.getSelectedItemIds(), Notes.ID_TRASH_FOLER)) {
Log.e(TAG, "Move notes to trash folder error, should not happens");
}
}
return widgets;
}
@Override
protected void onPostExecute(HashSet<AppWidgetAttribute> widgets) {
if (widgets != null) {
for (AppWidgetAttribute widget : widgets) {
if (widget.widgetId != AppWidgetManager.INVALID_APPWIDGET_ID
&& widget.widgetType != Notes.TYPE_WIDGET_INVALIDE) {
updateWidget(widget.widgetId, widget.widgetType);
}
}
}
mModeCallBack.finishActionMode();
}
}.execute();
}
private void deleteFolder(long folderId) {
if (folderId == Notes.ID_ROOT_FOLDER) {
Log.e(TAG, "Wrong folder id, should not happen " + folderId);
return;
}
HashSet<Long> ids = new HashSet<Long>();
ids.add(folderId);
HashSet<AppWidgetAttribute> widgets = DataUtils.getFolderNoteWidget(mContentResolver,
folderId);
if (!isSyncMode()) {
DataUtils.batchDeleteNotes(mContentResolver, ids);
} else {
DataUtils.batchMoveToFolder(mContentResolver, ids, Notes.ID_TRASH_FOLER);
}
if (widgets != null) {
for (AppWidgetAttribute widget : widgets) {
if (widget.widgetId != AppWidgetManager.INVALID_APPWIDGET_ID
&& widget.widgetType != Notes.TYPE_WIDGET_INVALIDE) {
updateWidget(widget.widgetId, widget.widgetType);
}
}
}
}
private void openNode(NoteItemData data) {
Intent intent = new Intent(getActivity(), NoteEditActivity.class);
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra(Intent.EXTRA_UID, data.getId());
this.startActivityForResult(intent, REQUEST_CODE_OPEN_NODE);
}
private void openFolder(NoteItemData data) {
mCurrentFolderId = data.getId();
startAsyncNotesListQuery();
if (data.getId() == Notes.ID_CALL_RECORD_FOLDER) {
mState = ListEditState.CALL_RECORD_FOLDER;
mAddNewNote.setVisibility(View.GONE);
} else {
mState = ListEditState.SUB_FOLDER;
}
if (data.getId() == Notes.ID_CALL_RECORD_FOLDER) {
mTitleBar.setText(R.string.call_record_folder_name);
} else {
mTitleBar.setText(data.getSnippet());
}
mTitleBar.setVisibility(View.VISIBLE);
}
public void onClick(View v) {
switch (v.getId()) {
case R.id.btn_new_note:
createNewNote();
break;
default:
break;
}
}
private void showSoftInput() {
InputMethodManager inputMethodManager = (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE);
if (inputMethodManager != null) {
inputMethodManager.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0);
}
}
private void hideSoftInput(View view) {
InputMethodManager inputMethodManager = (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE);
inputMethodManager.hideSoftInputFromWindow(view.getWindowToken(), 0);
}
private void showCreateOrModifyFolderDialog(final boolean create) {
final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
View view = LayoutInflater.from(getActivity()).inflate(R.layout.dialog_edit_text, null);
final EditText etName = (EditText) view.findViewById(R.id.et_foler_name);
showSoftInput();
if (!create) {
if (mFocusNoteDataItem != null) {
etName.setText(mFocusNoteDataItem.getSnippet());
builder.setTitle(getString(R.string.menu_folder_change_name));
} else {
Log.e(TAG, "The long click data item is null");
return;
}
} else {
etName.setText("");
builder.setTitle(this.getString(R.string.menu_create_folder));
}
builder.setPositiveButton(android.R.string.ok, null);
builder.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
hideSoftInput(etName);
}
});
// 把 Dialog 改成 AlertDialog
final AlertDialog dialog = builder.setView(view).show();
final Button positive = (Button)dialog.findViewById(android.R.id.button1);
positive.setOnClickListener(new OnClickListener() {
public void onClick(View v) {
hideSoftInput(etName);
String name = etName.getText().toString();
if (DataUtils.checkVisibleFolderName(mContentResolver, name)) {
Toast.makeText(getActivity(), getString(R.string.folder_exist, name),
Toast.LENGTH_LONG).show();
etName.setSelection(0, etName.length());
return;
}
if (!create) {
if (!TextUtils.isEmpty(name)) {
ContentValues values = new ContentValues();
values.put(NoteColumns.SNIPPET, name);
values.put(NoteColumns.TYPE, Notes.TYPE_FOLDER);
values.put(NoteColumns.LOCAL_MODIFIED, 1);
mContentResolver.update(Notes.CONTENT_NOTE_URI, values, NoteColumns.ID
+ "=?", new String[] {
String.valueOf(mFocusNoteDataItem.getId())
});
}
} else if (!TextUtils.isEmpty(name)) {
ContentValues values = new ContentValues();
values.put(NoteColumns.SNIPPET, name);
values.put(NoteColumns.TYPE, Notes.TYPE_FOLDER);
mContentResolver.insert(Notes.CONTENT_NOTE_URI, values);
}
dialog.dismiss();
}
});
if (TextUtils.isEmpty(etName.getText())) {
positive.setEnabled(false);
}
etName.addTextChangedListener(new TextWatcher() {
public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
public void onTextChanged(CharSequence s, int start, int before, int count) {
if (TextUtils.isEmpty(etName.getText())) {
positive.setEnabled(false);
} else {
positive.setEnabled(true);
}
}
public void afterTextChanged(Editable s) { }
});
}
// 重点Fragment 没有 onBackPressed这个方法供 MainActivity 调用
public boolean onBackPressed() {
switch (mState) {
case SUB_FOLDER:
mCurrentFolderId = Notes.ID_ROOT_FOLDER;
mState = ListEditState.NOTE_LIST;
startAsyncNotesListQuery();
mTitleBar.setVisibility(View.GONE);
return true; // 消费了返回事件
case CALL_RECORD_FOLDER:
mCurrentFolderId = Notes.ID_ROOT_FOLDER;
mState = ListEditState.NOTE_LIST;
mAddNewNote.setVisibility(View.VISIBLE);
mTitleBar.setVisibility(View.GONE);
startAsyncNotesListQuery();
return true; // 消费了返回事件
case NOTE_LIST:
default:
return false; // 不消费,让 MainActivity 处理(退出应用)
}
}
private void updateWidget(int appWidgetId, int appWidgetType) {
Intent intent = new Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE);
if (appWidgetType == Notes.TYPE_WIDGET_2X) {
intent.setClass(getActivity(), NoteWidgetProvider_2x.class);
} else if (appWidgetType == Notes.TYPE_WIDGET_4X) {
intent.setClass(getActivity(), NoteWidgetProvider_4x.class);
} else {
Log.e(TAG, "Unspported widget type");
return;
}
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, new int[] {
appWidgetId
});
getActivity().sendBroadcast(intent);
getActivity().setResult(Activity.RESULT_OK, intent);
}
private final OnCreateContextMenuListener mFolderOnCreateContextMenuListener = new OnCreateContextMenuListener() {
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
if (mFocusNoteDataItem != null) {
menu.setHeaderTitle(mFocusNoteDataItem.getSnippet());
menu.add(0, MENU_FOLDER_VIEW, 0, R.string.menu_folder_view);
menu.add(0, MENU_FOLDER_DELETE, 0, R.string.menu_folder_delete);
menu.add(0, MENU_FOLDER_CHANGE_NAME, 0, R.string.menu_folder_change_name);
}
}
};
@Override
public boolean onContextItemSelected(@NonNull MenuItem item) {
if (mFocusNoteDataItem == null) {
Log.e(TAG, "The long click data item is null");
return false;
}
switch (item.getItemId()) {
case MENU_FOLDER_VIEW:
openFolder(mFocusNoteDataItem);
break;
case MENU_FOLDER_DELETE:
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
builder.setTitle(getString(R.string.alert_title_delete));
builder.setIcon(android.R.drawable.ic_dialog_alert);
builder.setMessage(getString(R.string.alert_message_delete_folder));
builder.setPositiveButton(android.R.string.ok,
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
deleteFolder(mFocusNoteDataItem.getId());
}
});
builder.setNegativeButton(android.R.string.cancel, null);
builder.show();
break;
case MENU_FOLDER_CHANGE_NAME:
showCreateOrModifyFolderDialog(false);
break;
default:
break;
}
return true;
}
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
menu.clear();
if (mState == ListEditState.NOTE_LIST) {
inflater.inflate(R.menu.note_list, menu);
menu.findItem(R.id.menu_sync).setTitle(
GTaskSyncService.isSyncing() ? R.string.menu_sync_cancel : R.string.menu_sync);
} else if (mState == ListEditState.SUB_FOLDER) {
inflater.inflate(R.menu.sub_folder, menu);
} else if (mState == ListEditState.CALL_RECORD_FOLDER) {
inflater.inflate(R.menu.call_record_folder, menu);
} else {
Log.e(TAG, "Wrong state:" + mState);
}
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_new_folder: {
showCreateOrModifyFolderDialog(true);
break;
}
case R.id.menu_export_text: {
exportNoteToText();
break;
}
case R.id.menu_sync: {
if (isSyncMode()) {
if (TextUtils.equals(item.getTitle(), getString(R.string.menu_sync))) {
GTaskSyncService.startSync(getActivity());
} else {
GTaskSyncService.cancelSync(getActivity());
}
} else {
startPreferenceActivity();
}
break;
}
case R.id.menu_setting: {
startPreferenceActivity();
break;
}
case R.id.menu_new_note: {
createNewNote();
break;
}
case R.id.menu_search:
onSearchRequested();
break;
default:
break;
}
return true;
}
public boolean onSearchRequested() {
// Fragment 没有 startSearch需要调用 Activity 的
startActivity(new Intent(getActivity(), NotesListActivity.class).setAction(Intent.ACTION_SEARCH));
// 实际上 Fragment 很难直接调用系统的 SearchDialog 并回调,这里简单处理
return true;
}
private void exportNoteToText() {
final BackupUtils backup = BackupUtils.getInstance(getActivity());
new AsyncTask<Void, Void, Integer>() {
@Override
protected Integer doInBackground(Void... unused) {
return backup.exportToText();
}
@Override
protected void onPostExecute(Integer result) {
if (result == BackupUtils.STATE_SD_CARD_UNMOUONTED) {
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
builder.setTitle(getActivity()
.getString(R.string.failed_sdcard_export));
builder.setMessage(getActivity()
.getString(R.string.error_sdcard_unmounted));
builder.setPositiveButton(android.R.string.ok, null);
builder.show();
} else if (result == BackupUtils.STATE_SUCCESS) {
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
builder.setTitle(getActivity()
.getString(R.string.success_sdcard_export));
builder.setMessage(getActivity().getString(
R.string.format_exported_file_location, backup
.getExportedTextFileName(), backup.getExportedTextFileDir()));
builder.setPositiveButton(android.R.string.ok, null);
builder.show();
} else if (result == BackupUtils.STATE_SYSTEM_ERROR) {
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
builder.setTitle(getActivity()
.getString(R.string.failed_sdcard_export));
builder.setMessage(getActivity()
.getString(R.string.error_sdcard_export));
builder.setPositiveButton(android.R.string.ok, null);
builder.show();
}
}
}.execute();
}
private boolean isSyncMode() {
return NotesPreferenceActivity.getSyncAccountName(getActivity()).trim().length() > 0;
}
private void startPreferenceActivity() {
Intent intent = new Intent(getActivity(), NotesPreferenceActivity.class);
startActivity(intent);
}
private class OnListItemClickListener implements AdapterView.OnItemClickListener {
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
if (view instanceof NotesListItem) {
NoteItemData item = ((NotesListItem) view).getItemData();
if (mNotesListAdapter.isInChoiceMode()) {
if (item.getType() == Notes.TYPE_NOTE) {
position = position - mNotesListView.getHeaderViewsCount();
mModeCallBack.onItemCheckedStateChanged(null, position, id,
!mNotesListAdapter.isSelectedItem(position));
}
return;
}
switch (mState) {
case NOTE_LIST:
if (item.getType() == Notes.TYPE_FOLDER
|| item.getType() == Notes.TYPE_SYSTEM) {
openFolder(item);
} else if (item.getType() == Notes.TYPE_NOTE) {
openNode(item);
} else {
Log.e(TAG, "Wrong note type in NOTE_LIST");
}
break;
case SUB_FOLDER:
case CALL_RECORD_FOLDER:
if (item.getType() == Notes.TYPE_NOTE) {
openNode(item);
} else {
Log.e(TAG, "Wrong note type in SUB_FOLDER");
}
break;
default:
break;
}
}
}
}
private void startQueryDestinationFolders() {
String selection = NoteColumns.TYPE + "=? AND " + NoteColumns.PARENT_ID + "<>? AND " + NoteColumns.ID + "<>?";
selection = (mState == ListEditState.NOTE_LIST) ? selection:
"(" + selection + ") OR (" + NoteColumns.ID + "=" + Notes.ID_ROOT_FOLDER + ")";
mBackgroundQueryHandler.startQuery(FOLDER_LIST_QUERY_TOKEN,
null,
Notes.CONTENT_NOTE_URI,
FoldersListAdapter.PROJECTION,
selection,
new String[] {
String.valueOf(Notes.TYPE_FOLDER),
String.valueOf(Notes.ID_TRASH_FOLER),
String.valueOf(mCurrentFolderId)
},
NoteColumns.MODIFIED_DATE + " DESC");
}
public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
if (view instanceof NotesListItem) {
mFocusNoteDataItem = ((NotesListItem) view).getItemData();
if (mFocusNoteDataItem.getType() == Notes.TYPE_NOTE && !mNotesListAdapter.isInChoiceMode()) {
if (mNotesListView.startActionMode(mModeCallBack) != null) {
mModeCallBack.onItemCheckedStateChanged(null, position, id, true);
mNotesListView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
} else {
Log.e(TAG, "startActionMode fails");
}
} else if (mFocusNoteDataItem.getType() == Notes.TYPE_FOLDER) {
mNotesListView.setOnCreateContextMenuListener(mFolderOnCreateContextMenuListener);
}
}
return false;
}
}
Loading…
Cancel
Save