保持各成员分支的所有维护功能的代码一致 #7

Closed
phfuef3l9 wants to merge 7 commits from master into diwen_branch

24
src/.gitignore vendored

@ -1,9 +1,15 @@
# generated files
bin/
gen/
# Local configuration file (sdk path, etc)
project.properties
.settings/
.classpath
.project
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

@ -1,190 +0,0 @@
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.
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.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

@ -1,23 +0,0 @@
[中文]
1. MiCode便签是小米便签的社区开源版由MIUI团队(www.miui.com) 发起并贡献第一批代码遵循NOTICE文件所描述的开源协议
今后为MiCode社区(www.micode.net) 拥有,并由社区发布和维护。
2. Bug反馈和跟踪请访问Github,
https://github.com/MiCode/Notes/issues?sort=created&direction=desc&state=open
3. 功能建议和综合讨论请访问MiCode,
http://micode.net/forum.php?mod=forumdisplay&fid=38
[English]
1. MiCode Notes is open source edition of XM notepad, it's first initiated and sponsored by MIUI team (www.miui.com).
It's opened under license described by NOTICE file. It's owned by the MiCode community (www.micode.net). In future,
the MiCode community will release and maintain this project.
2. Regarding issue tracking, please visit Github,
https://github.com/MiCode/Notes/issues?sort=created&direction=desc&state=open
3. Regarding feature request and general discussion, please visit Micode forum,
http://micode.net/forum.php?mod=forumdisplay&fid=38

@ -0,0 +1 @@
/build

@ -0,0 +1,79 @@
plugins {
alias(libs.plugins.android.application)
}
android {
namespace = "net.micode.notes"
// 修改这里:匹配你下载的 SDK 30
compileSdk = 34
defaultConfig {
applicationId = "net.micode.notes"
minSdk = 30
// 修改这里:匹配你下载的 SDK 30
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
// === 这里是新增的代码,用来解决 Jar 包冲突报错 ===
packaging {
resources {
excludes += "META-INF/DEPENDENCIES"
excludes += "META-INF/NOTICE"
excludes += "META-INF/LICENSE"
excludes += "META-INF/LICENSE.txt"
excludes += "META-INF/NOTICE.txt"
}
}
// ==============================================
}
dependencies {
// === 修改部分:使用兼容 SDK 30 的旧版本库 ===
// 替换掉了原本的 libs.appcompat 等引用,改为写死的旧版本号
// ViewPager2 用于左右滑动
implementation("androidx.recyclerview:recyclerview:1.3.0")
implementation("androidx.viewpager2:viewpager2:1.0.0")
// Material Design 用于底部导航栏
implementation("com.google.android.material:material:1.9.0")
// Fragment 核心库 (确保使用 AndroidX 版本)
implementation("androidx.fragment:fragment:1.5.7")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.appcompat:appcompat:1.3.1")
implementation("com.google.android.material:material:1.4.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.drawerlayout:drawerlayout:1.2.0")
implementation("androidx.coordinatorlayout:coordinatorlayout:1.2.0")
// 如果你需要 Activity 的新特性,也需要指定旧版本,否则默认会拉取最新的导致报错
implementation("androidx.activity:activity:1.2.4")
// Google Play Services - 用于地理围栏
implementation("com.google.android.gms:play-services-location:21.0.1")
// === 保持不变:你的 Jar 包路径 ===
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
// === 测试库也建议指定旧版本,防止冲突 ===
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.3")
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
}

Binary file not shown.

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

@ -0,0 +1,26 @@
package net.micode.notes;
import android.content.Context;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.*;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("net.micode.notes", appContext.getPackageName());
}
}

@ -2,6 +2,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<!-- 悬浮窗权限 -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- 前台服务权限 (Android 9+) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- 允许查询窗口状态 (可选,为了更好适配) -->
<uses-permission android:name="android.permission.GET_TASKS"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
@ -10,12 +18,16 @@
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
<uses-permission android:name="android.permission.USE_CREDENTIALS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- 原有的权限可能已经存在,请确保补充以下权限 -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- 针对 Android 13+ (API 33+) 的图片读取权限 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- 通知权限:便签时间提醒需在设定时间弹出通知 (Android 13+ 需在应用信息中开启) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- 声明相机硬件特性(非强制,但推荐) -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
@ -26,17 +38,24 @@
android:theme="@style/NoteTheme"
tools:targetApi="31">
<!-- 新的入口 -->
<!-- 开场动画:作为启动入口 -->
<activity
android:name=".ui.MainActivity"
android:name=".ui.SplashActivity"
android:exported="true"
android:theme="@style/Theme.AppCompat.Light.NoActionBar"> <!-- 注意 Theme -->
android:theme="@style/SplashTheme"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- 主界面:由 SplashActivity 跳转 -->
<activity
android:name=".ui.MainActivity"
android:exported="false"
android:theme="@style/Theme.AppCompat.Light.NoActionBar" />
<!-- 旧的 Activity保留声明去掉 intent-filter -->
<activity
android:name=".ui.NotesListActivity"
@ -88,7 +107,11 @@
android:resource="@xml/searchable" />
</activity>
<service
android:name=".tool.FloatingService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<provider
android:name="net.micode.notes.data.NotesProvider"
android:authorities="micode_notes"
@ -135,8 +158,10 @@
<receiver
android:name="net.micode.notes.ui.AlarmReceiver"
android:process=":remote" >
</receiver>
android:process=":remote" />
<receiver
android:name=".ui.NoteReminderReceiver"
android:exported="false" />
<activity
android:name=".ui.AlarmAlertActivity"
@ -145,6 +170,19 @@
android:theme="@android:style/Theme.Holo.Wallpaper.NoTitleBar" >
</activity>
<activity
android:name=".ui.TodoEditActivity"
android:exported="false"
android:theme="@style/TodoEditTheme" />
<receiver
android:name=".todo.TodoAlarmReceiver"
android:exported="false" />
<receiver
android:name=".todo.TodoGeofenceReceiver"
android:exported="false" />
<activity
android:name="net.micode.notes.ui.NotesPreferenceActivity"
android:label="@string/preferences_title"

@ -0,0 +1,279 @@
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) {
// 读取返回的数据(使用 try-with-resources 确保 br 关闭,符合 G.PRM.07
StringBuilder response = new StringBuilder();
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), "utf-8"))) {
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) {
StringBuilder response = new StringBuilder();
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), "utf-8"))) {
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) {
StringBuilder response = new StringBuilder();
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), "utf-8"))) {
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,65 @@
package net.micode.notes.ai;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Locale;
/**
* AI
* <p>
* DeepSeek
* <ul>
* <li> AI 便</li>
* <li> (RAG)便便</li>
* </ul>
* </p>
*
* @see net.micode.notes.ai.NoteRetriever
* @see net.micode.notes.ai.AIService
*/
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();
}
}

