便签编辑页面UI优化和时间、地点智能识别

pull/32/head
包尔俊 3 weeks ago
parent 574573cad8
commit f8c785f348

@ -4,21 +4,6 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<<<<<<< HEAD
<DropdownSelection timestamp="2026-01-26T02:44:48.273765700Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\archmaxjtx\.android\avd\Pixel_4a.avd" />
=======
<DropdownSelection timestamp="2026-01-30T10:15:59.196226600Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\啊?\.android\avd\Pixel_4a_API_31.avd" />
>>>>>>> baoerjun_branch
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>

@ -22,6 +22,24 @@
<uses-permission android:name="android.permission.USE_CREDENTIALS" />
<!-- 允许接收系统启动完成广播,用于初始化闹钟 -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- 允许设置闹钟 -->
<uses-permission android:name="com.android.alarm.permission.SET_ALARM" />
<uses-permission android:name="android.permission.SET_ALARM" />
<!-- 允许在 Android 11+ 上查询外部应用 -->
<queries>
<intent>
<action android:name="android.intent.action.SET_ALARM" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="geo" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
<!-- 阿里云推送服务权限 -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

@ -321,7 +321,12 @@ public class WorkingNote {
// 加载壁纸路径
int wallpaperIndex = cursor.getColumnIndex(DataColumns.DATA5);
if (wallpaperIndex != -1) {
mWallpaperPath = cursor.getString(wallpaperIndex);
String path = cursor.getString(wallpaperIndex);
if (!TextUtils.isEmpty(path)) {
mWallpaperPath = path;
} else {
mWallpaperPath = null;
}
}
} else if (DataConstants.CALL_NOTE.equals(type)) {
// 加载通话记录数据
@ -449,7 +454,7 @@ public class WorkingNote {
* @return true false
*/
private boolean isWorthSaving() {
if (mIsDeleted || (!existInDatabase() && TextUtils.isEmpty(mContent))
if (mIsDeleted || (!existInDatabase() && TextUtils.isEmpty(mContent) && TextUtils.isEmpty(mWallpaperPath))
|| (existInDatabase() && !mNote.isLocalModified())) {
return false;
} else {

@ -0,0 +1,167 @@
package net.micode.notes.tool;
import android.content.Context;
import android.os.Build;
import android.text.Spannable;
import android.text.style.URLSpan;
import android.view.textclassifier.TextClassificationManager;
import android.view.textclassifier.TextClassifier;
import android.view.textclassifier.TextLinks;
import java.util.Collection;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
*
* <p>
* Android AI (TextClassifier)
* URL
* </p>
*/
public class SmartParser {
// 时间 Scheme 前缀
public static final String SCHEME_TIME = "smarttime:";
// 地点 Scheme 前缀
public static final String SCHEME_GEO = "smartgeo:";
// 优化的时间正则表达式(作为补充)
private static final String TIME_REGEX =
"((今天|明天|后天|下周[一二三四五六日])?\\s*([上下]午)?\\s*(\\d{1,2})[:点](\\d{0,2})分?)" +
"|(\\b\\d{1,2}:\\d{2}\\b)";
// 优化的地点正则表达式:匹配 1-10 个中文字符/数字 + 常见的地点后缀
private static final String GEO_REGEX =
"([一-龥0-9]{1,10}(?:省|市|区|县|街道|路|弄|巷|楼|院|场|店|里|广场|大厦|中心|医院|学校|大学|公园|车站|机场|酒店|宾馆|超市|商场))";
// 扩展噪音词列表:包含动词、代词、时间单位和方位词
private static final String NOISE_PREFIXES = "我在去到从的地了你他们这那点分时上下午";
/**
*
*
* @param context
* @param text
*/
public static void parse(Context context, Spannable text) {
if (text == null || text.length() == 0) return;
// 1. 清除旧的智能链接
URLSpan[] allSpans = text.getSpans(0, text.length(), URLSpan.class);
for (URLSpan span : allSpans) {
String url = span.getURL();
if (url != null && (url.startsWith(SCHEME_TIME) || url.startsWith(SCHEME_GEO))) {
text.removeSpan(span);
}
}
// 2. 识别逻辑
// 步骤 A: 优先识别时间(因为时间格式相对固定,误报率低)
applyRegexLinks(text, Pattern.compile(TIME_REGEX, Pattern.CASE_INSENSITIVE), SCHEME_TIME);
// 步骤 B: 识别地点(地点正则较宽松,需要避开已识别的时间)
applyRegexLinks(text, Pattern.compile(GEO_REGEX), SCHEME_GEO);
// 3. 使用系统级 AI (TextClassifier) 作为增强
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
try {
TextClassificationManager tcm = context.getSystemService(TextClassificationManager.class);
if (tcm != null) {
TextClassifier classifier = tcm.getTextClassifier();
TextLinks.Request request = new TextLinks.Request.Builder(text).build();
TextLinks links = classifier.generateLinks(request);
for (TextLinks.TextLink link : links.getLinks()) {
String entityType = getTopEntity(link);
if ("address".equals(entityType) || "location".equals(entityType) || "place".equals(entityType)) {
applySmartSpan(text, link.getStart(), link.getEnd(), SCHEME_GEO);
} else if ("date".equals(entityType) || "datetime".equals(entityType)) {
applySmartSpan(text, link.getStart(), link.getEnd(), SCHEME_TIME);
}
}
}
} catch (Exception e) {
// 静默回退
}
}
}
/**
*
*/
private static String getTopEntity(TextLinks.TextLink link) {
float maxConfidence = -1;
String topType = null;
for (int i = 0; i < link.getEntityCount(); i++) {
String type = link.getEntity(i);
float confidence = link.getConfidenceScore(type);
if (confidence > maxConfidence) {
maxConfidence = confidence;
topType = type;
}
}
return topType;
}
/**
* Span
*/
private static void applySmartSpan(Spannable text, int start, int end, String scheme) {
// 1. 噪音修剪(特别是地点识别)
if (SCHEME_GEO.equals(scheme)) {
while (start < end && NOISE_PREFIXES.indexOf(text.charAt(start)) != -1) {
start++;
}
}
if (start >= end) return;
// 2. 处理重叠冲突
URLSpan[] existing = text.getSpans(start, end, URLSpan.class);
if (existing.length > 0) {
for (URLSpan span : existing) {
int spanStart = text.getSpanStart(span);
int spanEnd = text.getSpanEnd(span);
// 如果当前识别结果完全落在已有 span 内部,则跳过
if (start >= spanStart && end <= spanEnd) {
return;
}
// 如果当前识别结果包含了已有 span尝试修剪当前结果的起始位置
if (start < spanEnd && end > spanStart) {
// 如果重叠发生在开头,将起始位置移动到已有 span 之后
if (start < spanEnd) {
start = spanEnd;
}
}
}
}
// 再次检查修剪后的合法性
if (start >= end) return;
// 针对地点识别,修剪后可能剩下的是噪音或过短
if (SCHEME_GEO.equals(scheme)) {
// 再次修剪新起点处的噪音
while (start < end && NOISE_PREFIXES.indexOf(text.charAt(start)) != -1) {
start++;
}
// 如果剩下的文本太短(如只有 1 个字且不是后缀),则放弃
if (end - start < 2) return;
}
text.setSpan(new SmartURLSpan(scheme + text.subSequence(start, end)),
start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
/**
*
*/
private static void applyRegexLinks(Spannable text, Pattern pattern, String scheme) {
Matcher m = pattern.matcher(text);
while (m.find()) {
applySmartSpan(text, m.start(), m.end(), scheme);
}
}
}

@ -0,0 +1,122 @@
package net.micode.notes.tool;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.provider.AlarmClock;
import android.text.style.URLSpan;
import android.util.Log;
import android.view.View;
import android.widget.Toast;
import net.micode.notes.R;
import java.util.Calendar;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* URLSpan
*/
public class SmartURLSpan extends URLSpan {
private static final String TAG = "SmartURLSpan";
public SmartURLSpan(String url) {
super(url);
}
@Override
public void onClick(View widget) {
String url = getURL();
Context context = widget.getContext();
if (url.startsWith(SmartParser.SCHEME_TIME)) {
handleTimeClick(context, url.substring(SmartParser.SCHEME_TIME.length()));
} else if (url.startsWith(SmartParser.SCHEME_GEO)) {
handleGeoClick(context, url.substring(SmartParser.SCHEME_GEO.length()));
} else {
super.onClick(widget);
}
}
/**
*
*/
private void handleTimeClick(Context context, String timeStr) {
try {
int hour = -1;
int minute = 0;
// 尝试解析小时和分钟
// 支持格式如14:30, 10点30分, 9点
Pattern p = Pattern.compile("(\\d{1,2})[:点](\\d{0,2})");
Matcher m = p.matcher(timeStr);
if (m.find()) {
hour = Integer.parseInt(m.group(1));
String minStr = m.group(2);
if (minStr != null && !minStr.isEmpty()) {
minute = Integer.parseInt(minStr);
}
}
// 处理上下午
if (timeStr.contains("下午") && hour < 12) {
hour += 12;
} else if (timeStr.contains("上午") && hour == 12) {
hour = 0;
}
if (hour == -1) {
// 如果没解析出来,默认打开闹钟主界面
Intent intent = new Intent(AlarmClock.ACTION_SET_ALARM);
context.startActivity(intent);
return;
}
// 设置闹钟意图
Intent intent = new Intent(AlarmClock.ACTION_SET_ALARM)
.putExtra(AlarmClock.EXTRA_HOUR, hour)
.putExtra(AlarmClock.EXTRA_MINUTES, minute)
.putExtra(AlarmClock.EXTRA_SKIP_UI, false);
if (intent.resolveActivity(context.getPackageManager()) != null) {
context.startActivity(intent);
} else {
Log.w(TAG, "No activity found to handle set alarm, trying without resolveActivity");
try {
context.startActivity(intent);
} catch (Exception e2) {
Toast.makeText(context, "无法打开闹钟应用", Toast.LENGTH_SHORT).show();
}
}
} catch (Exception e) {
Log.e(TAG, "Failed to set alarm", e);
Toast.makeText(context, "解析时间失败", Toast.LENGTH_SHORT).show();
}
}
/**
*
*/
private void handleGeoClick(Context context, String location) {
try {
// 使用 geo:0,0?q=location 格式打开地图
Uri gmmIntentUri = Uri.parse("geo:0,0?q=" + Uri.encode(location));
Intent mapIntent = new Intent(Intent.ACTION_VIEW, gmmIntentUri);
if (mapIntent.resolveActivity(context.getPackageManager()) != null) {
context.startActivity(mapIntent);
} else {
Log.w(TAG, "No activity found to handle geo intent, trying web fallback");
// 如果没有地图应用支持 geo 协议,尝试搜索
Uri webUri = Uri.parse("https://www.google.com/maps/search/" + Uri.encode(location));
Intent webIntent = new Intent(Intent.ACTION_VIEW, webUri);
context.startActivity(webIntent);
}
} catch (Exception e) {
Log.e(TAG, "Failed to open map", e);
Toast.makeText(context, "无法打开地图应用", Toast.LENGTH_SHORT).show();
}
}
}

@ -83,6 +83,7 @@ import net.micode.notes.databinding.DialogBackgroundSelectorBinding;
import net.micode.notes.databinding.DialogColorPickerBinding;
import net.micode.notes.databinding.NoteEditBinding;
import net.micode.notes.tool.RichTextHelper;
import net.micode.notes.tool.SmartParser;
import net.micode.notes.data.FontManager;
@ -1285,6 +1286,10 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
*/
private Spannable getHighlightQueryResult(String fullText, String userQuery) {
SpannableString spannable = new SpannableString(fullText == null ? "" : fullText);
// 应用智能解析(时间、地点识别)
SmartParser.parse(this, spannable);
if (!TextUtils.isEmpty(userQuery)) {
mPattern = Pattern.compile(userQuery);
Matcher m = mPattern.matcher(fullText);
@ -1681,21 +1686,53 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
}).start();
}
private void saveWallpaperToPrivateStorage(android.net.Uri uri) {
new Thread(() -> {
try {
java.io.InputStream is = getContentResolver().openInputStream(uri);
if (is == null) return;
// Create wallpapers directory if not exists
java.io.File wallpapersDir = new java.io.File(getFilesDir(), "wallpapers");
if (!wallpapersDir.exists()) {
wallpapersDir.mkdirs();
}
// Create a unique file name
String fileName = "wp_" + System.currentTimeMillis() + ".jpg";
java.io.File destFile = new java.io.File(wallpapersDir, fileName);
java.io.FileOutputStream fos = new java.io.FileOutputStream(destFile);
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = is.read(buffer)) != -1) {
fos.write(buffer, 0, bytesRead);
}
fos.close();
is.close();
final String filePath = "file://" + destFile.getAbsolutePath();
runOnUiThread(() -> {
mWorkingNote.setWallpaper(filePath);
mNoteBgColorSelector.setVisibility(View.GONE);
});
} catch (Exception e) {
Log.e(TAG, "Failed to copy wallpaper", e);
runOnUiThread(() -> {
showToast(R.string.failed_sdcard_export);
});
}
}).start();
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_CODE_PICK_WALLPAPER && resultCode == RESULT_OK && data != null) {
android.net.Uri uri = data.getData();
if (uri != null) {
// Take persistent permissions
try {
getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
} catch (SecurityException e) {
Log.e(TAG, "Failed to take persistable uri permission", e);
}
mWorkingNote.setWallpaper(uri.toString());
mNoteBgColorSelector.setVisibility(View.GONE);
saveWallpaperToPrivateStorage(uri);
}
} else if (requestCode == REQUEST_CODE_PICK_IMAGE && resultCode == RESULT_OK && data != null) {
android.net.Uri uri = data.getData();

@ -59,6 +59,12 @@ import android.view.ScaleGestureDetector;
import android.view.GestureDetector;
import android.text.style.ImageSpan;
import net.micode.notes.tool.RichTextHelper;
import net.micode.notes.tool.SmartParser;
import net.micode.notes.tool.SmartURLSpan;
import android.text.TextWatcher;
import android.text.Editable;
import android.text.Spannable;
import android.text.style.ClickableSpan;
import android.app.AlertDialog;
import android.widget.SeekBar;
import android.widget.TextView;
@ -93,6 +99,8 @@ public class NoteEditText extends EditText implements ScaleGestureDetector.OnSca
sSchemaActionResMap.put(SCHEME_TEL, R.string.note_link_tel);
sSchemaActionResMap.put(SCHEME_HTTP, R.string.note_link_web);
sSchemaActionResMap.put(SCHEME_EMAIL, R.string.note_link_email);
sSchemaActionResMap.put(SmartParser.SCHEME_TIME, R.string.note_link_time);
sSchemaActionResMap.put(SmartParser.SCHEME_GEO, R.string.note_link_geo);
}
@Override
@ -213,6 +221,20 @@ public class NoteEditText extends EditText implements ScaleGestureDetector.OnSca
private void init(Context context) {
mScaleDetector = new ScaleGestureDetector(context, this);
setLinkTextColor(getResources().getColor(R.color.primary_color)); // 设置链接颜色
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) {
// 触发智能解析
SmartParser.parse(getContext(), s);
}
});
mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onDoubleTap(MotionEvent e) {
@ -396,6 +418,16 @@ public class NoteEditText extends EditText implements ScaleGestureDetector.OnSca
int off = layout.getOffsetForHorizontal(line, x);
// 设置文本选择光标位置
Selection.setSelection(getText(), off);
// 检查是否有 ClickableSpan如智能链接
if (getText() instanceof Spannable) {
Spannable spannable = (Spannable) getText();
ClickableSpan[] links = spannable.getSpans(off, off, ClickableSpan.class);
if (links.length != 0) {
links[0].onClick(this);
return true;
}
}
break;
}

@ -35,6 +35,8 @@
<string name="note_link_email">Send email</string>
<string name="note_link_web">Browse web</string>
<string name="note_link_other">Open map</string>
<string name="note_link_time">创建闹钟</string>
<string name="note_link_geo">查看地图</string>
<!-- Text export file information -->
<string name="file_path" translatable="false">/MIUI/notes/</string>
<string name="file_name_txt_format" translatable="false">notes_%s.txt</string>

Loading…
Cancel
Save