diff --git a/src/Notesmaster/.idea/deploymentTargetSelector.xml b/src/Notesmaster/.idea/deploymentTargetSelector.xml index f802cd9..b268ef3 100644 --- a/src/Notesmaster/.idea/deploymentTargetSelector.xml +++ b/src/Notesmaster/.idea/deploymentTargetSelector.xml @@ -4,21 +4,6 @@ diff --git a/src/Notesmaster/app/src/main/AndroidManifest.xml b/src/Notesmaster/app/src/main/AndroidManifest.xml index ff73964..96ce6f4 100644 --- a/src/Notesmaster/app/src/main/AndroidManifest.xml +++ b/src/Notesmaster/app/src/main/AndroidManifest.xml @@ -22,6 +22,24 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/model/WorkingNote.java b/src/Notesmaster/app/src/main/java/net/micode/notes/model/WorkingNote.java index 9a8976f..a8e50c8 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/model/WorkingNote.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/model/WorkingNote.java @@ -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 { diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/tool/SmartParser.java b/src/Notesmaster/app/src/main/java/net/micode/notes/tool/SmartParser.java new file mode 100644 index 0000000..0576ed1 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/tool/SmartParser.java @@ -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; + +/** + * 智能解析工具类 + *

+ * 整合了 Android 系统级 AI (TextClassifier) 和自定义正则表达式。 + * 能够识别文本中的时间、地点、电话、URL 等信息。 + *

+ */ +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); + } + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/tool/SmartURLSpan.java b/src/Notesmaster/app/src/main/java/net/micode/notes/tool/SmartURLSpan.java new file mode 100644 index 0000000..ed08191 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/tool/SmartURLSpan.java @@ -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(); + } + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java index b19c795..5a3b1c0 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java @@ -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(); diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditText.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditText.java index e8efb9e..789b515 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditText.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditText.java @@ -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; } diff --git a/src/Notesmaster/app/src/main/res/values/strings.xml b/src/Notesmaster/app/src/main/res/values/strings.xml index 2c51ccd..80148b2 100644 --- a/src/Notesmaster/app/src/main/res/values/strings.xml +++ b/src/Notesmaster/app/src/main/res/values/strings.xml @@ -35,6 +35,8 @@ Send email Browse web Open map + 创建闹钟 + 查看地图 /MIUI/notes/ notes_%s.txt