@ -25,6 +25,15 @@ import android.util.Log;
import java.util.HashMap;
/**
*
* <p>
*
* 便/使
* </p>
*
* @see net.micode.notes.data.Notes
*/
public class Contact {
private static HashMap<String, String> sContactCache;
private static final String TAG = "Contact";

@ -34,7 +34,10 @@ public class Notes {
public static final int ID_TEMPARAY_FOLDER = -1;
public static final int ID_CALL_RECORD_FOLDER = -2;
public static final int ID_TRASH_FOLER = -3;
// [新增] 灵感箱文件夹 ID
public static final int ID_INSPIRATION_FOLDER = -4;
// [新增] 隐私空间文件夹 ID存放私密便签仅在输入「隐私空间」后可见
public static final int ID_PRIVACY_FOLDER = -5;
public static final String INTENT_EXTRA_ALERT_DATE = "net.micode.notes.alert_date";
public static final String INTENT_EXTRA_BACKGROUND_ID = "net.micode.notes.background_color_id";
public static final String INTENT_EXTRA_WIDGET_ID = "net.micode.notes.widget_id";
@ -165,6 +168,12 @@ public class Notes {
* <P> Type : INTEGER (long) </P>
*/
public static final String VERSION = "version";
/**
* Whether the note or folder is pinned to top (1) or not (0).
* <P> Type : INTEGER </P>
*/
public static final String PINNED = "pinned";
}
public interface DataColumns {

@ -30,12 +30,12 @@ import net.micode.notes.data.Notes.NoteColumns;
public class NotesDatabaseHelper extends SQLiteOpenHelper {
private static final String DB_NAME = "note.db";
private static final int DB_VERSION = 4;
private static final int DB_VERSION = 7;
public interface TABLE {
public static final String NOTE = "note";
public static final String DATA = "data";
public static final String TODO_ITEMS = "todo_items";
}
private static final String TAG = "NotesDatabaseHelper";
@ -60,7 +60,8 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper {
NoteColumns.LOCAL_MODIFIED + " INTEGER NOT NULL DEFAULT 0," +
NoteColumns.ORIGIN_PARENT_ID + " INTEGER NOT NULL DEFAULT 0," +
NoteColumns.GTASK_ID + " TEXT NOT NULL DEFAULT ''," +
NoteColumns.VERSION + " INTEGER NOT NULL DEFAULT 0" +
NoteColumns.VERSION + " INTEGER NOT NULL DEFAULT 0," +
NoteColumns.PINNED + " INTEGER NOT NULL DEFAULT 0" +
")";
private static final String CREATE_DATA_TABLE_SQL =
@ -82,6 +83,19 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper {
"CREATE INDEX IF NOT EXISTS note_id_index ON " +
TABLE.DATA + "(" + DataColumns.NOTE_ID + ");";
private static final String CREATE_TODO_ITEMS_TABLE_SQL =
"CREATE TABLE " + TABLE.TODO_ITEMS + "(" +
"id INTEGER PRIMARY KEY AUTOINCREMENT," +
"content TEXT NOT NULL DEFAULT ''," +
"is_done INTEGER NOT NULL DEFAULT 0," +
"reminder_type INTEGER NOT NULL DEFAULT 0," +
"reminder_timestamp INTEGER NOT NULL DEFAULT 0," +
"latitude REAL NOT NULL DEFAULT 0," +
"longitude REAL NOT NULL DEFAULT 0," +
"location_name TEXT NOT NULL DEFAULT ''," +
"created_time INTEGER NOT NULL DEFAULT 0" +
")";
/**
* Increase folder's note count when move note to the folder
*/
@ -268,6 +282,14 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper {
values.put(NoteColumns.ID, Notes.ID_TRASH_FOLER);
values.put(NoteColumns.TYPE, Notes.TYPE_SYSTEM);
db.insert(TABLE.NOTE, null, values);
/**
* create privacy space folder (hidden, only visible when user enters via Mi Steward)
*/
values.clear();
values.put(NoteColumns.ID, Notes.ID_PRIVACY_FOLDER);
values.put(NoteColumns.TYPE, Notes.TYPE_SYSTEM);
db.insert(TABLE.NOTE, null, values);
}
public void createDataTable(SQLiteDatabase db) {
@ -298,6 +320,7 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper {
public void onCreate(SQLiteDatabase db) {
createNoteTable(db);
createDataTable(db);
createTodoTable(db);
}
@Override
@ -322,6 +345,21 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper {
oldVersion++;
}
if (oldVersion == 4) {
upgradeToV5(db);
oldVersion++;
}
if (oldVersion == 5) {
upgradeToV6(db);
oldVersion++;
}
if (oldVersion == 6) {
upgradeToV7(db);
oldVersion++;
}
if (reCreateTriggers) {
reCreateNoteTableTriggers(db);
reCreateDataTableTriggers(db);
@ -359,4 +397,26 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper {
db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.VERSION
+ " INTEGER NOT NULL DEFAULT 0");
}
private void upgradeToV5(SQLiteDatabase db) {
db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.PINNED
+ " INTEGER NOT NULL DEFAULT 0");
}
private void upgradeToV6(SQLiteDatabase db) {
ContentValues values = new ContentValues();
values.put(NoteColumns.ID, Notes.ID_PRIVACY_FOLDER);
values.put(NoteColumns.TYPE, Notes.TYPE_SYSTEM);
db.insert(TABLE.NOTE, null, values);
}
private void upgradeToV7(SQLiteDatabase db) {
db.execSQL(CREATE_TODO_ITEMS_TABLE_SQL);
Log.d(TAG, "todo_items table has been created");
}
public void createTodoTable(SQLiteDatabase db) {
db.execSQL(CREATE_TODO_ITEMS_TABLE_SQL);
Log.d(TAG, "todo_items table has been created");
}
}

@ -0,0 +1,153 @@
package net.micode.notes.data;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.util.Log;
import net.micode.notes.data.NotesDatabaseHelper.TABLE;
import net.micode.notes.todo.TodoItem;
import java.util.ArrayList;
import java.util.List;
/**
* 访 (CRUD)
*/
public class TodoDao {
private static final String TAG = "TodoDao";
private static final String COL_ID = "id";
private static final String COL_CONTENT = "content";
private static final String COL_IS_DONE = "is_done";
private static final String COL_REMINDER_TYPE = "reminder_type";
private static final String COL_REMINDER_TIMESTAMP = "reminder_timestamp";
private static final String COL_LATITUDE = "latitude";
private static final String COL_LONGITUDE = "longitude";
private static final String COL_LOCATION_NAME = "location_name";
private static final String COL_CREATED_TIME = "created_time";
private final NotesDatabaseHelper mHelper;
public TodoDao(Context context) {
mHelper = NotesDatabaseHelper.getInstance(context);
}
public long insert(TodoItem item) {
SQLiteDatabase db = mHelper.getWritableDatabase();
ContentValues cv = toContentValues(item);
long id = db.insert(TABLE.TODO_ITEMS, null, cv);
Log.d(TAG, "insert todo id=" + id);
return id;
}
public int update(TodoItem item) {
if (item.getId() <= 0) return 0;
SQLiteDatabase db = mHelper.getWritableDatabase();
ContentValues cv = toContentValues(item);
return db.update(TABLE.TODO_ITEMS, cv, COL_ID + "=?", new String[]{String.valueOf(item.getId())});
}
public int delete(long id) {
SQLiteDatabase db = mHelper.getWritableDatabase();
return db.delete(TABLE.TODO_ITEMS, COL_ID + "=?", new String[]{String.valueOf(id)});
}
public int deleteByIds(List<Long> ids) {
if (ids == null || ids.isEmpty()) return 0;
SQLiteDatabase db = mHelper.getWritableDatabase();
StringBuilder sb = new StringBuilder(COL_ID).append(" IN (");
for (int i = 0; i < ids.size(); i++) {
sb.append(i > 0 ? "," : "").append(ids.get(i));
}
sb.append(")");
return db.delete(TABLE.TODO_ITEMS, sb.toString(), null);
}
public TodoItem getById(long id) {
SQLiteDatabase db = mHelper.getReadableDatabase();
try (Cursor c = db.query(TABLE.TODO_ITEMS, null, COL_ID + "=?", new String[]{String.valueOf(id)}, null, null, null)) {
if (c != null && c.moveToFirst()) {
return fromCursor(c);
}
}
return null;
}
public List<TodoItem> getAll() {
List<TodoItem> list = new ArrayList<>();
SQLiteDatabase db = mHelper.getReadableDatabase();
try (Cursor c = db.query(TABLE.TODO_ITEMS, null, null, null, null, null, COL_IS_DONE + " ASC, " + COL_CREATED_TIME + " DESC")) {
if (c != null) {
while (c.moveToNext()) {
list.add(fromCursor(c));
}
}
}
return list;
}
public List<TodoItem> getUndone() {
List<TodoItem> list = new ArrayList<>();
SQLiteDatabase db = mHelper.getReadableDatabase();
try (Cursor c = db.query(TABLE.TODO_ITEMS, null, COL_IS_DONE + "=0", null, null, null, COL_CREATED_TIME + " DESC")) {
if (c != null) {
while (c.moveToNext()) {
list.add(fromCursor(c));
}
}
}
return list;
}
public List<TodoItem> getDone() {
List<TodoItem> list = new ArrayList<>();
SQLiteDatabase db = mHelper.getReadableDatabase();
try (Cursor c = db.query(TABLE.TODO_ITEMS, null, COL_IS_DONE + "=1", null, null, null, COL_CREATED_TIME + " DESC")) {
if (c != null) {
while (c.moveToNext()) {
list.add(fromCursor(c));
}
}
}
return list;
}
public int getUndoneCount() {
SQLiteDatabase db = mHelper.getReadableDatabase();
try (Cursor c = db.rawQuery("SELECT COUNT(*) FROM " + TABLE.TODO_ITEMS + " WHERE " + COL_IS_DONE + "=0", null)) {
if (c != null && c.moveToFirst()) {
return c.getInt(0);
}
}
return 0;
}
private ContentValues toContentValues(TodoItem item) {
ContentValues cv = new ContentValues();
cv.put(COL_CONTENT, item.getContent());
cv.put(COL_IS_DONE, item.isDone() ? 1 : 0);
cv.put(COL_REMINDER_TYPE, item.getReminderType());
cv.put(COL_REMINDER_TIMESTAMP, item.getReminderTimestamp());
cv.put(COL_LATITUDE, item.getLatitude());
cv.put(COL_LONGITUDE, item.getLongitude());
cv.put(COL_LOCATION_NAME, item.getLocationName());
cv.put(COL_CREATED_TIME, item.getCreatedTime());
return cv;
}
private TodoItem fromCursor(Cursor c) {
TodoItem item = new TodoItem();
item.setId(c.getLong(c.getColumnIndexOrThrow(COL_ID)));
item.setContent(c.getString(c.getColumnIndexOrThrow(COL_CONTENT)));
item.setDone(c.getInt(c.getColumnIndexOrThrow(COL_IS_DONE)) == 1);
item.setReminderType(c.getInt(c.getColumnIndexOrThrow(COL_REMINDER_TYPE)));
item.setReminderTimestamp(c.getLong(c.getColumnIndexOrThrow(COL_REMINDER_TIMESTAMP)));
item.setLatitude(c.getDouble(c.getColumnIndexOrThrow(COL_LATITUDE)));
item.setLongitude(c.getDouble(c.getColumnIndexOrThrow(COL_LONGITUDE)));
item.setLocationName(c.getString(c.getColumnIndexOrThrow(COL_LOCATION_NAME)));
item.setCreatedTime(c.getLong(c.getColumnIndexOrThrow(COL_CREATED_TIME)));
return item;
}
}

@ -24,7 +24,16 @@ import net.micode.notes.tool.GTaskStringUtils;
import org.json.JSONException;
import org.json.JSONObject;
/**
* GTask
* <p>
* {@link Task} GTask gtask id
*
* </p>
*
* @see Task
* @see GTaskStringUtils
*/
public class MetaData extends Task {
private final static String TAG = MetaData.class.getSimpleName();

@ -20,6 +20,17 @@ import android.database.Cursor;
import org.json.JSONObject;
/**
* Google Task
* <p>
* GTask /gid
* {@link TaskList} {@link Task} JSON
* </p>
*
* @see TaskList
* @see Task
* @see MetaData
*/
public abstract class Node {
public static final int SYNC_ACTION_NONE = 0;

@ -34,7 +34,17 @@ import net.micode.notes.gtask.exception.ActionFailureException;
import org.json.JSONException;
import org.json.JSONObject;
/**
* 便 GTask
* <p>
* 便 Data GTask notes
* {@link SqlNote} 便 GTask
* </p>
*
* @see SqlNote
* @see Task
* @see Notes.DataColumns
*/
public class SqlData {
private static final String TAG = SqlData.class.getSimpleName();

@ -37,7 +37,17 @@ import org.json.JSONObject;
import java.util.ArrayList;
/**
* 便 GTask
* <p>
* 便Notes ContentProvider GTask {@link Task}
* Note CRUDJSON {@link GTaskManager}
* </p>
*
* @see Task
* @see SqlData
* @see GTaskManager
*/
public class SqlNote {
private static final String TAG = SqlNote.class.getSimpleName();

@ -31,7 +31,17 @@ import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
/**
* Google Task
* <p>
* GTask 便 sync便
* JSON
* </p>
*
* @see Node
* @see TaskList
* @see SqlNote
*/
public class Task extends Node {
private static final String TAG = Task.class.getSimpleName();

@ -29,7 +29,17 @@ import org.json.JSONObject;
import java.util.ArrayList;
/**
* Google Task
* <p>
* GTask {@link Task}
* GTask API JSON 便
* </p>
*
* @see Node
* @see Task
* @see GTaskManager
*/
public class TaskList extends Node {
private static final String TAG = TaskList.class.getSimpleName();

@ -16,6 +16,15 @@
package net.micode.notes.gtask.exception;
/**
* GTask
* <p>
* GTask API
* RuntimeException
* </p>
*
* @see NetworkFailureException
*/
public class ActionFailureException extends RuntimeException {
private static final long serialVersionUID = 4425249765923293627L;

@ -16,6 +16,15 @@
package net.micode.notes.gtask.exception;
/**
* GTask
* <p>
* GTask
* Exception
* </p>
*
* @see ActionFailureException
*/
public class NetworkFailureException extends Exception {
private static final long serialVersionUID = 2107610287180234136L;

@ -28,7 +28,16 @@ import net.micode.notes.R;
import net.micode.notes.ui.NotesListActivity;
import net.micode.notes.ui.NotesPreferenceActivity;
/**
* GTask
* <p>
* 便 Google Task
* OnCompleteListener
* </p>
*
* @see GTaskManager
* @see GTaskSyncService
*/
public class GTaskASyncTask extends AsyncTask<Void, String, Integer> {
private static int GTASK_SYNC_NOTIFICATION_ID = 5234235;

@ -60,7 +60,17 @@ import java.util.zip.GZIPInputStream;
import java.util.zip.Inflater;
import java.util.zip.InflaterInputStream;
/**
* Google Task API
* <p>
* Google Task HTTP
* 使 Apache HttpClient AccountManager Google
* </p>
*
* @see GTaskManager
* @see TaskList
* @see Task
*/
public class GTaskClient {
private static final String TAG = GTaskClient.class.getSimpleName();
@ -306,9 +316,11 @@ public class GTaskClient {
input = new InflaterInputStream(entity.getContent(), inflater);
}
InputStreamReader isr = null;
BufferedReader br = null;
try {
InputStreamReader isr = new InputStreamReader(input);
BufferedReader br = new BufferedReader(isr);
isr = new InputStreamReader(input);
br = new BufferedReader(isr);
StringBuilder sb = new StringBuilder();
while (true) {
@ -319,7 +331,27 @@ public class GTaskClient {
sb = sb.append(buff);
}
} finally {
input.close();
if (br != null) {
try {
br.close();
} catch (IOException e) {
Log.e(TAG, "close BufferedReader", e);
}
}
if (isr != null) {
try {
isr.close();
} catch (IOException e) {
Log.e(TAG, "close InputStreamReader", e);
}
}
if (input != null) {
try {
input.close();
} catch (IOException e) {
Log.e(TAG, "close InputStream", e);
}
}
}
}

@ -47,7 +47,18 @@ import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
/**
* Google Task
* <p>
* 便 Google Task 便广
* {@link GTaskSyncService} {@link GTaskASyncTask}
* </p>
*
* @see GTaskClient
* @see GTaskASyncTask
* @see SqlNote
* @see SqlData
*/
public class GTaskManager {
private static final String TAG = GTaskManager.class.getSimpleName();

@ -23,6 +23,16 @@ import android.content.Intent;
import android.os.Bundle;
import android.os.IBinder;
/**
* GTask
* <p>
* Service/ Intent {@link GTaskASyncTask}
* 广 UI
* </p>
*
* @see GTaskASyncTask
* @see GTaskManager
*/
public class GTaskSyncService extends Service {
public final static String ACTION_STRING_NAME = "sync_action_type";

@ -0,0 +1,43 @@
package net.micode.notes.model;
import java.util.List;
/**
*
* <p>
* Mi AI AI
* AI
* </p>
*
* @see net.micode.notes.ui.ChatAdapter
*/
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; }
}

@ -108,7 +108,12 @@ public class Note {
mNoteDiffValues.put(NoteColumns.MODIFIED_DATE, System.currentTimeMillis());
}
// 委托方法:将文本数据的设置操作转发给内部类处理
/**
* NoteData
*
* @param key DataColumns.CONTENTTextNote.MODE
* @param value
*/
public void setTextData(String key, String value) {
mNoteData.setTextData(key, value);
}
@ -118,11 +123,20 @@ public class Note {
mNoteData.setTextDataId(id);
}
/**
* Data ID
*
* @return ID0
*/
public long getTextDataId() {
return mNoteData.mTextDataId;
}
// 委托方法:设置关联的通话记录数据行 ID
/**
* ID
*
* @param id Data ID
*/
public void setCallDataId(long id) {
mNoteData.setCallDataId(id);
}

@ -18,12 +18,14 @@ package net.micode.notes.model;
import android.appwidget.AppWidgetManager;
import android.content.ContentUris;
import android.content.ContentValues;
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.tool.DataUtils;
import net.micode.notes.data.Notes.CallNote;
import net.micode.notes.data.Notes.DataColumns;
import net.micode.notes.data.Notes.DataConstants;
@ -222,6 +224,7 @@ public class WorkingNote {
// [新代码开始] 使用 ThreadExecutor 在后台线程执行保存,防止卡顿
// =================================================================================
final Context context = mContext;
final String contentForSnippet = mContent != null ? mContent : "";
// 提交异步任务
ThreadExecutor.getInstance().execute(new Runnable() {
@ -245,7 +248,14 @@ public class WorkingNote {
// mNote.syncNote 会将内存中的 Diff 写入数据库
mNote.syncNote(context, mNoteId);
// 3. 更新桌面小部件 (如果关联了 Widget)
// 3. 标题snippet写回以第一行/第三行/无标题/图片便签 规则覆盖触发器写入的全文
String snippet = DataUtils.computeNoteSnippet(contentForSnippet);
ContentValues cv = new ContentValues();
cv.put(NoteColumns.SNIPPET, snippet);
context.getContentResolver().update(
ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, mNoteId), cv, null, null);
// 4. 更新桌面小部件 (如果关联了 Widget)
if (mWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID
&& mWidgetType != Notes.TYPE_WIDGET_INVALIDE
&& mNoteSettingStatusListener != null) {
@ -313,6 +323,11 @@ public class WorkingNote {
}
}
/**
* Widget UI
*
* @param l null
*/
public void setOnSettingStatusChangedListener(NoteSettingChangedListener l) {
mNoteSettingStatusListener = l;
}
@ -328,6 +343,11 @@ public class WorkingNote {
}
}
/**
* 便 Widget Widget
*
* @param mark true
*/
public void markDeleted(boolean mark) {
mIsDeleted = mark;
if (mWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID
@ -358,6 +378,11 @@ public class WorkingNote {
}
}
/**
* 2x 4x
*
* @param type Notes.TYPE_WIDGET_2X Notes.TYPE_WIDGET_4X
*/
public void setWidgetType(int type) {
if (type != mWidgetType) {
mWidgetType = type;
@ -365,6 +390,11 @@ public class WorkingNote {
}
}
/**
* ID
*
* @param id AppWidgetManager ID
*/
public void setWidgetId(int id) {
if (id != mWidgetId) {
mWidgetId = id;
@ -392,6 +422,11 @@ public class WorkingNote {
mNote.setNoteValue(NoteColumns.PARENT_ID, String.valueOf(Notes.ID_CALL_RECORD_FOLDER));
}
/**
*
*
* @return true
*/
public boolean hasClockAlert() {
return (mAlertDate > 0 ? true : false);
}

@ -0,0 +1,130 @@
package net.micode.notes.todo;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.AccelerateInterpolator;
import java.util.ArrayList;
import java.util.Random;
/**
* View Canvas
*/
public class ConfettiView extends View {
/** 粒子数量,铺满整屏 */
private static final int PARTICLE_COUNT = 260;
private static final long DURATION_MS = 2800;
private final ArrayList<Particle> mParticles = new ArrayList<>();
private final Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Random mRandom = new Random();
private long mStartTime;
private static class Particle {
float x, y;
float vx, vy;
float size;
int color;
float rotation;
float rotationSpeed;
}
public ConfettiView(Context context) {
super(context);
init();
}
public ConfettiView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
private void init() {
mPaint.setStyle(Paint.Style.FILL);
}
public void start() {
mParticles.clear();
setVisibility(VISIBLE);
// 先设为 VISIBLE 再 post确保布局后 getWidth/getHeight 有值GONE 时宽高为 0 会直接 return
post(this::initParticlesIfSized);
}
private void initParticlesIfSized() {
initParticlesIfSized(0);
}
private static final int MAX_LAYOUT_RETRY = 3;
private void initParticlesIfSized(int retryCount) {
int w = getWidth();
int h = getHeight();
if (w <= 0 || h <= 0) {
if (retryCount < MAX_LAYOUT_RETRY) {
requestLayout();
post(() -> initParticlesIfSized(retryCount + 1));
}
return;
}
int[] colors = {
Color.parseColor("#FF6B6B"),
Color.parseColor("#4ECDC4"),
Color.parseColor("#FFE66D"),
Color.parseColor("#95E1D3"),
Color.parseColor("#F38181"),
Color.parseColor("#AA96DA"),
Color.parseColor("#FCBAD3"),
Color.parseColor("#A8D8EA"),
};
for (int i = 0; i < PARTICLE_COUNT; i++) {
Particle p = new Particle();
p.x = (mRandom.nextFloat() - 0.1f) * (w * 1.2f);
p.y = -mRandom.nextFloat() * h * 0.6f;
p.vx = (mRandom.nextFloat() - 0.5f) * 10;
p.vy = 5 + mRandom.nextFloat() * 14;
p.size = 8 + mRandom.nextFloat() * 12;
p.color = colors[mRandom.nextInt(colors.length)];
p.rotation = mRandom.nextFloat() * 360;
p.rotationSpeed = (mRandom.nextFloat() - 0.5f) * 24;
mParticles.add(p);
}
mStartTime = System.currentTimeMillis();
invalidate();
}
@Override
protected void onDraw(Canvas canvas) {
if (mParticles.isEmpty()) return;
long elapsed = System.currentTimeMillis() - mStartTime;
if (elapsed >= DURATION_MS) {
setVisibility(GONE);
return;
}
float progress = (float) elapsed / DURATION_MS;
AccelerateInterpolator interpolator = new AccelerateInterpolator();
float alpha = 1f - interpolator.getInterpolation(progress);
for (Particle p : mParticles) {
p.x += p.vx;
p.y += p.vy;
p.vy += 0.3f;
p.rotation += p.rotationSpeed;
mPaint.setColor(p.color);
mPaint.setAlpha((int) (255 * alpha));
canvas.save();
canvas.translate(p.x, p.y);
canvas.rotate(p.rotation);
canvas.drawRect(-p.size / 2, -p.size / 4, p.size / 2, p.size / 4, mPaint);
canvas.restore();
}
invalidate();
}
}

@ -0,0 +1,251 @@
package net.micode.notes.todo;
import android.graphics.Paint;
import android.view.LayoutInflater;
import androidx.core.content.ContextCompat;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.CompoundButton;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import net.micode.notes.R;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
*
* <p>
* RecyclerView Adapter
* 线 OnItemClickListener OnItemLongClickListener
* </p>
*
* @see TodoFragment
* @see TodoItem
*/
public class TodoAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private static final int VIEW_TYPE_SECTION = 0;
private static final int VIEW_TYPE_ITEM = 1;
private final List<Object> mItems = new ArrayList<>();
private final Set<Integer> mSelectedPositions = new HashSet<>();
private boolean mChoiceMode;
private OnItemClickListener mItemClickListener;
private OnItemLongClickListener mItemLongClickListener;
public interface OnItemClickListener {
void onItemClick(TodoItem item, int adapterPosition);
void onCheckChanged(TodoItem item, boolean checked);
}
public interface OnItemLongClickListener {
void onItemLongClick(TodoItem item, int adapterPosition);
}
/**
*
*
* @param undone
* @param done
*/
public void setData(List<TodoItem> undone, List<TodoItem> done) {
mItems.clear();
if (!undone.isEmpty()) {
mItems.add("undone");
mItems.addAll(undone);
}
if (!done.isEmpty()) {
mItems.add("done");
mItems.addAll(done);
}
notifyDataSetChanged();
}
/** 设置 item 点击与勾选变更监听器 */
public void setOnItemClickListener(OnItemClickListener l) { mItemClickListener = l; }
/** 设置 item 长按监听器 */
public void setOnItemLongClickListener(OnItemLongClickListener l) { mItemLongClickListener = l; }
/**
*
*
* @param mode true
*/
public void setChoiceMode(boolean mode) {
mChoiceMode = mode;
mSelectedPositions.clear();
notifyDataSetChanged();
}
/** 判断是否处于多选模式 */
public boolean isChoiceMode() { return mChoiceMode; }
/**
*
*
* @param position
* @param checked true
*/
public void setChecked(int position, boolean checked) {
if (checked) mSelectedPositions.add(position);
else mSelectedPositions.remove(position);
notifyItemChanged(position);
}
/** 判断指定位置是否被选中 */
public boolean isChecked(int position) { return mSelectedPositions.contains(position); }
/** 获取当前选中的待办数量 */
public int getSelectedCount() { return mSelectedPositions.size(); }
/**
* ID
*/
public List<Long> getSelectedIds() {
List<Long> ids = new ArrayList<>();
for (Integer pos : mSelectedPositions) {
Object o = mItems.get(pos);
if (o instanceof TodoItem) ids.add(((TodoItem) o).getId());
}
return ids;
}
/**
* TodoItem
*
* @param select true false
*/
public void selectAll(boolean select) {
mSelectedPositions.clear();
if (select) {
for (int i = 0; i < mItems.size(); i++) {
if (mItems.get(i) instanceof TodoItem) mSelectedPositions.add(i);
}
}
notifyDataSetChanged();
}
/**
*
*/
public boolean isAllSelected() {
int itemCount = 0;
for (Object o : mItems) if (o instanceof TodoItem) itemCount++;
return itemCount > 0 && mSelectedPositions.size() == itemCount;
}
private boolean isSection(int position) {
return mItems.get(position) instanceof String;
}
private TodoItem getItemAt(int position) {
Object o = mItems.get(position);
return o instanceof TodoItem ? (TodoItem) o : null;
}
@Override
public int getItemViewType(int position) {
return isSection(position) ? VIEW_TYPE_SECTION : VIEW_TYPE_ITEM;
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
if (viewType == VIEW_TYPE_SECTION) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_todo_section, parent, false);
return new SectionHolder(v);
} else {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_todo, parent, false);
return new ItemHolder(v);
}
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
if (holder instanceof SectionHolder) {
String section = (String) mItems.get(position);
((SectionHolder) holder).bind(section.equals("undone")
? holder.itemView.getContext().getString(R.string.todo_section_undone)
: holder.itemView.getContext().getString(R.string.todo_section_done));
} else if (holder instanceof ItemHolder) {
TodoItem item = getItemAt(position);
if (item != null) {
((ItemHolder) holder).bind(item, mChoiceMode, mSelectedPositions.contains(position));
}
}
}
@Override
public int getItemCount() { return mItems.size(); }
private class SectionHolder extends RecyclerView.ViewHolder {
TextView tvTitle;
SectionHolder(View itemView) {
super(itemView);
tvTitle = itemView.findViewById(R.id.tv_section_title);
}
void bind(String title) {
tvTitle.setText(title);
}
}
private class ItemHolder extends RecyclerView.ViewHolder {
CheckBox cbTodo;
TextView tvContent;
ItemHolder(View itemView) {
super(itemView);
cbTodo = itemView.findViewById(R.id.cb_todo);
tvContent = itemView.findViewById(R.id.tv_todo_content);
}
void bind(TodoItem item, boolean choiceMode, boolean checked) {
// 先移除监听器,避免 setChecked 触发复用前的旧回调导致错更新或闪退
cbTodo.setOnCheckedChangeListener(null);
cbTodo.setChecked(item.isDone());
tvContent.setText(item.getContent());
if (item.isDone()) {
tvContent.setPaintFlags(tvContent.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG);
tvContent.setTextColor(ContextCompat.getColor(itemView.getContext(), android.R.color.darker_gray));
} else {
tvContent.setPaintFlags(tvContent.getPaintFlags() & ~Paint.STRIKE_THRU_TEXT_FLAG);
tvContent.setTextColor(ContextCompat.getColor(itemView.getContext(), R.color.text_primary));
}
cbTodo.setVisibility(View.VISIBLE);
if (choiceMode) {
cbTodo.setChecked(checked);
cbTodo.setOnCheckedChangeListener((b, isChecked) -> setChecked(getAdapterPosition(), isChecked));
} else {
cbTodo.setOnCheckedChangeListener((b, isChecked) -> {
if (mItemClickListener != null) mItemClickListener.onCheckChanged(item, isChecked);
});
}
itemView.setOnClickListener(v -> {
if (choiceMode) {
setChecked(getAdapterPosition(), !mSelectedPositions.contains(getAdapterPosition()));
} else if (mItemClickListener != null) {
mItemClickListener.onItemClick(item, getAdapterPosition());
}
});
itemView.setOnLongClickListener(v -> {
if (mItemLongClickListener != null) {
mItemLongClickListener.onItemLongClick(item, getAdapterPosition());
}
return false;
});
}
}
}

@ -0,0 +1,61 @@
package net.micode.notes.todo;
/**
* 广
* <p>
* AlarmManager 广
* Android 8.0+
* </p>
*
* @see TodoReminderManager
*/
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import androidx.core.app.NotificationCompat;
import net.micode.notes.R;
import net.micode.notes.ui.MainActivity;
public class TodoAlarmReceiver extends BroadcastReceiver {
private static final String CHANNEL_ID = "todo_reminder";
@Override
public void onReceive(Context context, Intent intent) {
long todoId = intent.getLongExtra("todo_id", 0);
String content = intent.getStringExtra("todo_content");
if (content == null) content = "待办提醒";
createNotificationChannel(context);
Intent openIntent = new Intent(context, MainActivity.class);
openIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent pi = PendingIntent.getActivity(context, (int) todoId, openIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.icon_app)
.setContentTitle("待办提醒")
.setContentText(content)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pi)
.setAutoCancel(true);
NotificationManager nm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
if (nm != null) nm.notify((int) todoId, builder.build());
}
private void createNotificationChannel(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(CHANNEL_ID,
"待办提醒", NotificationManager.IMPORTANCE_DEFAULT);
NotificationManager nm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
if (nm != null) nm.createNotificationChannel(channel);
}
}
}

@ -0,0 +1,97 @@
package net.micode.notes.todo;
/**
* 广
* <p>
* Geofencing 广
* 使 Google Play Services Geofencing API
* </p>
*
* @see TodoReminderManager
* @see com.google.android.gms.location.GeofencingClient
*/
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import com.google.android.gms.location.Geofence;
import com.google.android.gms.location.GeofencingEvent;
import net.micode.notes.R;
import net.micode.notes.ui.MainActivity;
import java.util.List;
public class TodoGeofenceReceiver extends BroadcastReceiver {
private static final String TAG = "TodoGeofenceReceiver";
private static final String CHANNEL_ID = "todo_geofence";
@Override
public void onReceive(Context context, Intent intent) {
GeofencingEvent event = GeofencingEvent.fromIntent(intent);
if (event == null) return;
if (event.hasError()) {
Log.e(TAG, "Geofence error: " + event.getErrorCode());
return;
}
int transition = event.getGeofenceTransition();
if (transition != Geofence.GEOFENCE_TRANSITION_ENTER) return;
List<Geofence> triggering = event.getTriggeringGeofences();
if (triggering == null || triggering.isEmpty()) return;
for (Geofence g : triggering) {
String requestId = g.getRequestId();
if (requestId.startsWith("todo_")) {
long todoId = Long.parseLong(requestId.substring(5));
String content = intent.getStringExtra("todo_content");
String locationName = intent.getStringExtra("location_name");
if (content == null) {
net.micode.notes.data.TodoDao dao = new net.micode.notes.data.TodoDao(context);
net.micode.notes.todo.TodoItem item = dao.getById(todoId);
content = item != null ? item.getContent() : "待办";
if (item != null && locationName == null) locationName = item.getLocationName();
}
String title = "到达" + (locationName != null ? locationName : "目标地点");
showNotification(context, (int) todoId, title, content);
}
}
}
private void showNotification(Context context, int id, String title, String content) {
createNotificationChannel(context);
Intent openIntent = new Intent(context, MainActivity.class);
openIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent pi = PendingIntent.getActivity(context, id, openIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.icon_app)
.setContentTitle(title)
.setContentText(content)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pi)
.setAutoCancel(true);
NotificationManager nm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
if (nm != null) nm.notify(id, builder.build());
}
private void createNotificationChannel(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(CHANNEL_ID,
"待办地点提醒", NotificationManager.IMPORTANCE_DEFAULT);
NotificationManager nm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
if (nm != null) nm.createNotificationChannel(channel);
}
}
}

@ -0,0 +1,62 @@
package net.micode.notes.todo;
/**
*
*/
public class TodoItem {
/** 提醒类型:无 */
public static final int REMINDER_NONE = 0;
/** 提醒类型:时间 */
public static final int REMINDER_TIME = 1;
/** 提醒类型:地点 */
public static final int REMINDER_LOCATION = 2;
private long id;
private String content;
private boolean isDone;
private int reminderType;
private long reminderTimestamp;
private double latitude;
private double longitude;
private String locationName;
private long createdTime;
public TodoItem() {
this.id = 0;
this.content = "";
this.isDone = false;
this.reminderType = REMINDER_NONE;
this.reminderTimestamp = 0;
this.latitude = 0;
this.longitude = 0;
this.locationName = "";
this.createdTime = System.currentTimeMillis();
}
public long getId() { return id; }
public void setId(long id) { this.id = id; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content != null ? content : ""; }
public boolean isDone() { return isDone; }
public void setDone(boolean done) { isDone = done; }
public int getReminderType() { return reminderType; }
public void setReminderType(int reminderType) { this.reminderType = reminderType; }
public long getReminderTimestamp() { return reminderTimestamp; }
public void setReminderTimestamp(long reminderTimestamp) { this.reminderTimestamp = reminderTimestamp; }
public double getLatitude() { return latitude; }
public void setLatitude(double latitude) { this.latitude = latitude; }
public double getLongitude() { return longitude; }
public void setLongitude(double longitude) { this.longitude = longitude; }
public String getLocationName() { return locationName; }
public void setLocationName(String locationName) { this.locationName = locationName != null ? locationName : ""; }
public long getCreatedTime() { return createdTime; }
public void setCreatedTime(long createdTime) { this.createdTime = createdTime; }
}

@ -0,0 +1,121 @@
package net.micode.notes.todo;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.util.Log;
import com.google.android.gms.location.Geofence;
import com.google.android.gms.location.GeofencingClient;
import com.google.android.gms.location.GeofencingRequest;
import com.google.android.gms.location.LocationServices;
import net.micode.notes.data.TodoDao;
import java.util.ArrayList;
import java.util.List;
/**
* (AlarmManager) + (Geofencing)
*/
public class TodoReminderManager {
private static final String TAG = "TodoReminderManager";
private static final float GEOFENCE_RADIUS_METERS = 100f;
public static void scheduleReminders(Context context, TodoItem item) {
if (item == null || item.getId() <= 0) return;
cancelReminders(context, item.getId());
if (item.getReminderType() == TodoItem.REMINDER_TIME && item.getReminderTimestamp() > 0) {
scheduleTimeReminder(context, item);
} else if (item.getReminderType() == TodoItem.REMINDER_LOCATION
&& item.getLatitude() != 0 && item.getLongitude() != 0) {
scheduleGeofenceReminder(context, item);
}
}
public static void cancelReminders(Context context, long todoId) {
cancelTimeReminder(context, todoId);
cancelGeofenceReminder(context, todoId);
}
private static void scheduleTimeReminder(Context context, TodoItem item) {
AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
if (am == null) return;
if (item.getReminderTimestamp() <= System.currentTimeMillis()) return;
Intent intent = new Intent(context, TodoAlarmReceiver.class);
intent.putExtra("todo_id", item.getId());
intent.putExtra("todo_content", item.getContent());
PendingIntent pi = PendingIntent.getBroadcast(context, (int) item.getId(), intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (am.canScheduleExactAlarms()) {
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, item.getReminderTimestamp(), pi);
} else {
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, item.getReminderTimestamp(), pi);
}
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, item.getReminderTimestamp(), pi);
} else {
am.set(AlarmManager.RTC_WAKEUP, item.getReminderTimestamp(), pi);
}
Log.d(TAG, "Scheduled time reminder for todo " + item.getId());
}
private static void cancelTimeReminder(Context context, long todoId) {
AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
if (am == null) return;
Intent intent = new Intent(context, TodoAlarmReceiver.class);
PendingIntent pi = PendingIntent.getBroadcast(context, (int) todoId, intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
am.cancel(pi);
}
private static void scheduleGeofenceReminder(Context context, TodoItem item) {
GeofencingClient client = LocationServices.getGeofencingClient(context);
Geofence geofence = new Geofence.Builder()
.setRequestId("todo_" + item.getId())
.setCircularRegion(item.getLatitude(), item.getLongitude(), GEOFENCE_RADIUS_METERS)
.setExpirationDuration(Geofence.NEVER_EXPIRE)
.setTransitionTypes(Geofence.GEOFENCE_TRANSITION_ENTER)
.build();
List<Geofence> list = new ArrayList<>();
list.add(geofence);
GeofencingRequest request = new GeofencingRequest.Builder()
.setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER)
.addGeofences(list)
.build();
Intent intent = new Intent(context, TodoGeofenceReceiver.class);
intent.putExtra("todo_id", item.getId());
intent.putExtra("todo_content", item.getContent());
intent.putExtra("location_name", item.getLocationName());
PendingIntent pi = PendingIntent.getBroadcast(context, (int) item.getId(), intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
client.addGeofences(request, pi)
.addOnSuccessListener(aVoid -> Log.d(TAG, "Geofence added for todo " + item.getId()))
.addOnFailureListener(e -> Log.e(TAG, "Geofence add failed: " + e.getMessage()));
}
private static void cancelGeofenceReminder(Context context, long todoId) {
GeofencingClient client = LocationServices.getGeofencingClient(context);
List<String> ids = new ArrayList<>();
ids.add("todo_" + todoId);
client.removeGeofences(ids)
.addOnSuccessListener(aVoid -> Log.d(TAG, "Geofence removed for todo " + todoId))
.addOnFailureListener(e -> Log.e(TAG, "Geofence remove failed: " + e.getMessage()));
}
/** 在删除待办时调用,取消所有提醒 */
public static void cancelAllForTodo(Context context, long todoId) {
cancelReminders(context, todoId);
}
}

@ -327,18 +327,27 @@ public class BackupUtils {
}
mFileName = file.getName();
mFileDirectory = mContext.getString(R.string.file_path);
FileOutputStream fos = null;
PrintStream ps = null;
try {
FileOutputStream fos = new FileOutputStream(file);
fos = new FileOutputStream(file);
ps = new PrintStream(fos);
return ps;
} catch (FileNotFoundException e) {
e.printStackTrace();
return null;
} catch (NullPointerException e) {
e.printStackTrace();
return null;
} finally {
if (ps == null && fos != null) {
try {
fos.close();
} catch (IOException e) {
Log.e(TAG, "close FileOutputStream", e);
}
}
}
return ps;
}
}

@ -81,6 +81,40 @@ public class DataUtils {
return false;
}
/**
* 便/
*
* @param resolver ContentResolver
* @param ids 便 ID
* @param pinned true false
* @return
*/
public static boolean batchSetPinned(ContentResolver resolver, HashSet<Long> ids, boolean pinned) {
if (ids == null || ids.isEmpty()) {
return true;
}
int value = pinned ? 1 : 0;
ArrayList<ContentProviderOperation> operationList = new ArrayList<ContentProviderOperation>();
for (long id : ids) {
if (id <= 0) continue; // 跳过系统文件夹
ContentProviderOperation.Builder builder = ContentProviderOperation
.newUpdate(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, id));
builder.withValue(NoteColumns.PINNED, value);
builder.withValue(NoteColumns.LOCAL_MODIFIED, 1);
operationList.add(builder.build());
}
if (operationList.isEmpty()) return true;
try {
ContentProviderResult[] results = resolver.applyBatch(Notes.AUTHORITY, operationList);
return results != null && results.length > 0;
} catch (RemoteException e) {
Log.e(TAG, "batchSetPinned: " + e.getMessage());
} catch (OperationApplicationException e) {
Log.e(TAG, "batchSetPinned: " + e.getMessage());
}
return false;
}
/**
* 便
* ID (ORIGIN_PARENT_ID)便
@ -345,4 +379,44 @@ public class DataUtils {
}
return snippet;
}
/** 列表标题:无有效内容时显示 */
private static final String FALLBACK_UNTITLED = "无标题";
/** 仅图片无文字时的标题 */
private static final String FALLBACK_IMAGE_NOTE = "图片便签";
/** 新建便签模板占位符,用于判断是否取第三行 */
private static final String TEMPLATE_FIRST_LINE = "标题";
private static final String TEMPLATE_THIRD_LINE = "开始书写";
/**
* 便snippet
* 1 &lt;img src="..."/&gt; 234便
*/
public static String computeNoteSnippet(String content) {
if (content == null) return FALLBACK_UNTITLED;
String text = content.replaceAll("<img\\s+src=\"[^\"]*\"\\s*/>", "").trim();
if (text.isEmpty()) return FALLBACK_IMAGE_NOTE;
String[] lines = text.split("\n", -1);
String firstLine = lines.length > 0 ? lines[0].trim() : "";
if (TEMPLATE_FIRST_LINE.equals(firstLine)) {
String thirdLine = lines.length > 2 ? lines[2].trim() : "";
if (thirdLine.isEmpty() || TEMPLATE_THIRD_LINE.equals(thirdLine)) return FALLBACK_UNTITLED;
return truncateForSnippet(thirdLine);
}
if (firstLine.isEmpty()) return FALLBACK_UNTITLED;
return truncateForSnippet(firstLine);
}
/**
* snippet 60
*
* @param s
* @return
*/
private static String truncateForSnippet(String s) {
if (s == null) return FALLBACK_UNTITLED;
s = s.trim();
if (s.isEmpty()) return FALLBACK_UNTITLED;
return s.length() > 60 ? s.substring(0, 60) + "…" : s;
}
}

@ -0,0 +1,91 @@
package net.micode.notes.tool;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.media.projection.MediaProjection;
import android.media.projection.MediaProjectionManager;
import android.os.Build;
import android.os.IBinder;
import androidx.core.app.NotificationCompat;
import net.micode.notes.R;
/**
*
* <p>
*
* MediaProjection 便
* Android 10+ FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
* </p>
*
* @see FloatingViewManager
* @see ScreenCaptureManager
*/
public class FloatingService extends Service {
private FloatingViewManager mManager;
private ScreenCaptureManager mCaptureManager;
@Override
public IBinder onBind(Intent intent) { return null; }
@Override
public void onCreate() {
super.onCreate();
mManager = new FloatingViewManager(this);
startForegroundServiceNotification();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && intent.hasExtra("data")) {
int resultCode = intent.getIntExtra("resultCode", -1);
Intent data = intent.getParcelableExtra("data");
MediaProjectionManager mm = (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE);
MediaProjection mp = mm.getMediaProjection(resultCode, data);
// 关键:确保这里每次都是 new 出来的
mCaptureManager = new ScreenCaptureManager(this);
mCaptureManager.setMediaProjection(mp);
mManager.setCaptureManager(mCaptureManager);
}
mManager.showFloatingBall();
return START_STICKY;
}
@Override
public void onDestroy() {
super.onDestroy();
mManager.removeFloatingBall();
}
private void startForegroundServiceNotification() {
String channelId = "inspiration_channel";
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(channelId, "灵感球服务", NotificationManager.IMPORTANCE_LOW);
((NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE)).createNotificationChannel(channel);
}
// 1. 构建通知,删掉了那行报错的代码
Notification notification = new NotificationCompat.Builder(this, channelId)
.setContentTitle("灵感球已开启")
.setContentText("点击悬浮球,捕捉瞬间灵感")
.setSmallIcon(R.drawable.icon_app)
.build();
// 2. 在这里指定前台服务类型(这是最标准的写法)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10 (Q) 以上支持指定类型
startForeground(1, notification, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION);
} else {
// 老版本直接启动
startForeground(1, notification);
}
}
}

@ -0,0 +1,302 @@
package net.micode.notes.tool;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import net.micode.notes.tool.DataUtils;
import android.graphics.Bitmap;
import android.graphics.PixelFormat;
import android.util.Log;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.os.VibrationEffect;
import android.os.Vibrator;
import android.text.TextUtils;
import android.util.DisplayMetrics;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.WindowManager;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.Toast;
// 关键:必须导入内部类 NoteColumns 和 DataColumns
import net.micode.notes.R;
import net.micode.notes.data.Notes;
import net.micode.notes.data.Notes.NoteColumns;
import net.micode.notes.data.Notes.DataColumns;
import java.io.File;
import java.io.FileOutputStream;
/**
* (Floating Ball Manager)
* <p>
*
* 1. UI
* 2. +
* 3. {@link ScreenCaptureManager} {@link MediaUtils}
*/
public class FloatingViewManager {
private Context mContext;
private WindowManager mWindowManager;
private View mFloatingView;
private WindowManager.LayoutParams mParams;
private ScreenCaptureManager mCaptureManager;
private int mScreenWidth, mScreenHeight;
/**
*
*
* @param context WindowManager DisplayMetrics
*/
public FloatingViewManager(Context context) {
mContext = context;
mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics m = context.getResources().getDisplayMetrics();
mScreenWidth = m.widthPixels;
mScreenHeight = m.heightPixels;
}
/**
* capture
*
* @param scm null
*/
public void setCaptureManager(ScreenCaptureManager scm) {
this.mCaptureManager = scm;
}
/**
*
* 使 TYPE_APPLICATION_OVERLAY TYPE_PHONE
*/
public void showFloatingBall() {
if (mFloatingView != null) return;
mParams = new WindowManager.LayoutParams();
mParams.type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ?
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY : WindowManager.LayoutParams.TYPE_PHONE;
mParams.format = PixelFormat.RGBA_8888;
mParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN;
mParams.gravity = Gravity.LEFT | Gravity.TOP;
mParams.width = WindowManager.LayoutParams.WRAP_CONTENT;
mParams.height = WindowManager.LayoutParams.WRAP_CONTENT;
mParams.x = mScreenWidth;
mParams.y = mScreenHeight / 2;
mFloatingView = LayoutInflater.from(mContext).inflate(R.layout.layout_floating_ball, null);
initTouchListener();
mWindowManager.addView(mFloatingView, mParams);
}
/**
*
*/
private void initTouchListener() {
View ball = mFloatingView.findViewById(R.id.iv_floating_ball);
ball.setOnTouchListener(new View.OnTouchListener() {
private int initialX, initialY;
private float initialTouchX, initialTouchY;
private boolean isDrag;
@Override
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
initialX = mParams.x; initialY = mParams.y;
initialTouchX = event.getRawX(); initialTouchY = event.getRawY();
isDrag = false;
return true;
case MotionEvent.ACTION_MOVE:
int dx = (int) (event.getRawX() - initialTouchX);
int dy = (int) (event.getRawY() - initialTouchY);
if (!isDrag && (Math.abs(dx) > 20 || Math.abs(dy) > 20)) isDrag = true;
if (isDrag) {
mParams.x = initialX + dx; mParams.y = initialY + dy;
mWindowManager.updateViewLayout(mFloatingView, mParams);
}
return true;
case MotionEvent.ACTION_UP:
if (!isDrag) onBallClick(); else snapToEdge();
return true;
}
return false;
}
});
}
/**
*
*/
private void snapToEdge() {
mParams.x = (mParams.x + mFloatingView.getWidth() / 2 < mScreenWidth / 2) ? 0 : mScreenWidth;
mWindowManager.updateViewLayout(mFloatingView, mParams);
}
/**
*
* 3
*/
private void onBallClick() {
if (mCaptureManager == null) {
Toast.makeText(mContext, "截屏服务未初始化", Toast.LENGTH_SHORT).show();
return;
}
// 1. 视觉与触觉反馈
mFloatingView.animate().scaleX(0.7f).scaleY(0.7f).setDuration(100).withEndAction(() -> {
mFloatingView.animate().scaleX(1.0f).scaleY(1.0f).setDuration(100).start();
}).start();
Vibrator vibrator = (Vibrator) mContext.getSystemService(Context.VIBRATOR_SERVICE);
if (vibrator != null) vibrator.vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE));
// 2. 隐藏小球开始截图
mFloatingView.setVisibility(View.GONE);
// 增加一个“超时保险”:如果 3 秒还没截到图,强行把球变回来
final Handler timeoutHandler = new Handler(Looper.getMainLooper());
final Runnable timeoutRunnable = () -> {
if (mFloatingView.getVisibility() == View.GONE) {
mFloatingView.setVisibility(View.VISIBLE);
Toast.makeText(mContext, "截图响应超时", Toast.LENGTH_SHORT).show();
}
};
timeoutHandler.postDelayed(timeoutRunnable, 3000);
// 3. 执行截图
new Handler(Looper.getMainLooper()).postDelayed(() -> {
mCaptureManager.capture(bitmap -> {
// 截图有回应了,移除超时保险
timeoutHandler.removeCallbacks(timeoutRunnable);
mFloatingView.setVisibility(View.VISIBLE);
if (bitmap != null) {
handleCapturedBitmap(bitmap);
} else {
Toast.makeText(mContext, "截图失败,请重试", Toast.LENGTH_SHORT).show();
}
});
}, 200); // 稍微延长一点等待时间,确保小球完全消失
}
/**
* Bitmap JPEG
*
* @param bitmap null
*/
private void handleCapturedBitmap(Bitmap bitmap) {
try {
File file = MediaUtils.createImageFile(mContext);
try (FileOutputStream out = new FileOutputStream(file)) {
bitmap.compress(Bitmap.CompressFormat.JPEG, 70, out);
}
// 使用 getCanonicalPath() 规范化路径,符合 G.FIO.01
String path = file.getCanonicalPath();
showInputWindow(path);
} catch (Exception e) { e.printStackTrace(); }
}
/**
* saveInspirationToDb
*
* @param imagePath
*/
private void showInputWindow(String imagePath) {
WindowManager.LayoutParams p = new WindowManager.LayoutParams();
p.type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ?
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY : WindowManager.LayoutParams.TYPE_PHONE;
p.format = PixelFormat.RGBA_8888;
p.flags = WindowManager.LayoutParams.FLAG_DIM_BEHIND;
p.dimAmount = 0.5f;
p.width = WindowManager.LayoutParams.MATCH_PARENT;
p.height = WindowManager.LayoutParams.WRAP_CONTENT;
p.gravity = Gravity.CENTER;
View inputView = LayoutInflater.from(mContext).inflate(R.layout.layout_floating_input, null);
EditText et = inputView.findViewById(R.id.et_inspiration);
inputView.findViewById(R.id.btn_send_inspiration).setOnClickListener(v -> {
String text = et.getText().toString();
saveInspirationToDb(imagePath, text);
mWindowManager.removeView(inputView);
});
mWindowManager.addView(inputView, p);
et.requestFocus();
// 延时弹出软键盘
new Handler(Looper.getMainLooper()).postDelayed(() -> {
InputMethodManager imm = (InputMethodManager) mContext.getSystemService(Context.INPUT_METHOD_SERVICE);
imm.showSoftInput(et, InputMethodManager.SHOW_IMPLICIT);
}, 200);
}
/**
* + 便
* 线 Note Data snippet
*
* @param path
* @param text
*/
private void saveInspirationToDb(String path, String text) {
new Thread(() -> {
try {
// 1. 插入 Note 主表snippet 先占位,插入 Data 后会被触发器覆盖,再在下面按规则写回)
ContentValues v = new ContentValues();
long now = System.currentTimeMillis();
v.put(net.micode.notes.data.Notes.NoteColumns.PARENT_ID, net.micode.notes.data.Notes.ID_ROOT_FOLDER);
v.put(net.micode.notes.data.Notes.NoteColumns.SNIPPET, "");
v.put(net.micode.notes.data.Notes.NoteColumns.TYPE, 0); // TYPE_NOTE
v.put(net.micode.notes.data.Notes.NoteColumns.MODIFIED_DATE, now);
v.put(net.micode.notes.data.Notes.NoteColumns.CREATED_DATE, now);
v.put(net.micode.notes.data.Notes.NoteColumns.LOCAL_MODIFIED, 1);
v.put(net.micode.notes.data.Notes.NoteColumns.BG_COLOR_ID, 0);
Uri uri = mContext.getContentResolver().insert(net.micode.notes.data.Notes.CONTENT_NOTE_URI, v);
if (uri == null) return;
long noteId = android.content.ContentUris.parseId(uri);
// 2. 插入 Data 附表
ContentValues dv = new ContentValues();
dv.put(Notes.DataColumns.NOTE_ID, noteId);
dv.put(Notes.DataColumns.MIME_TYPE, "vnd.android.cursor.item/text_note");
String fullContent = "<img src=\"" + path + "\"/>\n" + (text == null ? "" : text);
dv.put(Notes.DataColumns.CONTENT, fullContent);
dv.put(Notes.DataColumns.DATA1, 0);
mContext.getContentResolver().insert(Notes.CONTENT_DATA_URI, dv);
// 3. 灵感便签标题修复:优先用用户输入的文字作为 snippet仅无文字时用「图片便签」禁止用文件路径
String snippet = DataUtils.computeNoteSnippet(fullContent);
ContentValues uv = new ContentValues();
uv.put(Notes.NoteColumns.SNIPPET, snippet);
mContext.getContentResolver().update(
ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId), uv, null, null);
new Handler(Looper.getMainLooper()).post(() ->
Toast.makeText(mContext, "灵感已存入灵感箱", Toast.LENGTH_SHORT).show());
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
/**
* showFloatingBall
*/
public void removeFloatingBall() {
if (mFloatingView != null) {
mWindowManager.removeView(mFloatingView);
mFloatingView = null;
}
}
}

@ -0,0 +1,249 @@
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.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
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 {
return createImageFileWithExtension(context, ".jpg");
}
/**
* //
*
* @param context getExternalFilesDir
* @param extension ".jpg"".png"
* @return Pictures/images
*/
private static File createImageFileWithExtension(Context context, String extension) 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();
}
if (extension == null || !extension.startsWith(".")) {
extension = ".jpg";
}
return File.createTempFile(imageFileName, extension, storageDir);
}
/**
* Uri MIME PNG jpg/webp
*
* @param context ContentResolver.getType
* @param uri Uri
* @return ".jpg"".png"
*/
private static String getExtensionFromUri(Context context, Uri uri) {
if (uri == null) return ".jpg";
String mime = context.getContentResolver().getType(uri);
if (mime != null) {
if (mime.contains("png")) return ".png";
if (mime.contains("webp")) return ".webp";
if (mime.contains("gif")) return ".gif";
}
return ".jpg";
}
/**
* Uri // ContentProvider file Uri
* 便 Uri
* @return null
*/
public static String copyUriToInternalStorage(Context context, Uri sourceUri) {
if (context == null || sourceUri == null) return null;
// 1. file:// 方案:截图或部分文件管理器返回的是文件路径,直接复制文件
if ("file".equalsIgnoreCase(sourceUri.getScheme())) {
String path = sourceUri.getPath();
if (path != null && !path.isEmpty()) {
return copyFileToInternalStorage(context, new File(path));
}
Log.e(TAG, "file Uri has no path: " + sourceUri);
return null;
}
// 2. content:// 方案:相册、微信等返回的 Content Uri
ContentResolver contentResolver = context.getContentResolver();
String extension = getExtensionFromUri(context, sourceUri);
// 2a. 优先尝试 openInputStream多数相册/MediaStore 支持)
InputStream inputStream = null;
try {
inputStream = contentResolver.openInputStream(sourceUri);
} catch (SecurityException e) {
Log.w(TAG, "openInputStream SecurityException, try openFileDescriptor: " + e.getMessage());
} catch (java.io.FileNotFoundException e) {
Log.w(TAG, "openInputStream FileNotFoundException, try openFileDescriptor: " + e.getMessage());
}
if (inputStream != null) {
String path = copyStreamToInternalStorage(context, inputStream, extension);
closeQuietly(inputStream);
return path;
}
// 2b. 部分应用(如微信、某些截图)仅支持 openFileDescriptor用此方式读取
try {
android.os.ParcelFileDescriptor pfd = contentResolver.openFileDescriptor(sourceUri, "r");
if (pfd != null) {
FileInputStream fis = new FileInputStream(pfd.getFileDescriptor());
String path = copyStreamToInternalStorage(context, fis, extension);
closeQuietly(fis);
try { pfd.close(); } catch (IOException ignored) { }
return path;
}
} catch (SecurityException e) {
Log.e(TAG, "openFileDescriptor SecurityException: " + e.getMessage());
} catch (IOException e) {
Log.e(TAG, "openFileDescriptor IOException: " + e.getMessage());
}
Log.e(TAG, "copyUriToInternalStorage failed for: " + sourceUri);
return null;
}
/**
* file:// Uri 或已有路径)
*/
private static String copyFileToInternalStorage(Context context, File sourceFile) {
if (sourceFile == null || !sourceFile.exists() || !sourceFile.canRead()) {
String pathForLog = "null";
if (sourceFile != null) {
try {
pathForLog = sourceFile.getCanonicalPath();
} catch (IOException e) {
pathForLog = "(path unavailable)";
}
}
Log.e(TAG, "Source file not readable: " + pathForLog);
return null;
}
String ext = ".jpg";
String name = sourceFile.getName().toLowerCase();
if (name.endsWith(".png")) ext = ".png";
else if (name.endsWith(".webp")) ext = ".webp";
else if (name.endsWith(".gif")) ext = ".gif";
FileInputStream fis = null;
try {
fis = new FileInputStream(sourceFile);
return copyStreamToInternalStorage(context, fis, ext);
} catch (IOException e) {
Log.e(TAG, "copyFileToInternalStorage failed", e);
return null;
} finally {
closeQuietly(fis);
}
}
/**
*
*/
private static String copyStreamToInternalStorage(Context context, InputStream input, String extension) {
FileOutputStream outputStream = null;
try {
File targetFile = createImageFileWithExtension(context, extension);
outputStream = new FileOutputStream(targetFile);
byte[] buffer = new byte[8192];
int length;
while ((length = input.read(buffer)) > 0) {
outputStream.write(buffer, 0, length);
}
outputStream.flush();
return targetFile.getCanonicalPath();
} catch (IOException e) {
Log.e(TAG, "Failed to copy stream to internal storage", e);
return null;
} finally {
closeQuietly(outputStream);
}
}
/**
*
*/
private static void closeQuietly(OutputStream os) {
if (os != null) try { os.close(); } catch (IOException ignored) { }
}
/**
*
*/
private static void closeQuietly(InputStream is) {
if (is != null) try { is.close(); } catch (IOException ignored) { }
}
/**
* 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);
}
/**
* Bitmap (inSampleSize)使 OOM
*
* @param options inJustDecodeBounds=true Options
* @param reqWidth
* @param reqHeight
* @return 2 1248
*/
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,30 @@
package net.micode.notes.tool;
/**
*
*
* 退
*/
public class PrivacySpaceManager {
private static volatile boolean sInPrivacySpace = false;
/** 是否为隐私空间模式 */
public static boolean isInPrivacySpace() {
return sInPrivacySpace;
}
/** 进入隐私空间(由 Mi 管家输入「隐私空间」触发) */
public static void enterPrivacySpace() {
sInPrivacySpace = true;
}
/**
* 退
* 退
*
*/
public static void exitPrivacySpace() {
sInPrivacySpace = false;
}
}

@ -9,6 +9,8 @@ import android.content.Context;
import android.preference.PreferenceManager;
import net.micode.notes.R;
import java.security.SecureRandom;
import net.micode.notes.ui.NotesPreferenceActivity;
/**
@ -89,7 +91,7 @@ public class ResourceParser {
public static int getDefaultBgId(Context context) {
if (PreferenceManager.getDefaultSharedPreferences(context).getBoolean(
NotesPreferenceActivity.PREFERENCE_SET_BG_COLOR_KEY, false)) {
return (int) (Math.random() * NoteBgResources.BG_EDIT_RESOURCES.length);
return new SecureRandom().nextInt(NoteBgResources.BG_EDIT_RESOURCES.length);
} else {
return BG_DEFAULT_COLOR;
}
@ -142,25 +144,62 @@ public class ResourceParser {
R.drawable.list_red_single
};
/**
* ID ID
*/
public static int getNoteBgFirstRes(int id) {
return BG_FIRST_RESOURCES[id];
}
/**
* ID ID
*/
public static int getNoteBgLastRes(int id) {
return BG_LAST_RESOURCES[id];
}
/**
* ID ID
*/
public static int getNoteBgSingleRes(int id) {
return BG_SINGLE_RESOURCES[id];
}
/**
* ID ID
*/
public static int getNoteBgNormalRes(int id) {
return BG_NORMAL_RESOURCES[id];
}
/**
* ID
*/
public static int getFolderBgRes() {
return R.drawable.list_folder;
}
/** 置顶时列表项左侧标签条颜色(加深),与 BG 五色对应 */
private final static int[] PINNED_BAR_COLOR_RESOURCES = new int[] {
R.color.note_pinned_bar_yellow,
R.color.note_pinned_bar_blue,
R.color.note_pinned_bar_white,
R.color.note_pinned_bar_green,
R.color.note_pinned_bar_red
};
/**
* ID ID
*
* @param bgColorId ID0-4
* @return ID ID
*/
public static int getPinnedBarColorRes(int bgColorId) {
if (bgColorId < 0 || bgColorId >= PINNED_BAR_COLOR_RESOURCES.length) {
return PINNED_BAR_COLOR_RESOURCES[0];
}
return PINNED_BAR_COLOR_RESOURCES[bgColorId];
}
}
/**
@ -176,6 +215,9 @@ public class ResourceParser {
R.drawable.widget_2x_red,
};
/**
* ID 2x2 ID
*/
public static int getWidget2xBgResource(int id) {
return BG_2X_RESOURCES[id];
}
@ -188,6 +230,9 @@ public class ResourceParser {
R.drawable.widget_4x_red
};
/**
* ID 4x4 ID
*/
public static int getWidget4xBgResource(int id) {
return BG_4X_RESOURCES[id];
}
@ -217,6 +262,9 @@ public class ResourceParser {
return TEXTAPPEARANCE_RESOURCES[id];
}
/**
* bounds
*/
public static int getResourcesSize() {
return TEXTAPPEARANCE_RESOURCES.length;
}

@ -0,0 +1,110 @@
package net.micode.notes.tool;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.PixelFormat;
import android.hardware.display.DisplayManager;
import android.hardware.display.VirtualDisplay;
import android.media.Image;
import android.media.ImageReader;
import android.media.projection.MediaProjection;
import android.os.Handler;
import android.os.Looper;
import android.util.DisplayMetrics;
import android.view.WindowManager;
import java.nio.ByteBuffer;
/**
*
* <p>
* MediaProjection Bitmap
* FloatingViewManager 使便
* ImageReader VirtualDisplay
* </p>
*
* @see FloatingViewManager
* @see FloatingService
*/
public class ScreenCaptureManager {
private MediaProjection mMediaProjection;
private int mScreenWidth, mScreenHeight, mScreenDensity;
private ImageReader mImageReader; // 【关键】全局持有,不销毁
private VirtualDisplay mVirtualDisplay;
public ScreenCaptureManager(Context context) {
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics metrics = new DisplayMetrics();
wm.getDefaultDisplay().getMetrics(metrics);
mScreenWidth = metrics.widthPixels;
mScreenHeight = metrics.heightPixels;
mScreenDensity = metrics.densityDpi;
// 【关键】构造时就创建好 ImageReader一直复用
// maxImages 设为 2避免 Buffer 不够
mImageReader = ImageReader.newInstance(mScreenWidth, mScreenHeight, PixelFormat.RGBA_8888, 2);
}
public void setMediaProjection(MediaProjection mp) {
this.mMediaProjection = mp;
if (mMediaProjection != null) {
// Android 14 必须注册回调,否则崩
mMediaProjection.registerCallback(new MediaProjection.Callback() {}, null);
}
}
public void capture(final OnCaptureListener listener) {
if (mMediaProjection == null || mImageReader == null) return;
// 【关键】每次只需创建 VirtualDisplay
mVirtualDisplay = mMediaProjection.createVirtualDisplay(
"ScreenCapture",
mScreenWidth, mScreenHeight, mScreenDensity,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
mImageReader.getSurface(),
null, null);
// 设置单次监听
mImageReader.setOnImageAvailableListener(new ImageReader.OnImageAvailableListener() {
@Override
public void onImageAvailable(ImageReader reader) {
// 一旦收到图片,立刻移除监听,防止重复触发
reader.setOnImageAvailableListener(null, null);
// 停止投影 (释放 VirtualDisplay但不释放 ImageReader)
if (mVirtualDisplay != null) {
mVirtualDisplay.release();
mVirtualDisplay = null;
}
Bitmap bitmap = null;
Image image = null;
try {
image = reader.acquireLatestImage();
if (image != null) {
Image.Plane[] planes = image.getPlanes();
ByteBuffer buffer = planes[0].getBuffer();
int pixelStride = planes[0].getPixelStride();
int rowStride = planes[0].getRowStride();
int rowPadding = rowStride - pixelStride * mScreenWidth;
bitmap = Bitmap.createBitmap(mScreenWidth + rowPadding / pixelStride, mScreenHeight, Bitmap.Config.ARGB_8888);
bitmap.copyPixelsFromBuffer(buffer);
bitmap = Bitmap.createBitmap(bitmap, 0, 0, mScreenWidth, mScreenHeight);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (image != null) image.close(); // 必须关闭 Image 释放 Buffer
final Bitmap result = bitmap;
new Handler(Looper.getMainLooper()).post(() -> {
if (listener != null) listener.onCaptured(result);
});
}
}
}, null); // 在当前线程(主线程)回调,因为这里逻辑很快,为了避免线程死锁先这样
}
public interface OnCaptureListener {
void onCaptured(Bitmap bitmap);
}
}

@ -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,52 @@
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;
/**
* / paint descent/bottom ascent/top
* 0
*/
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 (rect.isEmpty()) {
return 0;
}
int w = rect.right;
int h = rect.bottom;
if (fm != null) {
// 只向上扩展行高以容纳图片,保留原有 descent/bottom避免多行/多图时行高错位
fm.ascent = Math.min(fm.ascent, -h);
fm.top = Math.min(fm.top, -h);
}
return w;
}
@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,126 @@
package net.micode.notes.ui;
/**
* Mi
* <p>
* RecyclerView Adapter AI TYPE_USER TYPE_AI
* AI
* </p>
*
* @see net.micode.notes.model.ChatMessage
* @see net.micode.notes.ui.MiStewardFragment
*/
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,142 @@
package net.micode.notes.ui;
import android.app.Activity;
import android.content.AsyncQueryHandler;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.media.projection.MediaProjectionManager;
import android.net.Uri;
import android.os.Bundle;
import android.provider.Settings;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CompoundButton;
import android.widget.ListView;
import android.widget.Switch;
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.tool.FloatingService;
public class InspirationFragment extends Fragment {
private ListView mListView;
private NotesListAdapter mAdapter;
private BackgroundQueryHandler mQueryHandler;
private Switch mSwitchFloating;
private static final int QUERY_TOKEN = 202;
private static final int REQUEST_CODE_OVERLAY_PERMISSION = 1001;
private static final int REQUEST_CODE_SCREEN_CAPTURE = 1002;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_inspiration, container, false);
initViews(view);
return view;
}
private void initViews(View view) {
mListView = view.findViewById(R.id.inspiration_list);
mSwitchFloating = view.findViewById(R.id.switch_floating_ball);
mAdapter = new NotesListAdapter(getActivity());
mListView.setAdapter(mAdapter);
mQueryHandler = new BackgroundQueryHandler(getActivity().getContentResolver());
mListView.setOnItemClickListener(new android.widget.AdapterView.OnItemClickListener() {
@Override
public void onItemClick(android.widget.AdapterView<?> parent, View view, int position, long id) {
// 跳转到编辑页 (复用 NoteEditActivity)
Intent intent = new Intent(getActivity(), NoteEditActivity.class);
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra(Intent.EXTRA_UID, id); // 传入便签 ID
startActivity(intent);
}
});
mSwitchFloating.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
if (isChecked) {
checkAndStartService();
} else {
stopFloatingService();
}
}
});
}
private void checkAndStartService() {
// 1. 检查悬浮窗权限
if (!Settings.canDrawOverlays(getContext())) {
Toast.makeText(getActivity(), "请授予悬浮窗权限", Toast.LENGTH_SHORT).show();
Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:" + getActivity().getPackageName()));
startActivityForResult(intent, REQUEST_CODE_OVERLAY_PERMISSION);
mSwitchFloating.setChecked(false);
return;
}
// 2. 申请截屏权限
MediaProjectionManager mm = (MediaProjectionManager) getActivity().getSystemService(Context.MEDIA_PROJECTION_SERVICE);
startActivityForResult(mm.createScreenCaptureIntent(), REQUEST_CODE_SCREEN_CAPTURE);
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == REQUEST_CODE_OVERLAY_PERMISSION) {
if (Settings.canDrawOverlays(getContext())) {
checkAndStartService();
}
} else if (requestCode == REQUEST_CODE_SCREEN_CAPTURE) {
if (resultCode == Activity.RESULT_OK && data != null) {
startFloatingService(resultCode, data);
} else {
mSwitchFloating.setChecked(false);
Toast.makeText(getActivity(), "未授权截屏,无法开启", Toast.LENGTH_SHORT).show();
}
}
}
private void startFloatingService(int resultCode, Intent data) {
Intent intent = new Intent(getActivity(), FloatingService.class);
intent.putExtra("resultCode", resultCode);
intent.putExtra("data", data);
getActivity().startForegroundService(intent);
}
private void stopFloatingService() {
Intent intent = new Intent(getActivity(), FloatingService.class);
getActivity().stopService(intent);
}
@Override
public void onStart() {
super.onStart();
refreshList();
}
private void refreshList() {
String selection = NoteColumns.PARENT_ID + "=?";
String[] selectionArgs = { String.valueOf(Notes.ID_INSPIRATION_FOLDER) };
mQueryHandler.startQuery(QUERY_TOKEN, null, Notes.CONTENT_NOTE_URI,
NoteItemData.PROJECTION, selection, selectionArgs, NoteColumns.MODIFIED_DATE + " DESC");
}
private final class BackgroundQueryHandler extends AsyncQueryHandler {
public BackgroundQueryHandler(ContentResolver cr) { super(cr); }
@Override
protected void onQueryComplete(int token, Object cookie, Cursor cursor) {
if (token == QUERY_TOKEN) mAdapter.changeCursor(cursor);
}
}
}

@ -0,0 +1,178 @@
package net.micode.notes.ui;
/**
* Activity
* <p>
* Mi / 便 / ViewPager2 Drawer
*
* </p>
*
* @see MiStewardFragment
* @see NotesListFragment
* @see TodoFragment
*/
import android.content.Intent;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.MenuItem;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.Fragment;
import androidx.viewpager2.adapter.FragmentStateAdapter;
import androidx.viewpager2.widget.ViewPager2;
import com.google.android.material.bottomnavigation.BottomNavigationView;
import com.google.android.material.navigation.NavigationView;
import net.micode.notes.R;
public class MainActivity extends AppCompatActivity {
private ViewPager2 mViewPager;
private BottomNavigationView mBottomNav;
private DrawerLayout mDrawerLayout;
private NavigationView mNavView;
/** 便签列表页引用,由 NotesListFragment 注册,用于侧边栏菜单回调 */
private NotesListFragment mNotesListFragment;
private View mMainContentRoot;
@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);
mDrawerLayout = findViewById(R.id.drawer_layout);
mNavView = findViewById(R.id.nav_view);
mMainContentRoot = findViewById(R.id.main_content_root);
mViewPager.setAdapter(new FragmentStateAdapter(this) {
@NonNull
@Override
public Fragment createFragment(int position) {
if (position == 0) {
return new MiStewardFragment();
} else if (position == 1) {
return new NotesListFragment();
} else {
return new TodoFragment();
}
}
@Override
public int getItemCount() {
return 3;
}
});
mBottomNav.setOnItemSelectedListener(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;
} else if (item.getItemId() == R.id.nav_todo) {
mViewPager.setCurrentItem(2);
return true;
}
return false;
});
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 if (position == 1) {
mBottomNav.setSelectedItemId(R.id.nav_notes);
} else {
mBottomNav.setSelectedItemId(R.id.nav_todo);
}
}
});
// 侧边栏菜单点击
mNavView.setNavigationItemSelectedListener(item -> {
int id = item.getItemId();
if (id == R.id.drawer_new_note) {
mViewPager.setCurrentItem(1);
mViewPager.post(() -> {
if (mNotesListFragment != null) {
mNotesListFragment.createNewNoteFromDrawer();
}
});
mDrawerLayout.closeDrawers();
return true;
}
if (id == R.id.drawer_new_folder) {
mViewPager.setCurrentItem(1);
mViewPager.post(() -> {
if (mNotesListFragment != null) {
mNotesListFragment.showCreateOrModifyFolderDialogFromDrawer(true);
}
});
mDrawerLayout.closeDrawers();
return true;
}
if (id == R.id.drawer_export) {
mViewPager.setCurrentItem(1);
mViewPager.post(() -> {
if (mNotesListFragment != null) {
mNotesListFragment.exportNoteToTextFromDrawer();
}
});
mDrawerLayout.closeDrawers();
return true;
}
return false;
});
}
/** 供便签页 Toolbar 菜单按钮调用,打开侧边栏 */
public void openDrawer() {
if (mDrawerLayout != null) {
mDrawerLayout.openDrawer(mNavView);
}
}
/** 由 NotesListFragment 在 onViewCreated 时注册onDestroyView 时置空 */
public void setNotesListFragment(NotesListFragment fragment) {
mNotesListFragment = fragment;
}
/**
* + 便
* MiStewardFragment
*/
public void enterPrivacySpace() {
int darkColor = ContextCompat.getColor(this, R.color.privacy_space_background);
if (mMainContentRoot != null) {
mMainContentRoot.setBackgroundColor(darkColor);
}
if (mBottomNav != null) {
mBottomNav.setBackgroundColor(darkColor);
}
mViewPager.setCurrentItem(1);
mViewPager.post(() -> {
if (mNotesListFragment != null) {
mNotesListFragment.onEnterPrivacySpace();
}
});
}
/** 退出隐私空间:恢复正常主题 */
public void exitPrivacySpace() {
if (mMainContentRoot != null) {
mMainContentRoot.setBackgroundColor(ContextCompat.getColor(this, R.color.surface_white));
}
if (mBottomNav != null) {
mBottomNav.setBackgroundColor(ContextCompat.getColor(this, R.color.surface_white));
}
}
}

@ -0,0 +1,253 @@
package net.micode.notes.ui;
/**
* Mi AI Fragment
* <p>
* Mi DeepSeek
* RAG 便 AI
*
* </p>
*
* @see ChatAdapter
* @see AIService
* @see NoteRetriever
* @see PromptBuilder
*/
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.media.projection.MediaProjectionManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.provider.Settings;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.CompoundButton;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.Switch;
import android.widget.Toast;
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.ai.AIService;
import net.micode.notes.ai.NoteRetriever;
import net.micode.notes.ai.PromptBuilder;
import net.micode.notes.model.ChatMessage;
import net.micode.notes.tool.FloatingService;
import net.micode.notes.tool.PrivacySpaceManager;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class MiStewardFragment extends Fragment {
private RecyclerView mRecyclerView;
private ChatAdapter mAdapter;
private EditText mInputBox;
private View mLogoArea;
private Switch mSwitchFloating;
private CheckBox mCbUseKnowledge;
private static final int REQUEST_CODE_OVERLAY_PERMISSION = 1001;
private static final int REQUEST_CODE_SCREEN_CAPTURE = 1002;
@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;
}
@Override
public void onResume() {
super.onResume();
if (getView() != null && PrivacySpaceManager.isInPrivacySpace()) {
getView().setBackgroundColor(androidx.core.content.ContextCompat.getColor(requireContext(), R.color.privacy_space_background));
}
}
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);
mCbUseKnowledge = view.findViewById(R.id.cb_use_knowledge);
mSwitchFloating = view.findViewById(R.id.switch_steward_floating);
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);
// 1. 发送按钮逻辑
btnSend.setOnClickListener(v -> {
String text = mInputBox.getText().toString().trim();
if (TextUtils.isEmpty(text)) return;
mLogoArea.setVisibility(View.GONE);
mRecyclerView.setVisibility(View.VISIBLE);
mAdapter.addMessage(new ChatMessage(text));
mInputBox.setText("");
scrollToBottom();
// 检测「隐私空间」四个字,有且仅有此四字时进入隐私空间
if ("隐私空间".equals(text)) {
enterPrivacySpaceFlow();
return;
}
performAIQuery(text);
});
// 2. 新对话按钮逻辑 (左上角)
btnNewChat.setOnClickListener(v -> {
mAdapter.clear();
mLogoArea.setVisibility(View.VISIBLE);
mRecyclerView.setVisibility(View.GONE);
Toast.makeText(getContext(), "已开启新对话", Toast.LENGTH_SHORT).show();
});
// 3. 灵感球开关逻辑 (右上角)
mSwitchFloating.setOnCheckedChangeListener((buttonView, isChecked) -> {
if (isChecked) {
checkAndStartFloatingService();
} else {
Intent intent = new Intent(getActivity(), FloatingService.class);
getActivity().stopService(intent);
Toast.makeText(getActivity(), "灵感球已关闭", Toast.LENGTH_SHORT).show();
}
});
}
private void checkAndStartFloatingService() {
// 第一步:检查悬浮窗权限
if (!Settings.canDrawOverlays(getContext())) {
Toast.makeText(getActivity(), "请授予悬浮窗权限", Toast.LENGTH_SHORT).show();
Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:" + getActivity().getPackageName()));
startActivityForResult(intent, REQUEST_CODE_OVERLAY_PERMISSION);
mSwitchFloating.setChecked(false); // 暂时拨回,等待授权
return;
}
// 第二步:申请截屏权限
MediaProjectionManager mm = (MediaProjectionManager) getActivity().getSystemService(Context.MEDIA_PROJECTION_SERVICE);
if (mm != null) {
startActivityForResult(mm.createScreenCaptureIntent(), REQUEST_CODE_SCREEN_CAPTURE);
}
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == REQUEST_CODE_OVERLAY_PERMISSION) {
if (Settings.canDrawOverlays(getContext())) {
checkAndStartFloatingService(); // 重新走流程,进入下一步申请截屏
}
} else if (requestCode == REQUEST_CODE_SCREEN_CAPTURE) {
if (resultCode == Activity.RESULT_OK && data != null) {
// 第三步:启动服务
Intent intent = new Intent(getActivity(), FloatingService.class);
intent.putExtra("resultCode", resultCode);
intent.putExtra("data", data);
getActivity().startForegroundService(intent);
mSwitchFloating.setChecked(true);
Toast.makeText(getActivity(), "灵感球已开启", Toast.LENGTH_SHORT).show();
} else {
mSwitchFloating.setChecked(false);
Toast.makeText(getActivity(), "未授权截屏,无法开启", Toast.LENGTH_SHORT).show();
}
}
}
private void performAIQuery(String userQuery) {
ChatMessage aiMsg = new ChatMessage();
mAdapter.addMessage(aiMsg);
scrollToBottom();
boolean useRAG = mCbUseKnowledge.isChecked();
new Thread(() -> {
List<NoteRetriever.RetrievedNote> relatedNotes = null;
if (useRAG) {
relatedNotes = NoteRetriever.searchRelevantNotes(getContext(), userQuery);
}
String systemPrompt = PromptBuilder.buildSystemPrompt(relatedNotes, useRAG);
AIService.chatWithKnowledge(systemPrompt, userQuery, new AIService.AIResultCallback() {
@Override
public void onSuccess(String result) {
List<String> refs = new ArrayList<>();
Pattern pattern = Pattern.compile("\\[Ref:(.*?)\\]");
Matcher matcher = pattern.matcher(result);
while (matcher.find()) {
refs.add(matcher.group(1));
}
String cleanContent = matcher.replaceAll("").trim();
new Handler(Looper.getMainLooper()).post(() -> {
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) {
new Handler(Looper.getMainLooper()).post(() -> {
ChatMessage lastMsg = mAdapter.getLastMessage();
if (lastMsg != null) {
lastMsg.setThinking(false);
lastMsg.setContent("AI 遇到点小麻烦:" + error);
mAdapter.updateLastMessage();
}
});
}
});
}).start();
}
private void scrollToBottom() {
mRecyclerView.scrollToPosition(mAdapter.getItemCount() - 1);
}
/**
* Mi + + 便
*/
private void enterPrivacySpaceFlow() {
// 1. Mi 管家回复
ChatMessage aiMsg = new ChatMessage();
aiMsg.setThinking(false);
aiMsg.setContent("隐私空间已开启~");
mAdapter.addMessage(aiMsg);
scrollToBottom();
// 2. 进入隐私空间模式
PrivacySpaceManager.enterPrivacySpace();
// 3. 通知 MainActivity变暗 UI 并切换到便签列表
if (getActivity() instanceof MainActivity) {
((MainActivity) getActivity()).enterPrivacySpace();
}
}
}

@ -17,13 +17,24 @@ import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Paint;
import android.Manifest;
import android.os.Build;
import android.os.Bundle;
import android.preference.PreferenceManager;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import android.graphics.Color;
import android.text.Editable;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.SpannableString;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.format.DateUtils;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.RelativeSizeSpan;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.Menu;
@ -54,8 +65,11 @@ import net.micode.notes.ui.NoteEditText.OnTextViewChangeListener;
import net.micode.notes.widget.NoteWidgetProvider_2x;
import net.micode.notes.widget.NoteWidgetProvider_4x;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -145,6 +159,7 @@ public class NoteEditActivity extends Activity implements OnClickListener,
private NoteEditText mNoteEditor; // 核心编辑框 (普通模式)
private View mNoteEditorPanel; // 编辑区域容器
private LinearLayout mEditTextList; // 清单模式下的容器 (CheckList)
private TextView mTvCharCount; // 左下角字数统计
// 业务逻辑核心对象 (ViewModel)
private WorkingNote mWorkingNote;
@ -159,6 +174,10 @@ public class NoteEditActivity extends Activity implements OnClickListener,
public static final String TAG_CHECKED = String.valueOf('\u221A');
public static final String TAG_UNCHECKED = String.valueOf('\u25A1');
/** 新建便签模板占位符:第一行、第三行,光标定位时自动清空 */
public static final String TEMPLATE_PLACEHOLDER_TITLE = "标题";
public static final String TEMPLATE_PLACEHOLDER_BODY = "开始书写";
// 搜索相关
private String mUserQuery;
private Pattern mPattern;
@ -179,7 +198,10 @@ public class NoteEditActivity extends Activity implements OnClickListener,
// 在 XML 布局中我们植入了一个 ID 为 btn_ai_polish 的 ImageButton
// 这里手动绑定点击监听器,触发 AI 逻辑
findViewById(R.id.btn_ai_polish).setOnClickListener(this);
// ============================
// 绑定富文本工具栏
findViewById(R.id.btn_bold).setOnClickListener(this);
findViewById(R.id.btn_font_size_up).setOnClickListener(this);
findViewById(R.id.btn_font_size_down).setOnClickListener(this);
// 绑定插入图片按钮
findViewById(R.id.btn_insert_image).setOnClickListener(this);
// ... findViewById(R.id.btn_ai_polish).setOnClickListener(this);
@ -194,15 +216,29 @@ public class NoteEditActivity extends Activity implements OnClickListener,
@Override
protected void onRestoreInstanceState(Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
if (savedInstanceState != null && savedInstanceState.containsKey(Intent.EXTRA_UID)) {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.putExtra(Intent.EXTRA_UID, savedInstanceState.getLong(Intent.EXTRA_UID));
if (!initActivityState(intent)) {
finish();
return;
}
Log.d(TAG, "Restoring from killed activity");
if (savedInstanceState == null) return;
long noteId = savedInstanceState.getLong(Intent.EXTRA_UID, -1);
if (noteId < 0) return;
Intent intent;
// noteId==0 表示当时是新建便签(异步保存未完成),用 folderId 按“新建”还原,避免 ACTION_VIEW(0) 失败导致 finish 退回列表
if (noteId == 0 && savedInstanceState.containsKey(KEY_SAVED_FOLDER_ID)) {
long folderId = savedInstanceState.getLong(KEY_SAVED_FOLDER_ID, Notes.ID_ROOT_FOLDER);
intent = new Intent(Intent.ACTION_INSERT_OR_EDIT);
intent.putExtra(Notes.INTENT_EXTRA_FOLDER_ID, folderId);
} else if (noteId > 0) {
intent = new Intent(Intent.ACTION_VIEW);
intent.putExtra(Intent.EXTRA_UID, noteId);
} else {
return;
}
if (!initActivityState(intent)) {
finish();
return;
}
if (savedInstanceState.containsKey(KEY_CURRENT_PHOTO_PATH)) {
mCurrentPhotoPath = savedInstanceState.getString(KEY_CURRENT_PHOTO_PATH);
}
Log.d(TAG, "Restoring from killed activity, noteId=" + noteId);
}
/**
@ -210,20 +246,25 @@ public class NoteEditActivity extends Activity implements OnClickListener,
* Intent Action "查看/编辑旧便签" "新建便签"
*/
private boolean initActivityState(Intent intent) {
/**
* If the user specified the {@link Intent#ACTION_VIEW} but not provided with id,
* then jump to the NotesListActivity
*/
mWorkingNote = null;
// Case 1: 查看或编辑已有便签
// 1. 处理“查看/编辑”模式
if (TextUtils.equals(Intent.ACTION_VIEW, intent.getAction())) {
long noteId = intent.getLongExtra(Intent.EXTRA_UID, 0);
mUserQuery = "";
mUserQuery = ""; // 初始化为空字符串,防止后续渲染空指针
// 处理来自搜索结果的跳转,可能会带有高亮关键词 (User Query)
/**
* Starting from the searched result
*/
if (intent.hasExtra(SearchManager.EXTRA_DATA_KEY)) {
noteId = Long.parseLong(intent.getStringExtra(SearchManager.EXTRA_DATA_KEY));
mUserQuery = intent.getStringExtra(SearchManager.USER_QUERY);
}
// 校验便签是否存在
if (!DataUtils.visibleInNoteDatabase(getContentResolver(), noteId, Notes.TYPE_NOTE)) {
Intent jump = new Intent(this, NotesListActivity.class);
startActivity(jump);
@ -231,7 +272,6 @@ public class NoteEditActivity extends Activity implements OnClickListener,
finish();
return false;
} else {
// 加载数据
mWorkingNote = WorkingNote.load(this, noteId);
if (mWorkingNote == null) {
Log.e(TAG, "load note failed with note id" + noteId);
@ -239,13 +279,15 @@ public class NoteEditActivity extends Activity implements OnClickListener,
return false;
}
}
// 此时不自动弹出软键盘
// 键盘默认隐藏
getWindow().setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN
| WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
}
// Case 2: 新建便签 (INSERT_OR_EDIT)
// 2. 处理“新建”模式
else if(TextUtils.equals(Intent.ACTION_INSERT_OR_EDIT, intent.getAction())) {
// New note
long folderId = intent.getLongExtra(Notes.INTENT_EXTRA_FOLDER_ID, 0);
int widgetId = intent.getIntExtra(Notes.INTENT_EXTRA_WIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID);
@ -254,15 +296,20 @@ public class NoteEditActivity extends Activity implements OnClickListener,
int bgResId = intent.getIntExtra(Notes.INTENT_EXTRA_BACKGROUND_ID,
ResourceParser.getDefaultBgId(this));
// 处理从通话记录创建便签的特殊逻辑
// Parse call-record note
String phoneNumber = intent.getStringExtra(Intent.EXTRA_PHONE_NUMBER);
long callDate = intent.getLongExtra(Notes.INTENT_EXTRA_CALL_DATE, 0);
if (callDate != 0 && phoneNumber != null) {
// 【关键修改】如果文件夹是灵感箱 (-4),强制不走通话记录逻辑,直接创建空便签
if (folderId == Notes.ID_INSPIRATION_FOLDER) {
mWorkingNote = WorkingNote.createEmptyNote(this, folderId, widgetId, widgetType, bgResId);
}
// 只有当有电话号码且不是灵感箱时,才尝试作为通话记录处理
else if (callDate != 0 && phoneNumber != null) {
if (TextUtils.isEmpty(phoneNumber)) {
Log.w(TAG, "The call record number is null");
}
long noteId = 0;
// 如果该通话记录已经有对应的便签,则打开它
if ((noteId = DataUtils.getNoteIdByPhoneNumberAndCallDate(getContentResolver(),
phoneNumber, callDate)) > 0) {
mWorkingNote = WorkingNote.load(this, noteId);
@ -272,18 +319,17 @@ public class NoteEditActivity extends Activity implements OnClickListener,
return false;
}
} else {
// 否则创建新的通话便签
mWorkingNote = WorkingNote.createEmptyNote(this, folderId, widgetId,
widgetType, bgResId);
mWorkingNote.convertToCallNote(phoneNumber, callDate);
}
} else {
// 创建普通空便签
// 普通新建
mWorkingNote = WorkingNote.createEmptyNote(this, folderId, widgetId, widgetType,
bgResId);
}
// 新建时自动弹出软键盘
// 新建时键盘自动弹出
getWindow().setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
| WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE);
@ -292,7 +338,7 @@ public class NoteEditActivity extends Activity implements OnClickListener,
finish();
return false;
}
// 注册监听器,当 WorkingNote 属性改变时回调本 Activity 刷新 UI
mWorkingNote.setOnSettingStatusChangedListener(this);
return true;
}
@ -308,48 +354,123 @@ public class NoteEditActivity extends Activity implements OnClickListener,
*
*/
private void initNoteScreen() {
if (mNoteEditor == null || mWorkingNote == null) return;
mNoteEditor.setTextAppearance(this, TextAppearanceResources
.getTexAppearanceResource(mFontSizeId));
mNoteEditor.setTextColor(0xFF000000); // 纯黑
mNoteEditor.setHintTextColor(0xFF888888); // 灰色提示
mNoteEditor.setBackgroundColor(0x00000000); // 背景透明
mNoteEditor.setEnabled(true);
mNoteEditor.setFocusable(true);
mNoteEditor.setFocusableInTouchMode(true);
// 根据模式决定显示 EditText 还是 ListLayout
if (mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) {
switchToListMode(mWorkingNote.getContent());
} else {
// 普通模式:设置文本并处理搜索高亮
// 原来是mNoteEditor.setText(...)
// 修改为:使用支持图片的设置方法
if (mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) {
switchToListMode(mWorkingNote.getContent());
} else {
// 这一步至关重要!让它解析 <img...> 标签
mNoteEditor.setTextWithImages(getHighlightQueryResult(mWorkingNote.getContent(), mUserQuery).toString());
mNoteEditor.setSelection(mNoteEditor.getText().length());
String content = mWorkingNote.getContent();
// 新建便签且无内容时:使用模板预设(第一行「标题」、第二行当前时间、第三行「开始书写」)
if (!mWorkingNote.existInDatabase() && TextUtils.isEmpty(content)) {
applyNewNoteTemplate(mNoteEditor);
} else {
mNoteEditor.setPlaceholderLinesForClear("", ""); // 非模板内容不启用占位清空
mNoteEditor.setTextWithImages(getHighlightQueryResult(content, mUserQuery).toString());
}
if (mNoteEditor.getText() != null) {
mNoteEditor.setSelection(mNoteEditor.getText().length());
}
}
mNoteEditor.setSelection(mNoteEditor.getText().length());
}
// 重置背景色选择器的选中状态
for (Integer id : sBgSelectorSelectionMap.keySet()) {
findViewById(sBgSelectorSelectionMap.get(id)).setVisibility(View.GONE);
View sel = findViewById(sBgSelectorSelectionMap.get(id));
if (sel != null) sel.setVisibility(View.GONE);
}
// 应用背景资源
mHeadViewPanel.setBackgroundResource(mWorkingNote.getTitleBgResId());
mNoteEditorPanel.setBackgroundResource(mWorkingNote.getBgColorResId());
if (mHeadViewPanel != null) mHeadViewPanel.setBackgroundResource(mWorkingNote.getTitleBgResId());
if (mNoteEditorPanel != null) mNoteEditorPanel.setBackgroundResource(mWorkingNote.getBgColorResId());
// 显示最后修改时间
mNoteHeaderHolder.tvModified.setText(DateUtils.formatDateTime(this,
mWorkingNote.getModifiedDate(), DateUtils.FORMAT_SHOW_DATE
| DateUtils.FORMAT_NUMERIC_DATE | DateUtils.FORMAT_SHOW_TIME
| DateUtils.FORMAT_SHOW_YEAR));
if (mNoteHeaderHolder != null && mNoteHeaderHolder.tvModified != null) {
mNoteHeaderHolder.tvModified.setText(DateUtils.formatDateTime(this,
mWorkingNote.getModifiedDate(), DateUtils.FORMAT_SHOW_DATE
| DateUtils.FORMAT_NUMERIC_DATE | DateUtils.FORMAT_SHOW_TIME
| DateUtils.FORMAT_SHOW_YEAR));
}
showAlertHeader(); // 刷新闹钟状态
updateCharCount(); // 左下角字数统计
}
/**
* 便()()
*/
private void applyNewNoteTemplate(NoteEditText editor) {
if (editor == null) return;
SpannableStringBuilder sb = new SpannableStringBuilder();
String timeStr = new SimpleDateFormat("M月d日 a h:mm", Locale.CHINA).format(new Date());
int p0 = 0;
sb.append(TEMPLATE_PLACEHOLDER_TITLE);
sb.setSpan(new ForegroundColorSpan(Color.GRAY), p0, sb.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
sb.setSpan(new RelativeSizeSpan(1.5f), p0, sb.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
sb.append("\n").append(timeStr).append("\n");
p0 = sb.length();
sb.append(TEMPLATE_PLACEHOLDER_BODY);
sb.setSpan(new ForegroundColorSpan(Color.GRAY), p0, sb.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
sb.setSpan(new RelativeSizeSpan(0.8f), p0, sb.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
editor.setText(sb);
editor.setPlaceholderLinesForClear(TEMPLATE_PLACEHOLDER_TITLE, TEMPLATE_PLACEHOLDER_BODY);
}
/**
* &lt;img src="路径"/&gt;
*/
private static int getCountableTextLength(CharSequence text) {
if (text == null) return 0;
String s = text.toString();
// 移除 <img src="..."/> 整段标签(图片在文本里实为路径,不参与字数)
s = s.replaceAll("<img\\s+src=\"[^\"]*\"\\s*/>", "");
// 移除换行符,回车换行不记为字
s = s.replace("\r", "").replace("\n", "");
return s.length();
}
/**
*
*/
private void updateCharCount() {
if (mTvCharCount == null) return;
int count = 0;
if (mWorkingNote != null && mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) {
if (mEditTextList != null) {
for (int i = 0; i < mEditTextList.getChildCount(); i++) {
android.view.View child = mEditTextList.getChildAt(i);
if (child != null) {
NoteEditText et = (NoteEditText) child.findViewById(R.id.et_edit_text);
if (et != null && et.getText() != null) {
count += getCountableTextLength(et.getText());
}
}
}
}
} else {
if (mNoteEditor != null && mNoteEditor.getText() != null) {
count = getCountableTextLength(mNoteEditor.getText());
}
}
mTvCharCount.setText(getString(R.string.note_char_count, count));
}
/**
*
*/
private void showAlertHeader() {
if (mWorkingNote == null || mNoteHeaderHolder == null) return;
if (mWorkingNote.hasClockAlert()) {
long time = System.currentTimeMillis();
if (time > mWorkingNote.getAlertDate()) {
@ -363,7 +484,7 @@ public class NoteEditActivity extends Activity implements OnClickListener,
} else {
mNoteHeaderHolder.tvAlertDate.setVisibility(View.GONE);
mNoteHeaderHolder.ivAlertIcon.setVisibility(View.GONE);
};
}
}
@Override
@ -372,6 +493,9 @@ public class NoteEditActivity extends Activity implements OnClickListener,
initActivityState(intent);
}
private static final String KEY_SAVED_FOLDER_ID = "saved_folder_id";
private static final String KEY_CURRENT_PHOTO_PATH = "current_photo_path";
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
@ -379,8 +503,16 @@ public class NoteEditActivity extends Activity implements OnClickListener,
if (!mWorkingNote.existInDatabase()) {
saveNote();
}
outState.putLong(Intent.EXTRA_UID, mWorkingNote.getNoteId());
Log.d(TAG, "Save working note id: " + mWorkingNote.getNoteId() + " onSaveInstanceState");
long noteId = mWorkingNote.getNoteId();
outState.putLong(Intent.EXTRA_UID, noteId);
// 新建便签时 noteId 可能仍为 0异步保存未完成恢复时需用 folderId 按“新建”还原
if (noteId == 0) {
outState.putLong(KEY_SAVED_FOLDER_ID, mWorkingNote.getFolderId());
}
if (mCurrentPhotoPath != null) {
outState.putString(KEY_CURRENT_PHOTO_PATH, mCurrentPhotoPath);
}
Log.d(TAG, "Save working note id: " + noteId + " onSaveInstanceState");
}
/**
@ -389,13 +521,12 @@ public class NoteEditActivity extends Activity implements OnClickListener,
*/
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (mNoteBgColorSelector.getVisibility() == View.VISIBLE
if (mNoteBgColorSelector != null && mNoteBgColorSelector.getVisibility() == View.VISIBLE
&& !inRangeOfView(mNoteBgColorSelector, ev)) {
mNoteBgColorSelector.setVisibility(View.GONE);
return true;
}
if (mFontSizeSelector.getVisibility() == View.VISIBLE
if (mFontSizeSelector != null && mFontSizeSelector.getVisibility() == View.VISIBLE
&& !inRangeOfView(mFontSizeSelector, ev)) {
mFontSizeSelector.setVisibility(View.GONE);
return true;
@ -403,6 +534,13 @@ public class NoteEditActivity extends Activity implements OnClickListener,
return super.dispatchTouchEvent(ev);
}
/**
* View
*
* @param view View
* @param ev
* @return true View false View
*/
private boolean inRangeOfView(View view, MotionEvent ev) {
int []location = new int[2];
view.getLocationOnScreen(location);
@ -417,6 +555,9 @@ public class NoteEditActivity extends Activity implements OnClickListener,
return true;
}
/**
* UI
*/
private void initResources() {
mHeadViewPanel = findViewById(R.id.note_title);
mNoteHeaderHolder = new HeadViewHolder();
@ -424,9 +565,23 @@ public class NoteEditActivity extends Activity implements OnClickListener,
mNoteHeaderHolder.ivAlertIcon = (ImageView) findViewById(R.id.iv_alert_icon);
mNoteHeaderHolder.tvAlertDate = (TextView) findViewById(R.id.tv_alert_date);
mNoteHeaderHolder.ibSetBgColor = (ImageView) findViewById(R.id.btn_set_bg_color);
mNoteHeaderHolder.ibSetBgColor.setOnClickListener(this);
if (mNoteHeaderHolder.ibSetBgColor != null) {
mNoteHeaderHolder.ibSetBgColor.setOnClickListener(this);
}
mNoteEditor = (NoteEditText) findViewById(R.id.note_edit_view);
mTvCharCount = (TextView) findViewById(R.id.tv_char_count);
mNoteEditorPanel = findViewById(R.id.sv_note_edit);
// 普通模式:编辑框内容变化时实时更新字数
mNoteEditor.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {}
@Override
public void afterTextChanged(Editable s) {
updateCharCount();
}
});
mNoteBgColorSelector = findViewById(R.id.note_bg_color_selector);
for (int id : sBgSelectorBtnsMap.keySet()) {
ImageView iv = (ImageView) findViewById(id);
@ -460,7 +615,10 @@ public class NoteEditActivity extends Activity implements OnClickListener,
clearSettingState();
}
// 更新桌面 Widget 内容
/**
*
* 便 Widget 广 2x 4x Widget
*/
private void updateWidget() {
Intent intent = new Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE);
if (mWorkingNote.getWidgetType() == Notes.TYPE_WIDGET_2X) {
@ -492,8 +650,25 @@ public class NoteEditActivity extends Activity implements OnClickListener,
showInsertImageDialog();
return;
}
// =================================
// === 富文本工具栏:对选中区域应用样式(仅运行时,不持久化) ===
if (id == R.id.btn_bold) {
if (mNoteEditor != null && mWorkingNote != null && mWorkingNote.getCheckListMode() != TextNote.MODE_CHECK_LIST) {
mNoteEditor.applyBoldToSelection();
}
return;
}
if (id == R.id.btn_font_size_up) {
if (mNoteEditor != null && mWorkingNote != null && mWorkingNote.getCheckListMode() != TextNote.MODE_CHECK_LIST) {
mNoteEditor.applyFontSizeUpToSelection();
}
return;
}
if (id == R.id.btn_font_size_down) {
if (mNoteEditor != null && mWorkingNote != null && mWorkingNote.getCheckListMode() != TextNote.MODE_CHECK_LIST) {
mNoteEditor.applyFontSizeDownToSelection();
}
return;
}
// =============================================
// [新增] AI 智能助手入口
// 点击放大镜图标,弹出功能选择对话框
@ -518,33 +693,50 @@ public class NoteEditActivity extends Activity implements OnClickListener,
// 处理更改背景色按钮
if (id == R.id.btn_set_bg_color) {
mNoteBgColorSelector.setVisibility(View.VISIBLE);
findViewById(sBgSelectorSelectionMap.get(mWorkingNote.getBgColorId())).setVisibility(
View.VISIBLE);
if (mNoteBgColorSelector != null && mWorkingNote != null) {
mNoteBgColorSelector.setVisibility(View.VISIBLE);
Integer selId = sBgSelectorSelectionMap.get(mWorkingNote.getBgColorId());
if (selId != null) {
View sel = findViewById(selId);
if (sel != null) sel.setVisibility(View.VISIBLE);
}
}
}
// 处理具体背景色点击
else if (sBgSelectorBtnsMap.containsKey(id)) {
findViewById(sBgSelectorSelectionMap.get(mWorkingNote.getBgColorId())).setVisibility(
View.GONE);
mWorkingNote.setBgColorId(sBgSelectorBtnsMap.get(id));
mNoteBgColorSelector.setVisibility(View.GONE);
if (mWorkingNote != null) {
Integer selId = sBgSelectorSelectionMap.get(mWorkingNote.getBgColorId());
if (selId != null) {
View sel = findViewById(selId);
if (sel != null) sel.setVisibility(View.GONE);
}
mWorkingNote.setBgColorId(sBgSelectorBtnsMap.get(id));
if (mNoteBgColorSelector != null) mNoteBgColorSelector.setVisibility(View.GONE);
}
}
// 处理具体字体大小点击
else if (sFontSizeBtnsMap.containsKey(id)) {
findViewById(sFontSelectorSelectionMap.get(mFontSizeId)).setVisibility(View.GONE);
Integer oldSelId = sFontSelectorSelectionMap.get(mFontSizeId);
if (oldSelId != null) {
View oldSel = findViewById(oldSelId);
if (oldSel != null) oldSel.setVisibility(View.GONE);
}
mFontSizeId = sFontSizeBtnsMap.get(id);
mSharedPrefs.edit().putInt(PREFERENCE_FONT_SIZE, mFontSizeId).commit();
findViewById(sFontSelectorSelectionMap.get(mFontSizeId)).setVisibility(View.VISIBLE);
Integer newSelId = sFontSelectorSelectionMap.get(mFontSizeId);
if (newSelId != null) {
View newSel = findViewById(newSelId);
if (newSel != null) newSel.setVisibility(View.VISIBLE);
}
// 字体改变后需要刷新视图
if (mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) {
if (mWorkingNote != null && mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) {
getWorkingText();
switchToListMode(mWorkingNote.getContent());
} else {
} else if (mNoteEditor != null) {
mNoteEditor.setTextAppearance(this,
TextAppearanceResources.getTexAppearanceResource(mFontSizeId));
}
mFontSizeSelector.setVisibility(View.GONE);
if (mFontSizeSelector != null) mFontSizeSelector.setVisibility(View.GONE);
}
}
@ -557,12 +749,16 @@ public class NoteEditActivity extends Activity implements OnClickListener,
super.onBackPressed();
}
// 隐藏设置面板
/**
*
*
* @return true false
*/
private boolean clearSettingState() {
if (mNoteBgColorSelector.getVisibility() == View.VISIBLE) {
if (mNoteBgColorSelector != null && mNoteBgColorSelector.getVisibility() == View.VISIBLE) {
mNoteBgColorSelector.setVisibility(View.GONE);
return true;
} else if (mFontSizeSelector.getVisibility() == View.VISIBLE) {
} else if (mFontSizeSelector != null && mFontSizeSelector.getVisibility() == View.VISIBLE) {
mFontSizeSelector.setVisibility(View.GONE);
return true;
}
@ -671,7 +867,12 @@ public class NoteEditActivity extends Activity implements OnClickListener,
d.show();
}
// 调用系统分享
/**
* Intent便
*
* @param context
* @param info
*/
private void sendTo(Context context, String info) {
Intent intent = new Intent(Intent.ACTION_SEND);
intent.putExtra(Intent.EXTRA_TEXT, info);
@ -679,7 +880,9 @@ public class NoteEditActivity extends Activity implements OnClickListener,
context.startActivity(intent);
}
// 保存当前并创建新便签
/**
* 便便
*/
private void createNewNote() {
saveNote();
finish();
@ -689,7 +892,10 @@ public class NoteEditActivity extends Activity implements OnClickListener,
startActivity(intent);
}
// 删除便签
/**
* 便
*
*/
private void deleteCurrentNote() {
if (mWorkingNote.existInDatabase()) {
HashSet<Long> ids = new HashSet<Long>();
@ -712,6 +918,12 @@ public class NoteEditActivity extends Activity implements OnClickListener,
mWorkingNote.markDeleted(true);
}
/**
* Google
*
*
* @return true
*/
private boolean isSyncMode() {
return NotesPreferenceActivity.getSyncAccountName(this).trim().length() > 0;
}
@ -721,15 +933,31 @@ public class NoteEditActivity extends Activity implements OnClickListener,
saveNote();
}
if (mWorkingNote.getNoteId() > 0) {
Intent intent = new Intent(this, AlarmReceiver.class);
if (set && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.POST_NOTIFICATIONS}, 0);
}
}
Intent intent = new Intent(this, NoteReminderReceiver.class);
intent.setData(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, mWorkingNote.getNoteId()));
PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 0, intent, 0);
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
flags |= PendingIntent.FLAG_IMMUTABLE;
}
int requestCode = (int) (mWorkingNote.getNoteId() % Integer.MAX_VALUE);
PendingIntent pendingIntent = PendingIntent.getBroadcast(this, requestCode, intent, flags);
AlarmManager alarmManager = ((AlarmManager) getSystemService(ALARM_SERVICE));
showAlertHeader();
if(!set) {
alarmManager.cancel(pendingIntent);
} else {
alarmManager.set(AlarmManager.RTC_WAKEUP, date, pendingIntent);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, date, pendingIntent);
} else {
alarmManager.set(AlarmManager.RTC_WAKEUP, date, pendingIntent);
}
}
} else {
Log.e(TAG, "Clock alert setting error");
@ -766,6 +994,7 @@ public class NoteEditActivity extends Activity implements OnClickListener,
edit.append(text);
edit.requestFocus();
edit.setSelection(length);
updateCharCount();
}
// 处理清单项回车(新增一行)
@ -783,6 +1012,7 @@ public class NoteEditActivity extends Activity implements OnClickListener,
((NoteEditText) mEditTextList.getChildAt(i).findViewById(R.id.et_edit_text))
.setIndex(i);
}
updateCharCount();
}
// 切换到清单模式:将文本拆分为列表项
@ -801,21 +1031,26 @@ public class NoteEditActivity extends Activity implements OnClickListener,
mNoteEditor.setVisibility(View.GONE);
mEditTextList.setVisibility(View.VISIBLE);
updateCharCount();
}
// 获取带有高亮的 Spannable 文本 (用于搜索结果高亮)
private Spannable getHighlightQueryResult(String fullText, String userQuery) {
SpannableString spannable = new SpannableString(fullText == null ? "" : fullText);
String safeText = fullText == null ? "" : fullText;
SpannableString spannable = new SpannableString(safeText);
if (!TextUtils.isEmpty(userQuery)) {
mPattern = Pattern.compile(userQuery);
Matcher m = mPattern.matcher(fullText);
int start = 0;
while (m.find(start)) {
spannable.setSpan(
new BackgroundColorSpan(this.getResources().getColor(
R.color.user_query_highlight)), m.start(), m.end(),
Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
start = m.end();
try {
mPattern = Pattern.compile(userQuery);
Matcher m = mPattern.matcher(safeText);
int start = 0;
while (m.find(start)) {
spannable.setSpan(
new BackgroundColorSpan(ContextCompat.getColor(this, R.color.user_query_highlight)),
m.start(), m.end(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
start = m.end();
}
} catch (Exception e) {
Log.w(TAG, "getHighlightQueryResult: " + e.getMessage());
}
}
return spannable;
@ -850,6 +1085,17 @@ public class NoteEditActivity extends Activity implements OnClickListener,
edit.setOnTextViewChangeListener(this);
edit.setIndex(index);
edit.setText(getHighlightQueryResult(item, mUserQuery));
// 清单模式:每项内容变化时实时更新字数
edit.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {}
@Override
public void afterTextChanged(Editable s) {
updateCharCount();
}
});
return view;
}
@ -863,6 +1109,7 @@ public class NoteEditActivity extends Activity implements OnClickListener,
} else {
mEditTextList.getChildAt(index).findViewById(R.id.cb_edit_item).setVisibility(View.GONE);
}
updateCharCount();
}
public void onCheckListModeChanged(int oldMode, int newMode) {
@ -877,8 +1124,15 @@ public class NoteEditActivity extends Activity implements OnClickListener,
mEditTextList.setVisibility(View.GONE);
mNoteEditor.setVisibility(View.VISIBLE);
}
updateCharCount();
}
/**
* UI WorkingNote
* /
*
* @return
*/
private boolean getWorkingText() {
boolean hasChecked = false;
if (mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) {
@ -912,6 +1166,10 @@ public class NoteEditActivity extends Activity implements OnClickListener,
return saved;
}
/**
* 便
* 便 noteId
*/
private void sendToDesktop() {
if (!mWorkingNote.existInDatabase()) {
saveNote();
@ -937,6 +1195,12 @@ public class NoteEditActivity extends Activity implements OnClickListener,
}
}
/**
* 使 10
*
* @param content 便
* @return
*/
private String makeShortcutIconTitle(String content) {
content = content.replace(TAG_CHECKED, "");
content = content.replace(TAG_UNCHECKED, "");
@ -944,10 +1208,19 @@ public class NoteEditActivity extends Activity implements OnClickListener,
SHORTCUT_ICON_TITLE_MAX_LEN) : content;
}
/**
* Toast
*/
private void showToast(int resId) {
showToast(resId, Toast.LENGTH_SHORT);
}
/**
* Toast
*
* @param resId ID
* @param duration Toast.LENGTH_SHORT Toast.LENGTH_LONG
*/
private void showToast(int resId, int duration) {
Toast.makeText(this, resId, duration).show();
}
@ -1087,31 +1360,51 @@ public class NoteEditActivity extends Activity implements OnClickListener,
// 2. 启动相机
private void takePhoto() {
Intent takePictureIntent = new Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE);
// 确保有相机应用能处理这个 Intent
if (takePictureIntent.resolveActivity(getPackageManager()) != null) {
try {
// 使用 MediaUtils 创建临时文件 (高内聚Activity 不关心文件怎么创建的)
java.io.File photoFile = net.micode.notes.tool.MediaUtils.createImageFile(this);
mCurrentPhotoPath = photoFile.getAbsolutePath();
// 获取安全的 Content Uri (使用我们刚配置好的 FileProvider)
android.net.Uri photoURI = androidx.core.content.FileProvider.getUriForFile(this,
"net.micode.notes.fileprovider",
photoFile);
takePictureIntent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, photoURI);
startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PHOTO);
} catch (java.io.IOException ex) {
Toast.makeText(this, "创建图片文件失败", Toast.LENGTH_SHORT).show();
}
} else {
android.content.pm.ResolveInfo resolveInfo = getPackageManager().resolveActivity(
takePictureIntent, android.content.pm.PackageManager.MATCH_DEFAULT_ONLY);
if (resolveInfo == null || resolveInfo.activityInfo == null) {
Toast.makeText(this, "未找到相机应用", Toast.LENGTH_SHORT).show();
return;
}
try {
java.io.File photoFile = net.micode.notes.tool.MediaUtils.createImageFile(this);
mCurrentPhotoPath = photoFile.getCanonicalPath();
android.net.Uri photoURI = androidx.core.content.FileProvider.getUriForFile(this,
"net.micode.notes.fileprovider",
photoFile);
takePictureIntent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, photoURI);
takePictureIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
takePictureIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
// Android 7.1+ 部分机型依赖 ClipData 传递 URI 权限
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) {
takePictureIntent.setClipData(android.content.ClipData.newUri(getContentResolver(), "", photoURI));
}
// 显式授予已解析的相机应用对该 URI 的写权限,提高兼容性
grantUriPermission(resolveInfo.activityInfo.packageName, photoURI,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PHOTO);
} catch (java.io.IOException ex) {
Log.e(TAG, "takePhoto createImageFile", ex);
Toast.makeText(this, "创建图片文件失败", Toast.LENGTH_SHORT).show();
} catch (IllegalArgumentException ex) {
Log.e(TAG, "takePhoto FileProvider URI", ex);
Toast.makeText(this, "无法创建拍照存储路径", Toast.LENGTH_SHORT).show();
} catch (Exception ex) {
Log.e(TAG, "takePhoto", ex);
Toast.makeText(this, "无法启动相机: " + ex.getMessage(), Toast.LENGTH_SHORT).show();
}
}
// 3. 打开相册
// 3. 打开相册(使用 GET_CONTENT 以更好兼容截图、微信等来源的图片)
private void pickFromGallery() {
Intent intent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.setType("image/*");
intent.addCategory(Intent.CATEGORY_OPENABLE);
// 确保选图器返回的 Uri 可被本应用读取(部分相册/文件管理器依赖此标志)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
startActivityForResult(intent, REQUEST_CODE_OPEN_ALBUM);
}
@Override
@ -1119,26 +1412,33 @@ public class NoteEditActivity extends Activity implements OnClickListener,
if (resultCode == RESULT_OK) {
switch (requestCode) {
case REQUEST_CODE_TAKE_PHOTO:
if (mCurrentPhotoPath != null) {
// === [修改] 调用 NoteEditText 的接口插入图片 ===
if (mCurrentPhotoPath != null && mNoteEditor != null) {
mNoteEditor.insertImage(mCurrentPhotoPath);
// 【新增】立刻把含标签的新文本保存到 Model防止 onResume 覆盖
mWorkingNote.setWorkingText(mNoteEditor.getText().toString());
mNoteEditor.setTextWithImages(mNoteEditor.getText().toString());
if (mWorkingNote != null) {
mWorkingNote.setWorkingText(mNoteEditor.getText().toString());
}
}
mCurrentPhotoPath = null;
break;
case REQUEST_CODE_OPEN_ALBUM:
if (data != null && data.getData() != null) {
android.net.Uri selectedImage = data.getData();
try {
final int takeFlags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
if (takeFlags != 0) {
getContentResolver().takePersistableUriPermission(selectedImage, takeFlags);
}
} catch (SecurityException ignored) { }
String localPath = net.micode.notes.tool.MediaUtils.copyUriToInternalStorage(this, selectedImage);
if (localPath != null) {
mNoteEditor.insertImage(localPath);
// 【新增】立刻保存到 Model
mNoteEditor.setTextWithImages(mNoteEditor.getText().toString());
mWorkingNote.setWorkingText(mNoteEditor.getText().toString());
} else {
Toast.makeText(this, R.string.error_image_copy_failed, Toast.LENGTH_SHORT).show();
}
// ============================================
}
break;

@ -7,10 +7,15 @@ package net.micode.notes.ui;
import android.content.Context;
import android.graphics.Rect;
import android.graphics.Typeface;
import android.text.Layout;
import android.text.Selection;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.RelativeSizeSpan;
import android.text.style.StyleSpan;
import android.text.style.URLSpan;
import android.util.AttributeSet;
import android.util.Log;
@ -19,6 +24,7 @@ import android.view.KeyEvent;
import android.view.MenuItem;
import android.view.MenuItem.OnMenuItemClickListener;
import android.view.MotionEvent;
import android.view.View;
import android.widget.EditText;
import android.widget.Toast;
@ -85,6 +91,13 @@ public class NoteEditText extends EditText {
private OnTextViewChangeListener mOnTextViewChangeListener;
/** 伪 Placeholder当光标定位到该行且内容完全匹配时自动清空该行 */
private String mPlaceholderLine1;
private String mPlaceholderLine2;
/**
* 0
*/
public NoteEditText(Context context) {
super(context, null);
mIndex = 0;
@ -111,6 +124,13 @@ public class NoteEditText extends EditText {
e.printStackTrace();
}
}
/**
* &lt;img src="路径"/&gt; CenterImageSpan
* 便
*
* @param text img
*/
public void setTextWithImages(String text) {
// 1. 先设置纯文本
setText(text);
@ -154,23 +174,78 @@ public class NoteEditText extends EditText {
e.printStackTrace();
}
}
// 强制重新测量与绘制,避免长文本/多图时高度未更新导致内容不可见
requestLayout();
post(new Runnable() {
@Override
public void run() {
invalidate();
}
});
}
/**
* Enter/Delete
*
* @param index 0
*/
public void setIndex(int index) {
mIndex = index;
}
/**
*
*
* @param listener null
*/
public void setOnTextViewChangeListener(OnTextViewChangeListener listener) {
mOnTextViewChangeListener = listener;
}
/**
* line1 line2 便
*/
public void setPlaceholderLinesForClear(String line1, String line2) {
mPlaceholderLine1 = line1;
mPlaceholderLine2 = line2;
}
/**
* XML inflate 使
*/
public NoteEditText(Context context, AttributeSet attrs) {
super(context, attrs, android.R.attr.editTextStyle);
}
/**
* defStyle
*/
public NoteEditText(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
/**
* EditText 36
* AT_MOST() DynamicLayout 35
* Layout AT_MOST UNSPECIFIED
* measuredHeight
*/
@Override
protected void onMeasure(int widthSpec, int heightSpec) {
int heightMode = View.MeasureSpec.getMode(heightSpec);
if (heightMode == View.MeasureSpec.AT_MOST) {
int unboundedHeight = View.MeasureSpec.makeMeasureSpec(0x3FFFFFFF, View.MeasureSpec.UNSPECIFIED);
super.onMeasure(widthSpec, unboundedHeight);
} else {
super.onMeasure(widthSpec, heightSpec);
}
Layout layout = getLayout();
if (layout != null) {
int contentHeight = layout.getHeight() + getCompoundPaddingTop() + getCompoundPaddingBottom();
setMeasuredDimension(getMeasuredWidth(), contentHeight);
}
}
/**
*
* <p>
@ -190,15 +265,50 @@ public class NoteEditText extends EditText {
y += getScrollY();
Layout layout = getLayout();
int line = layout.getLineForVertical(y);
int off = layout.getOffsetForHorizontal(line, x);
Selection.setSelection(getText(), off);
if (layout != null) {
int line = layout.getLineForVertical(y);
int off = layout.getOffsetForHorizontal(line, x);
Selection.setSelection(getText(), off);
// 伪 Placeholder若当前行内容为占位文案则清空该行
post(new Runnable() {
@Override
public void run() {
checkAndClearPlaceholderLine();
}
});
}
break;
}
return super.onTouchEvent(event);
}
/**
* mPlaceholderLine1 mPlaceholderLine2
*
*/
private void checkAndClearPlaceholderLine() {
if (TextUtils.isEmpty(mPlaceholderLine1) && TextUtils.isEmpty(mPlaceholderLine2)) return;
android.text.Editable text = getText();
if (text == null) return;
Layout layout = getLayout();
if (layout == null) return;
int selStart = getSelectionStart();
if (selStart < 0) return;
int line = layout.getLineForOffset(selStart);
int lineStart = layout.getLineStart(line);
int lineEnd = layout.getLineEnd(line);
String lineContent = text.subSequence(lineStart, lineEnd).toString().trim();
if (lineContent.equals(mPlaceholderLine1) || lineContent.equals(mPlaceholderLine2)) {
// 只替换占位文案,保留行尾换行符,避免下一行(如时间)顶上来
int replaceEnd = lineEnd;
if (lineEnd > lineStart && lineEnd <= text.length() && text.charAt(lineEnd - 1) == '\n') {
replaceEnd = lineEnd - 1;
}
text.replace(lineStart, replaceEnd, "");
}
}
/**
*
* onKeyUp
@ -314,4 +424,88 @@ public class NoteEditText extends EditText {
}
super.onCreateContextMenu(menu);
}
// ================= 富文本工具栏:加粗、字号变大、字号变小(仅运行时效果,不持久化) =================
/**
* (StyleSpan BOLD)
*
*/
public void applyBoldToSelection() {
applySpanToSelection(new StyleSpan(Typeface.BOLD), StyleSpan.class, true);
}
/**
* 1.2 (RelativeSizeSpan)
*/
public void applyFontSizeUpToSelection() {
applyRelativeSizeToSelection(1.2f);
}
/**
* 0.8 (RelativeSizeSpan)
*/
public void applyFontSizeDownToSelection() {
applyRelativeSizeToSelection(0.8f);
}
/**
* RelativeSizeSpan
* span
*
* @param proportion 1.2f 1.2
*/
private void applyRelativeSizeToSelection(float proportion) {
android.text.Editable text = getText();
if (text == null || !(text instanceof Spannable)) return;
int start = getSelectionStart();
int end = getSelectionEnd();
if (start < 0 || end < 0 || start == end) return;
if (start > end) { int t = start; start = end; end = t; }
Spannable sp = (Spannable) text;
RelativeSizeSpan[] existing = sp.getSpans(start, end, RelativeSizeSpan.class);
// 若选区已有相同比例的 RelativeSizeSpan 则移除,否则添加
boolean hasSame = false;
for (RelativeSizeSpan s : existing) {
if (Math.abs(s.getSizeChange() - proportion) < 0.01f) {
sp.removeSpan(s);
hasSame = true;
}
}
if (!hasSame) {
sp.setSpan(new RelativeSizeSpan(proportion), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
/**
* Span
* @param addSpan span
* @param spanClass / span
* @param toggle true span
*/
private void applySpanToSelection(Object addSpan, Class<?> spanClass, boolean toggle) {
android.text.Editable text = getText();
if (text == null || !(text instanceof Spannable)) return;
int start = getSelectionStart();
int end = getSelectionEnd();
if (start < 0 || end < 0 || start == end) return;
if (start > end) { int t = start; start = end; end = t; }
Spannable sp = (Spannable) text;
Object[] existing = sp.getSpans(start, end, spanClass);
boolean allCovered = true;
for (Object s : existing) {
int sStart = sp.getSpanStart(s);
int sEnd = sp.getSpanEnd(s);
if (sStart > start || sEnd < end) {
allCovered = false;
break;
}
}
if (existing.length == 0) allCovered = false;
if (toggle && allCovered && existing.length > 0) {
for (Object s : existing) sp.removeSpan(s);
} else {
sp.setSpan(addSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
}

@ -39,6 +39,7 @@ public class NoteItemData {
NoteColumns.TYPE,
NoteColumns.WIDGET_ID,
NoteColumns.WIDGET_TYPE,
NoteColumns.PINNED,
};
// 列索引常量,对应 PROJECTION 数组
@ -54,6 +55,7 @@ public class NoteItemData {
private static final int TYPE_COLUMN = 9;
private static final int WIDGET_ID_COLUMN = 10;
private static final int WIDGET_TYPE_COLUMN = 11;
private static final int PINNED_COLUMN = 12;
// 数据字段
private long mId;
@ -68,6 +70,7 @@ public class NoteItemData {
private int mType; // 类型 (便签/文件夹)
private int mWidgetId;
private int mWidgetType;
private boolean mPinned; // 是否置顶
private String mName; // 联系人名字 (仅通话记录便签有效)
private String mPhoneNumber; // 电话号码 (仅通话记录便签有效)
@ -99,6 +102,7 @@ public class NoteItemData {
mType = cursor.getInt(TYPE_COLUMN);
mWidgetId = cursor.getInt(WIDGET_ID_COLUMN);
mWidgetType = cursor.getInt(WIDGET_TYPE_COLUMN);
mPinned = (cursor.getInt(PINNED_COLUMN) > 0);
mPhoneNumber = "";
// 特殊逻辑:如果是通话记录文件夹下的便签,需要查找联系人名称
@ -240,6 +244,10 @@ public class NoteItemData {
return (mAlertDate > 0);
}
public boolean isPinned() {
return mPinned;
}
public boolean isCallRecord() {
return (mParentId == Notes.ID_CALL_RECORD_FOLDER && !TextUtils.isEmpty(mPhoneNumber));
}

@ -0,0 +1,80 @@
package net.micode.notes.ui;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat;
import net.micode.notes.R;
import net.micode.notes.tool.DataUtils;
/**
* 便
* Android 13+
*/
public final class NoteReminderNotifier {
private static final String TAG = "NoteReminderNotifier";
public static final String CHANNEL_ID = "note_reminder_channel";
private static final int SNIPPET_MAX_LEN = 40;
public static void show(Context context, long noteId, Uri noteUri) {
if (context == null || noteUri == null) return;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS)
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
Log.d(TAG, "POST_NOTIFICATIONS not granted");
return;
}
}
String snippet;
try {
snippet = DataUtils.getSnippetById(context.getContentResolver(), noteId);
if (snippet == null) snippet = "";
snippet = snippet.trim();
if (snippet.length() > SNIPPET_MAX_LEN) {
snippet = snippet.substring(0, SNIPPET_MAX_LEN) + "…";
}
} catch (Exception e) {
snippet = context.getString(R.string.note_reminder_default_text);
}
NotificationManager nm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
if (nm == null) return;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
context.getString(R.string.note_reminder_channel_name),
NotificationManager.IMPORTANCE_HIGH);
channel.setDescription(context.getString(R.string.note_reminder_channel_desc));
nm.createNotificationChannel(channel);
}
Intent openIntent = new Intent(context, AlarmAlertActivity.class);
openIntent.setData(noteUri);
openIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent pending = PendingIntent.getActivity(
context,
(int) (noteId % Integer.MAX_VALUE),
openIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.icon_app)
.setContentTitle(context.getString(R.string.note_reminder_title))
.setContentText(snippet.isEmpty() ? context.getString(R.string.note_reminder_default_text) : snippet)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setAutoCancel(true)
.setContentIntent(pending);
try {
NotificationManagerCompat.from(context).notify((int) (noteId % Integer.MAX_VALUE), builder.build());
} catch (SecurityException e) {
Log.e(TAG, "Cannot show notification: " + e.getMessage());
}
}
}

@ -0,0 +1,40 @@
package net.micode.notes.ui;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.util.Log;
/**
* 便广
* 使 Receiver
*/
public class NoteReminderReceiver extends BroadcastReceiver {
private static final String TAG = "NoteReminderReceiver";
@Override
public void onReceive(Context context, Intent intent) {
Uri data = intent.getData();
if (data == null || data.getPathSegments() == null || data.getPathSegments().size() < 2) {
Log.e(TAG, "Alarm intent has no note data");
return;
}
long noteId;
try {
noteId = Long.parseLong(data.getPathSegments().get(1));
} catch (NumberFormatException e) {
Log.e(TAG, "Invalid note id in alarm intent", e);
return;
}
NoteReminderNotifier.show(context, noteId, data);
Intent alertIntent = new Intent(context, AlarmAlertActivity.class);
alertIntent.setData(data);
alertIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
try {
context.startActivity(alertIntent);
} catch (Exception e) {
Log.d(TAG, "Could not start AlarmAlertActivity: " + e.getMessage());
}
}
}

@ -27,22 +27,21 @@ 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.MenuItem;
import android.view.MenuItem.OnMenuItemClickListener;
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.inputmethod.InputMethodManager;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.AdapterView.OnItemLongClickListener;
import android.widget.Button;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import android.widget.EditText;
import android.widget.ListView;
import android.widget.PopupMenu;
@ -87,6 +86,8 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt
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 int MENU_FOLDER_PIN = 3;
private static final int MENU_FOLDER_UNPIN = 4;
private static final String PREFERENCE_ADD_INTRODUCTION = "net.micode.notes.introduction";
@ -104,11 +105,7 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt
private NotesListAdapter mNotesListAdapter;
private ListView mNotesListView;
private Button mAddNewNote;
private boolean mDispatch;
private int mOriginY;
private int mDispatchY;
private FloatingActionButton mFab;
private TextView mTitleBar;
private long mCurrentFolderId; // 当前所在的文件夹 ID
@ -162,11 +159,13 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt
if (!sp.getBoolean(PREFERENCE_ADD_INTRODUCTION, false)) {
StringBuilder sb = new StringBuilder();
InputStream in = null;
InputStreamReader isr = null;
BufferedReader br = null;
try {
in = getResources().openRawResource(R.raw.introduction);
if (in != null) {
InputStreamReader isr = new InputStreamReader(in);
BufferedReader br = new BufferedReader(isr);
isr = new InputStreamReader(in);
br = new BufferedReader(isr);
char [] buf = new char[1024];
int len = 0;
while ((len = br.read(buf)) > 0) {
@ -180,12 +179,14 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt
e.printStackTrace();
return;
} finally {
if(in != null) {
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
if (br != null) {
try { br.close(); } catch (IOException e) { e.printStackTrace(); }
}
if (isr != null) {
try { isr.close(); } catch (IOException e) { e.printStackTrace(); }
}
if (in != null) {
try { in.close(); } catch (IOException e) { e.printStackTrace(); }
}
}
@ -220,12 +221,10 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt
mNotesListView.setOnItemLongClickListener(this);
mNotesListAdapter = new NotesListAdapter(this);
mNotesListView.setAdapter(mNotesListAdapter);
mAddNewNote = (Button) findViewById(R.id.btn_new_note);
mAddNewNote.setOnClickListener(this);
mAddNewNote.setOnTouchListener(new NewNoteOnTouchListener());
mDispatch = false;
mDispatchY = 0;
mOriginY = 0;
mFab = (FloatingActionButton) findViewById(R.id.btn_fab_new_note);
if (mFab != null) {
mFab.setOnClickListener(this);
}
mTitleBar = (TextView) findViewById(R.id.tv_title_bar);
mState = ListEditState.NOTE_LIST;
mModeCallBack = new ModeCallback();
@ -243,6 +242,8 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
getMenuInflater().inflate(R.menu.note_list_options, menu);
menu.findItem(R.id.delete).setOnMenuItemClickListener(this);
menu.findItem(R.id.pin_to_top).setOnMenuItemClickListener(this);
menu.findItem(R.id.unpin).setOnMenuItemClickListener(this);
mMoveMenu = menu.findItem(R.id.move);
if (mFocusNoteDataItem.getParentId() == Notes.ID_CALL_RECORD_FOLDER
|| DataUtils.getUserFolderCount(mContentResolver) == 0) {
@ -254,7 +255,7 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt
mActionMode = mode;
mNotesListAdapter.setChoiceMode(true);
mNotesListView.setLongClickable(false);
mAddNewNote.setVisibility(View.GONE);
if (mFab != null) mFab.setVisibility(View.GONE);
// 自定义 ActionMode 的标题栏,植入全选下拉菜单
View customView = LayoutInflater.from(NotesListActivity.this).inflate(
@ -301,7 +302,7 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt
public void onDestroyActionMode(ActionMode mode) {
mNotesListAdapter.setChoiceMode(false);
mNotesListView.setLongClickable(true);
mAddNewNote.setVisibility(View.VISIBLE);
if (mFab != null) mFab.setVisibility(View.VISIBLE);
}
public void finishActionMode() {
@ -341,6 +342,12 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt
case R.id.move:
startQueryDestinationFolders();
break;
case R.id.pin_to_top:
batchSetPinned(true);
break;
case R.id.unpin:
batchSetPinned(false);
break;
default:
return false;
}
@ -348,63 +355,16 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt
}
}
/**
* 便
* <p>
* (HACKME)
* "New Note" 穿
* Dispatch ListView
* y = -0.12x + 94 UI
*/
private class NewNoteOnTouchListener implements OnTouchListener {
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
Display display = 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 batchSetPinned(boolean pinned) {
HashSet<Long> ids = mNotesListAdapter.getSelectedItemIds();
if (ids == null || ids.isEmpty()) return;
if (DataUtils.batchSetPinned(mContentResolver, ids, pinned)) {
Toast.makeText(this, pinned ? getString(R.string.toast_pinned) : getString(R.string.toast_unpinned),
Toast.LENGTH_SHORT).show();
startAsyncNotesListQuery();
mModeCallBack.finishActionMode();
}
};
}
/**
* 便 ()
@ -422,7 +382,7 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt
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");
}, NoteColumns.PINNED + " DESC," + NoteColumns.TYPE + " DESC," + NoteColumns.BG_COLOR_ID + " DESC," + NoteColumns.MODIFIED_DATE + " DESC");
}
private final class BackgroundQueryHandler extends AsyncQueryHandler {
@ -552,9 +512,10 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt
startAsyncNotesListQuery(); // 刷新列表,查询子文件夹内容
if (data.getId() == Notes.ID_CALL_RECORD_FOLDER) {
mState = ListEditState.CALL_RECORD_FOLDER;
mAddNewNote.setVisibility(View.GONE);
if (mFab != null) mFab.setVisibility(View.GONE);
} else {
mState = ListEditState.SUB_FOLDER;
if (mFab != null) mFab.setVisibility(View.VISIBLE);
}
if (data.getId() == Notes.ID_CALL_RECORD_FOLDER) {
mTitleBar.setText(R.string.call_record_folder_name);
@ -565,12 +526,8 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt
}
public void onClick(View v) {
switch (v.getId()) {
case R.id.btn_new_note:
createNewNote();
break;
default:
break;
if (v.getId() == R.id.btn_fab_new_note) {
createNewNote();
}
}
@ -677,7 +634,7 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt
case CALL_RECORD_FOLDER:
mCurrentFolderId = Notes.ID_ROOT_FOLDER;
mState = ListEditState.NOTE_LIST;
mAddNewNote.setVisibility(View.VISIBLE);
if (mFab != null) mFab.setVisibility(View.VISIBLE);
mTitleBar.setVisibility(View.GONE);
startAsyncNotesListQuery();
break;
@ -713,6 +670,11 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt
if (mFocusNoteDataItem != null) {
menu.setHeaderTitle(mFocusNoteDataItem.getSnippet());
menu.add(0, MENU_FOLDER_VIEW, 0, R.string.menu_folder_view);
if (mFocusNoteDataItem.isPinned()) {
menu.add(0, MENU_FOLDER_UNPIN, 0, R.string.menu_unpin);
} else {
menu.add(0, MENU_FOLDER_PIN, 0, R.string.menu_pin_to_top);
}
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);
}
@ -754,6 +716,26 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt
case MENU_FOLDER_CHANGE_NAME:
showCreateOrModifyFolderDialog(false);
break;
case MENU_FOLDER_PIN:
if (mFocusNoteDataItem != null && mFocusNoteDataItem.getId() > 0) {
HashSet<Long> ids = new HashSet<Long>();
ids.add(mFocusNoteDataItem.getId());
if (DataUtils.batchSetPinned(mContentResolver, ids, true)) {
Toast.makeText(this, getString(R.string.toast_pinned), Toast.LENGTH_SHORT).show();
startAsyncNotesListQuery();
}
}
break;
case MENU_FOLDER_UNPIN:
if (mFocusNoteDataItem != null && mFocusNoteDataItem.getId() > 0) {
HashSet<Long> ids = new HashSet<Long>();
ids.add(mFocusNoteDataItem.getId());
if (DataUtils.batchSetPinned(mContentResolver, ids, false)) {
Toast.makeText(this, getString(R.string.toast_unpinned), Toast.LENGTH_SHORT).show();
startAsyncNotesListQuery();
}
}
break;
default:
break;
}

@ -98,6 +98,11 @@ public class NotesListAdapter extends CursorAdapter {
notifyDataSetChanged(); // 刷新 UI 以显示勾选框变化
}
/**
*
*
* @return true
*/
public boolean isInChoiceMode() {
return mChoiceMode;
}

@ -8,6 +8,8 @@ package net.micode.notes.ui;
import android.content.Context;
import android.text.format.DateUtils;
import android.view.View;
import androidx.core.content.ContextCompat;
import android.widget.CheckBox;
import android.widget.ImageView;
import android.widget.LinearLayout;
@ -17,6 +19,7 @@ import net.micode.notes.R;
import net.micode.notes.data.Notes;
import net.micode.notes.tool.DataUtils;
import net.micode.notes.tool.ResourceParser.NoteItemBgResources;
import net.micode.notes.tool.ResourceParser;
/**
* 便 (UI Component)
@ -34,6 +37,7 @@ public class NotesListItem extends LinearLayout {
private TextView mCallName; // 联系人名称 (仅通话记录便签显示)
private NoteItemData mItemData;
private CheckBox mCheckBox; // 批量选择模式下的复选框
private View mPinnedBar; // 置顶时左侧加深色条
public NotesListItem(Context context) {
super(context);
@ -44,6 +48,7 @@ public class NotesListItem extends LinearLayout {
mTime = (TextView) findViewById(R.id.tv_time);
mCallName = (TextView) findViewById(R.id.tv_name);
mCheckBox = (CheckBox) findViewById(android.R.id.checkbox);
mPinnedBar = findViewById(R.id.note_pinned_bar);
}
/**
@ -116,6 +121,17 @@ public class NotesListItem extends LinearLayout {
// 设置相对时间显示 (例如 "刚刚", "昨天")
mTime.setText(DateUtils.getRelativeTimeSpanString(data.getModifiedDate()));
// 置顶时显示左侧加深色条
if (data.isPinned() && mPinnedBar != null) {
mPinnedBar.setVisibility(View.VISIBLE);
int colorResId = (data.getType() == Notes.TYPE_FOLDER || data.getType() == Notes.TYPE_SYSTEM)
? ResourceParser.NoteItemBgResources.getPinnedBarColorRes(ResourceParser.YELLOW)
: ResourceParser.NoteItemBgResources.getPinnedBarColorRes(data.getBgColorId());
mPinnedBar.setBackgroundColor(ContextCompat.getColor(context, colorResId));
} else if (mPinnedBar != null) {
mPinnedBar.setVisibility(View.GONE);
}
// 设置动态背景
setBackground(data);
}

@ -0,0 +1,168 @@
package net.micode.notes.ui;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.content.Intent;
import android.graphics.Color;
import android.graphics.PorterDuff;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.view.View;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.view.animation.LinearInterpolator;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
import net.micode.notes.R;
/**
*
* + Logo + + 线
* 退 ANR使 post GlobalLayoutListener
*/
public class SplashActivity extends AppCompatActivity {
private static final int PHASE_ENTER_MS = 900;
private static final int HOLD_MS = 1100;
private static final int PHASE_EXIT_MS = 450;
/** 最大等待时间,超时则直接进入主界面,防止卡死 */
private static final int SAFETY_TIMEOUT_MS = 4000;
private View mContentRoot;
private ImageView mLogo;
private TextView mTitle;
private View mAccent;
private final Handler mHandler = new Handler(Looper.getMainLooper());
private boolean mEnterAnimationStarted;
private boolean mGoMainCalled;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_splash);
mContentRoot = findViewById(R.id.splash_content);
mLogo = findViewById(R.id.splash_logo);
mTitle = findViewById(R.id.splash_title);
mAccent = findViewById(R.id.splash_accent);
if (mContentRoot == null || mLogo == null || mTitle == null || mAccent == null) {
goToMain();
return;
}
if (mLogo != null) {
mLogo.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN);
}
// 使用 post 在下一帧启动动画,避免与 GlobalLayoutListener 的 API 差异导致不触发
mContentRoot.post(this::startEnterAnimation);
// 安全超时:若动画或跳转异常,最多等待 SAFETY_TIMEOUT_MS 后强制进入主界面
mHandler.postDelayed(this::goToMain, SAFETY_TIMEOUT_MS);
}
private void startEnterAnimation() {
if (mEnterAnimationStarted || isFinishing()) return;
if (mLogo == null || mTitle == null || mAccent == null) {
goToMain();
return;
}
mEnterAnimationStarted = true;
// 1. Logo缩放 + 淡入
mLogo.setScaleX(0.82f);
mLogo.setScaleY(0.82f);
mLogo.setAlpha(0f);
ObjectAnimator scaleX = ObjectAnimator.ofFloat(mLogo, View.SCALE_X, 0.82f, 1f);
ObjectAnimator scaleY = ObjectAnimator.ofFloat(mLogo, View.SCALE_Y, 0.82f, 1f);
ObjectAnimator alphaLogo = ObjectAnimator.ofFloat(mLogo, View.ALPHA, 0f, 1f);
scaleX.setDuration(520);
scaleY.setDuration(520);
alphaLogo.setDuration(420);
AccelerateDecelerateInterpolator interpolator = new AccelerateDecelerateInterpolator();
scaleX.setInterpolator(interpolator);
scaleY.setInterpolator(interpolator);
alphaLogo.setInterpolator(interpolator);
scaleX.start();
scaleY.start();
alphaLogo.start();
// 2. 标题:延迟淡入 + 上移
mTitle.setAlpha(0f);
mTitle.setTranslationY(12f);
mTitle.animate()
.alpha(1f)
.translationY(0f)
.setStartDelay(280)
.setDuration(400)
.setInterpolator(interpolator)
.start();
// 3. 底部细线:淡入
mAccent.setAlpha(0f);
mAccent.animate()
.alpha(1f)
.setStartDelay(460)
.setDuration(320)
.setInterpolator(new LinearInterpolator())
.start();
// 4. 停留后退场并跳转
mHandler.postDelayed(this::startExitAnimation, PHASE_ENTER_MS + HOLD_MS);
}
private void startExitAnimation() {
if (isFinishing() || mGoMainCalled) return;
if (mContentRoot == null) {
goToMain();
return;
}
mContentRoot.animate()
.alpha(0f)
.setDuration(PHASE_EXIT_MS)
.setInterpolator(new AccelerateDecelerateInterpolator())
.setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
if (mContentRoot != null) {
mContentRoot.animate().setListener(null);
}
goToMain();
}
})
.start();
}
private void goToMain() {
if (mGoMainCalled) return;
mGoMainCalled = true;
mHandler.removeCallbacksAndMessages(null);
if (isFinishing()) return;
try {
startActivity(new Intent(this, MainActivity.class));
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out);
} catch (Exception e) {
overridePendingTransition(0, 0);
}
finish();
}
@Override
protected void onDestroy() {
mHandler.removeCallbacksAndMessages(null);
mContentRoot = null;
mLogo = null;
mTitle = null;
mAccent = null;
super.onDestroy();
}
}

@ -0,0 +1,215 @@
package net.micode.notes.ui;
/**
* /
* <p>
* DatePicker + TimePickerGeofence
* TodoReminderManager AlarmManager/Geofencing
* </p>
*
* @see TodoItem
* @see TodoReminderManager
* @see TodoDao
*/
import android.Manifest;
import android.app.DatePickerDialog;
import android.app.TimePickerDialog;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.text.format.DateFormat;
import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import android.widget.EditText;
import net.micode.notes.R;
import net.micode.notes.data.TodoDao;
import net.micode.notes.todo.TodoItem;
import net.micode.notes.todo.TodoReminderManager;
import java.util.Calendar;
public class TodoEditActivity extends AppCompatActivity {
private static final String EXTRA_TODO_ID = "todo_id";
private EditText mEtContent;
private Button mBtnAddReminder;
private TextView mTvReminderInfo;
private Button mBtnSave;
private TodoDao mTodoDao;
private TodoItem mItem;
private boolean mIsEdit;
private final ActivityResultLauncher<String[]> mLocationPermissionLauncher =
registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), result -> {
if (Boolean.TRUE.equals(result.get(Manifest.permission.ACCESS_FINE_LOCATION))) {
applyMockLocation();
} else {
Toast.makeText(this, "需要位置权限才能设置地点提醒", Toast.LENGTH_SHORT).show();
}
});
public static Intent newIntent(Context context) {
return new Intent(context, TodoEditActivity.class);
}
public static Intent newIntent(Context context, long todoId) {
Intent i = new Intent(context, TodoEditActivity.class);
i.putExtra(EXTRA_TODO_ID, todoId);
return i;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_todo_edit);
if (getSupportActionBar() != null) getSupportActionBar().setDisplayHomeAsUpEnabled(true);
mTodoDao = new TodoDao(this);
Intent intent = getIntent();
long todoId = (intent != null) ? intent.getLongExtra(EXTRA_TODO_ID, 0) : 0;
mIsEdit = todoId > 0;
mItem = mIsEdit ? mTodoDao.getById(todoId) : new TodoItem();
if (mItem == null) mItem = new TodoItem();
mEtContent = findViewById(R.id.et_todo_content);
mBtnAddReminder = findViewById(R.id.btn_add_reminder);
mTvReminderInfo = findViewById(R.id.tv_reminder_info);
mBtnSave = findViewById(R.id.btn_save);
if (mIsEdit) mEtContent.setText(mItem.getContent());
updateReminderInfo();
mBtnAddReminder.setOnClickListener(v -> showReminderOptions());
mBtnSave.setOnClickListener(v -> save());
}
@Override
public boolean onSupportNavigateUp() {
finish();
return true;
}
private void showReminderOptions() {
String[] options = new String[]{
getString(R.string.todo_reminder_time),
getString(R.string.todo_reminder_location),
getString(R.string.todo_reminder_none)
};
new android.app.AlertDialog.Builder(this)
.setTitle(R.string.todo_add_reminder)
.setItems(options, (dialog, which) -> {
if (which == 0) pickTime();
else if (which == 1) pickLocation();
else clearReminder();
})
.show();
}
private void clearReminder() {
mItem.setReminderType(TodoItem.REMINDER_NONE);
mItem.setReminderTimestamp(0);
mItem.setLatitude(0);
mItem.setLongitude(0);
mItem.setLocationName("");
updateReminderInfo();
}
private void pickTime() {
Calendar cal = Calendar.getInstance();
if (mItem.getReminderTimestamp() > 0) cal.setTimeInMillis(mItem.getReminderTimestamp());
DatePickerDialog dateDialog = new DatePickerDialog(this,
(v, year, month, dayOfMonth) -> {
cal.set(year, month, dayOfMonth);
TimePickerDialog timeDialog = new TimePickerDialog(this,
(tv, hour, minute) -> {
cal.set(Calendar.HOUR_OF_DAY, hour);
cal.set(Calendar.MINUTE, minute);
cal.set(Calendar.SECOND, 0);
mItem.setReminderType(TodoItem.REMINDER_TIME);
mItem.setReminderTimestamp(cal.getTimeInMillis());
mItem.setLatitude(0);
mItem.setLongitude(0);
mItem.setLocationName("");
updateReminderInfo();
},
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE),
DateFormat.is24HourFormat(this));
timeDialog.show();
},
cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH));
dateDialog.show();
}
private void pickLocation() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
mLocationPermissionLauncher.launch(new String[]{
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_BACKGROUND_LOCATION
});
} else {
mLocationPermissionLauncher.launch(new String[]{Manifest.permission.ACCESS_FINE_LOCATION});
}
} else {
applyMockLocation();
}
}
private void applyMockLocation() {
// 模拟选点:返回虚拟坐标和地点名称(无真实地图 SDK
mItem.setReminderType(TodoItem.REMINDER_LOCATION);
mItem.setReminderTimestamp(0);
mItem.setLatitude(39.9042);
mItem.setLongitude(116.4074);
mItem.setLocationName("附近超市");
updateReminderInfo();
Toast.makeText(this, "已设置地点提醒:附近超市", Toast.LENGTH_SHORT).show();
}
private void updateReminderInfo() {
if (mItem.getReminderType() == TodoItem.REMINDER_TIME && mItem.getReminderTimestamp() > 0) {
mTvReminderInfo.setVisibility(android.view.View.VISIBLE);
mTvReminderInfo.setText("时间提醒: " + DateFormat.format("yyyy-MM-dd HH:mm", mItem.getReminderTimestamp()));
} else if (mItem.getReminderType() == TodoItem.REMINDER_LOCATION) {
mTvReminderInfo.setVisibility(android.view.View.VISIBLE);
mTvReminderInfo.setText("地点提醒: " + mItem.getLocationName());
} else {
mTvReminderInfo.setVisibility(android.view.View.GONE);
}
}
private void save() {
String content = mEtContent.getText() != null ? mEtContent.getText().toString().trim() : "";
if (TextUtils.isEmpty(content)) {
Toast.makeText(this, "请输入待办内容", Toast.LENGTH_SHORT).show();
return;
}
mItem.setContent(content);
if (mItem.getCreatedTime() == 0) mItem.setCreatedTime(System.currentTimeMillis());
if (mIsEdit && mItem.getId() > 0) {
mTodoDao.update(mItem);
} else {
long id = mTodoDao.insert(mItem);
mItem.setId(id);
}
TodoReminderManager.scheduleReminders(this, mItem);
setResult(RESULT_OK);
finish();
}
}

@ -0,0 +1,238 @@
package net.micode.notes.ui;
/**
* Fragment
* <p>
* /
*
*
* </p>
*
* @see TodoAdapter
* @see TodoEditActivity
* @see TodoReminderManager
*/
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.media.AudioManager;
import android.media.ToneGenerator;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.VibrationEffect;
import android.os.Vibrator;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.appbar.MaterialToolbar;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import net.micode.notes.R;
import net.micode.notes.data.TodoDao;
import net.micode.notes.todo.TodoReminderManager;
import net.micode.notes.todo.ConfettiView;
import net.micode.notes.todo.TodoAdapter;
import net.micode.notes.todo.TodoItem;
import java.util.ArrayList;
import java.util.List;
public class TodoFragment extends Fragment {
private static final int REQUEST_EDIT = 2001;
private RecyclerView mRecyclerView;
private TodoAdapter mAdapter;
private FloatingActionButton mFab;
private MaterialToolbar mToolbar;
private ConfettiView mConfettiView;
private TodoDao mTodoDao;
private List<TodoItem> mUndoneList = new ArrayList<>();
private List<TodoItem> mDoneList = new ArrayList<>();
private ActionModeCallback mActionModeCallback;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_todo, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
mTodoDao = new TodoDao(requireContext());
mRecyclerView = view.findViewById(R.id.recycler_todo);
mFab = view.findViewById(R.id.fab_add_todo);
mToolbar = view.findViewById(R.id.toolbar_todo);
mConfettiView = view.findViewById(R.id.confetti_view);
mRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
mAdapter = new TodoAdapter();
mRecyclerView.setAdapter(mAdapter);
mAdapter.setOnItemClickListener(new TodoAdapter.OnItemClickListener() {
@Override
public void onItemClick(TodoItem item, int position) {
if (!mAdapter.isChoiceMode()) {
Intent intent = TodoEditActivity.newIntent(requireContext(), item.getId());
startActivityForResult(intent, REQUEST_EDIT);
}
}
@Override
public void onCheckChanged(TodoItem item, boolean checked) {
if (mAdapter.isChoiceMode()) return;
if (item == null || item.getId() <= 0) return;
item.setDone(checked);
mTodoDao.update(item);
refreshData();
if (checked) {
checkAndTriggerGamification();
}
}
});
mAdapter.setOnItemLongClickListener((item, position) -> {
if (!mAdapter.isChoiceMode() && mActionModeCallback == null) {
mActionModeCallback = new ActionModeCallback();
requireActivity().startActionMode(mActionModeCallback);
mAdapter.setChoiceMode(true);
mAdapter.setChecked(position, true);
mFab.setVisibility(View.GONE);
}
});
mFab.setOnClickListener(v -> {
if (getActivity() == null || !isAdded()) return;
Intent intent = new Intent(getActivity(), TodoEditActivity.class);
startActivityForResult(intent, REQUEST_EDIT);
});
if (mToolbar != null && getActivity() instanceof MainActivity) {
mToolbar.setNavigationOnClickListener(v -> ((MainActivity) getActivity()).openDrawer());
}
mConfettiView.setOnClickListener(v -> mConfettiView.setVisibility(View.GONE));
refreshData();
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == REQUEST_EDIT && resultCode == Activity.RESULT_OK) {
refreshData();
}
}
private void refreshData() {
mUndoneList = mTodoDao.getUndone();
mDoneList = mTodoDao.getDone();
mAdapter.setData(mUndoneList, mDoneList);
}
private void checkAndTriggerGamification() {
int undoneCount = mTodoDao.getUndoneCount();
if (undoneCount == 0) {
triggerGamification();
}
}
private void triggerGamification() {
vibrate();
playSuccessSound();
showConfetti();
}
private void vibrate() {
try {
if (getContext() == null) return;
Vibrator v = (Vibrator) getContext().getSystemService(android.content.Context.VIBRATOR_SERVICE);
if (v != null && v.hasVibrator()) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
v.vibrate(VibrationEffect.createOneShot(150, VibrationEffect.DEFAULT_AMPLITUDE));
} else {
v.vibrate(150);
}
}
} catch (Exception e) { /* ignore */ }
}
/** 撒花时的短促有力提示音(系统 ToneGenerator无需 raw 资源) */
private static final int SUCCESS_TONE_DURATION_MS = 120;
private void playSuccessSound() {
try {
Context ctx = getContext();
if (ctx == null) return;
ToneGenerator tone = new ToneGenerator(AudioManager.STREAM_NOTIFICATION, 85);
tone.startTone(ToneGenerator.TONE_PROP_ACK, SUCCESS_TONE_DURATION_MS);
new Handler(Looper.getMainLooper()).postDelayed(() -> {
try {
tone.release();
} catch (Exception ignored) { }
}, SUCCESS_TONE_DURATION_MS + 80);
} catch (Exception e) { /* 无扬声器或权限时静默 */ }
}
private void showConfetti() {
if (mConfettiView != null) {
mConfettiView.post(() -> {
mConfettiView.start();
});
}
}
private class ActionModeCallback implements android.view.ActionMode.Callback {
@Override
public boolean onCreateActionMode(android.view.ActionMode mode, android.view.Menu menu) {
requireActivity().getMenuInflater().inflate(R.menu.todo_list_options, menu);
return true;
}
@Override
public boolean onPrepareActionMode(android.view.ActionMode mode, android.view.Menu menu) {
return false;
}
@Override
public boolean onActionItemClicked(android.view.ActionMode mode, android.view.MenuItem item) {
if (item.getItemId() == R.id.delete) {
List<Long> ids = mAdapter.getSelectedIds();
if (ids.isEmpty()) {
Toast.makeText(requireContext(), R.string.menu_select_none, Toast.LENGTH_SHORT).show();
return true;
}
new android.app.AlertDialog.Builder(requireContext())
.setTitle(R.string.alert_title_delete)
.setMessage(getString(R.string.todo_delete_confirm, ids.size()))
.setPositiveButton(android.R.string.ok, (d, w) -> {
for (long id : ids) TodoReminderManager.cancelAllForTodo(requireContext(), id);
mTodoDao.deleteByIds(ids);
refreshData();
mode.finish();
})
.setNegativeButton(android.R.string.cancel, null)
.show();
}
return true;
}
@Override
public void onDestroyActionMode(android.view.ActionMode mode) {
mAdapter.setChoiceMode(false);
mFab.setVisibility(View.VISIBLE);
mActionModeCallback = null;
}
}
}

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/bottom_nav_selected" android:state_checked="true" />
<item android:color="@color/bottom_nav_unselected" />
</selector>

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before

Width:  |  Height:  |  Size: 443 B

After

Width:  |  Height:  |  Size: 443 B

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save