diff --git a/positions/.gitignore b/positions/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/positions/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/positions/README.md b/positions/README.md new file mode 100644 index 0000000..3ee5c5f --- /dev/null +++ b/positions/README.md @@ -0,0 +1,35 @@ +# Positions + +#### 介绍 +安卓位置应用,有关于地理位置的相关应用。 +PS:使用感言~~~『記低用唔到』。 + +#### 软件架构 +适配安卓应用 [AIDE Pro] 的 Gradle 编译结构。 +也适配安卓应用 [AndroidIDE] 的 Gradle 编译结构。 + + +#### Gradle 编译说明 +调试版编译命令 :gradle assembleBetaDebug +阶段版编译命令 :bash .winboll/bashPublishAPKAddTag.sh positions + +#### 使用说明 + +#### 参与贡献 + +1. Fork 本仓库 +2. 新建 Feat_xxx 分支 +3. 提交代码 : ZhanGSKen(ZhanGSKen) +4. 新建 Pull Request + + +#### 特技 + +1. 使用 Readme\_XXX.md 来支持不同的语言,例如 Readme\_en.md, Readme\_zh.md +2. Gitee 官方博客 [blog.gitee.com](https://blog.gitee.com) +3. 你可以 [https://gitee.com/explore](https://gitee.com/explore) 这个地址来了解 Gitee 上的优秀开源项目 +4. [GVP](https://gitee.com/gvp) 全称是 Gitee 最有价值开源项目,是综合评定出的优秀开源项目 +5. Gitee 官方提供的使用手册 [https://gitee.com/help](https://gitee.com/help) +6. Gitee 封面人物是一档用来展示 Gitee 会员风采的栏目 [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/) + +#### 参考文档 diff --git a/positions/app_update_description.txt b/positions/app_update_description.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/positions/app_update_description.txt @@ -0,0 +1 @@ + diff --git a/positions/build.gradle b/positions/build.gradle new file mode 100644 index 0000000..857f72e --- /dev/null +++ b/positions/build.gradle @@ -0,0 +1,92 @@ +apply plugin: 'com.android.application' +apply from: '../.winboll/winboll_app_build.gradle' +apply from: '../.winboll/winboll_lint_build.gradle' + +def genVersionName(def versionName){ + // 检查编译标志位配置 + assert (winbollBuildProps['stageCount'] != null) + assert (winbollBuildProps['baseVersion'] != null) + // 保存基础版本号 + winbollBuildProps.setProperty("baseVersion", "${versionName}"); + //保存编译标志配置 + FileOutputStream fos = new FileOutputStream(winbollBuildPropsFile) + winbollBuildProps.store(fos, "${winbollBuildPropsDesc}"); + fos.close(); + + // 返回编译版本号 + return "${versionName}." + winbollBuildProps['stageCount'] +} + +android { + + // 关键:改为你已安装的 SDK 32(≥ targetSdkVersion 30,兼容已安装环境) + compileSdkVersion 32 + + // 直接使用已安装的构建工具 33.0.3(无需修改) + buildToolsVersion "33.0.3" + + defaultConfig { + applicationId "cc.winboll.studio.positions" + minSdkVersion 23 + targetSdkVersion 30 + versionCode 1 + // versionName 更新后需要手动设置 + // .winboll/winbollBuildProps.properties 文件的 stageCount=0 + // Gradle编译环境下合起来的 versionName 就是 "${versionName}.0" + versionName "15.12" + if(true) { + versionName = genVersionName("${versionName}") + } + } + + // 米盟 SDK + packagingOptions { + doNotStrip "*/*/libmimo_1011.so" + } +} + +dependencies { + // 米盟 + api 'com.miui.zeus:mimo-ad-sdk:5.3.+'//请使用最新版sdk + //注意:以下5个库必须要引入 + //api 'androidx.appcompat:appcompat:1.4.1' + api 'androidx.recyclerview:recyclerview:1.0.0' + api 'com.google.code.gson:gson:2.8.5' + api 'com.github.bumptech.glide:glide:4.9.0' + //annotationProcessor 'com.github.bumptech.glide:compiler:4.9.0' + + // https://mvnrepository.com/artifact/com.jzxiang.pickerview/TimePickerDialog + api 'com.jzxiang.pickerview:TimePickerDialog:1.0.1' + + // 谷歌定位服务核心依赖(FusedLocationProviderClient所在库) + api 'com.google.android.gms:play-services-location:21.0.1' + + // SSH + api 'com.jcraft:jsch:0.1.55' + // Html 解析 + api 'org.jsoup:jsoup:1.13.1' + // 二维码类库 + api 'com.google.zxing:core:3.4.1' + api 'com.journeyapps:zxing-android-embedded:3.6.0' + // 应用介绍页类库 + api 'io.github.medyo:android-about-page:2.0.0' + // 网络连接类库 + api 'com.squareup.okhttp3:okhttp:4.4.1' + // AndroidX 类库 + api 'androidx.appcompat:appcompat:1.1.0' + api 'com.google.android.material:material:1.4.0' + //api 'androidx.viewpager:viewpager:1.0.0' + //api 'androidx.vectordrawable:vectordrawable:1.1.0' + //api 'androidx.vectordrawable:vectordrawable-animated:1.1.0' + //api 'androidx.fragment:fragment:1.1.0' + + // WinBoLL库 nexus.winboll.cc 地址 + api 'cc.winboll.studio:libaes:15.15.9' + api 'cc.winboll.studio:libappbase:15.15.21' + + // WinBoLL备用库 jitpack.io 地址 + //api 'com.github.ZhanGSKen:AES:aes-v15.12.9' + //api 'com.github.ZhanGSKen:APPBase:appbase-v15.14.1' + + api fileTree(dir: 'libs', include: ['*.jar']) +} diff --git a/positions/build.properties b/positions/build.properties new file mode 100644 index 0000000..815c1a4 --- /dev/null +++ b/positions/build.properties @@ -0,0 +1,8 @@ +#Created by .winboll/winboll_app_build.gradle +#Mon May 04 11:31:30 HKT 2026 +stageCount=22 +libraryProject= +baseVersion=15.12 +publishVersion=15.12.21 +buildCount=0 +baseBetaVersion=15.12.22 diff --git a/positions/proguard-rules.pro b/positions/proguard-rules.pro new file mode 100644 index 0000000..855b18a --- /dev/null +++ b/positions/proguard-rules.pro @@ -0,0 +1,143 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in C:\tools\adt-bundle-windows-x86_64-20131030\sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# ============================== 基础通用规则 ============================== +# 保留系统组件 +-keep public class * extends android.app.Activity +-keep public class * extends android.app.Service +-keep public class * extends android.content.BroadcastReceiver +-keep public class * extends android.content.ContentProvider +-keep public class * extends android.app.backup.BackupAgentHelper +-keep public class * extends android.preference.Preference + +# 保留 WinBoLL 核心包及子类(合并简化规则) +-keep class cc.winboll.studio.** { *; } +-keepclassmembers class cc.winboll.studio.** { *; } + +# 保留所有类中的 public static final String TAG 字段(便于日志定位) +-keepclassmembers class * { + public static final java.lang.String TAG; +} + +# 保留序列化类(避免Parcelable/Gson解析异常) +-keep class * implements android.os.Parcelable { + public static final android.os.Parcelable$Creator *; +} +-keepclassmembers class * implements java.io.Serializable { + static final long serialVersionUID; + private static final java.io.ObjectStreamField[] serialPersistentFields; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); + java.lang.Object writeReplace(); + java.lang.Object readResolve(); +} + +# 保留 R 文件(避免资源ID混淆) +-keepclassmembers class **.R$* { + public static ; +} + +# 保留 native 方法(避免JNI调用失败) +-keepclasseswithmembernames class * { + native ; +} + +# 保留注解和泛型(避免反射/序列化异常) +-keepattributes *Annotation* +-keepattributes Signature + +# 屏蔽 Java 8+ 警告(适配 Java 7 语法) +-dontwarn java.lang.invoke.* +-dontwarn android.support.v8.renderscript.* +-dontwarn java.util.function.** + +# ============================== 第三方框架专项规则 ============================== +# OkHttp 4.4.1(米盟广告请求依赖,完善Lambda兼容) +-keep class okhttp3.** { *; } +-keep interface okhttp3.** { *; } +-keep class okhttp3.internal.** { *; } +-keep class okio.** { *; } +-dontwarn okhttp3.internal.platform.** +-dontwarn okio.** +# ============================== 必要补充规则 ============================== +# OkHttp 4.4.1 补充规则(Java 7 兼容) +-keep class okhttp3.internal.concurrent.** { *; } +-keep class okhttp3.internal.connection.** { *; } +-dontwarn okhttp3.internal.concurrent.TaskRunner +-dontwarn okhttp3.internal.connection.RealCall + +# Glide 4.9.0(米盟广告图片加载依赖) +-keep public class * implements com.bumptech.glide.module.GlideModule +-keep public class * extends com.bumptech.glide.module.AppGlideModule +-keep public enum com.bumptech.glide.load.ImageHeaderParser$ImageType { + **[] $VALUES; + public *; +} +-keepclassmembers class * implements com.bumptech.glide.module.AppGlideModule { + (); +} +-dontwarn com.bumptech.glide.** + +# Gson 2.8.5(米盟广告数据序列化依赖) +-keep class com.google.gson.** { *; } +-keep interface com.google.gson.** { *; } +-keepclassmembers class * { + @com.google.gson.annotations.SerializedName ; +} + +# 米盟 SDK(核心广告组件,完整保留避免加载失败) +-keep class com.miui.zeus.** { *; } +-keep interface com.miui.zeus.** { *; } +# 保留米盟日志字段(便于广告加载失败排查) +-keepclassmembers class com.miui.zeus.mimo.sdk.** { + public static final java.lang.String TAG; +} + +# RecyclerView 1.0.0(米盟广告布局渲染依赖) +-keep class androidx.recyclerview.** { *; } +-keep interface androidx.recyclerview.** { *; } +-keepclassmembers class androidx.recyclerview.widget.RecyclerView$Adapter { + public *; +} + +# 其他第三方框架(按引入依赖保留,无则可删除) +# XXPermissions 18.63 +-keep class com.hjq.permissions.** { *; } +-keep interface com.hjq.permissions.** { *; } + +# ZXing 二维码(核心解析组件) +-keep class com.google.zxing.** { *; } +-keep class com.journeyapps.zxing.** { *; } + +# Jsoup HTML解析 +-keep class org.jsoup.** { *; } + +# Pinyin4j 拼音搜索 +-keep class net.sourceforge.pinyin4j.** { *; } + +# JSch SSH组件 +-keep class com.jcraft.jsch.** { *; } + +# AndroidX 基础组件 +-keep class androidx.appcompat.** { *; } +-keep interface androidx.appcompat.** { *; } + +# ============================== 优化与调试配置 ============================== +# 优化级别(平衡混淆效果与性能) +-optimizationpasses 5 +-optimizations !code/simplification/arithmetic,!code/simplification/cast,!field/*,!class/merging/* + +# 调试辅助(保留行号便于崩溃定位) +-verbose +-dontpreverify +-dontusemixedcaseclassnames +-keepattributes SourceFile,LineNumberTable + diff --git a/positions/src/beta/AndroidManifest.xml b/positions/src/beta/AndroidManifest.xml new file mode 100644 index 0000000..d783f52 --- /dev/null +++ b/positions/src/beta/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/positions/src/beta/res/values-zh/strings.xml b/positions/src/beta/res/values-zh/strings.xml new file mode 100644 index 0000000..394a306 --- /dev/null +++ b/positions/src/beta/res/values-zh/strings.xml @@ -0,0 +1,5 @@ + + + 悟空笔记# + 时空任务# + diff --git a/positions/src/beta/res/values/strings.xml b/positions/src/beta/res/values/strings.xml new file mode 100644 index 0000000..5984bfd --- /dev/null +++ b/positions/src/beta/res/values/strings.xml @@ -0,0 +1,7 @@ + + + + Positions + PositionsPlus+ + + diff --git a/positions/src/beta/res/xml/file_provider.xml b/positions/src/beta/res/xml/file_provider.xml new file mode 100644 index 0000000..802e4cc --- /dev/null +++ b/positions/src/beta/res/xml/file_provider.xml @@ -0,0 +1,6 @@ + + + + diff --git a/positions/src/beta/res/xml/shortcutsmain.xml b/positions/src/beta/res/xml/shortcutsmain.xml new file mode 100644 index 0000000..08d8f99 --- /dev/null +++ b/positions/src/beta/res/xml/shortcutsmain.xml @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/positions/src/beta/res/xml/shortcutsplus.xml b/positions/src/beta/res/xml/shortcutsplus.xml new file mode 100644 index 0000000..78616e4 --- /dev/null +++ b/positions/src/beta/res/xml/shortcutsplus.xml @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/positions/src/main/AndroidManifest.xml b/positions/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2c8e99c --- /dev/null +++ b/positions/src/main/AndroidManifest.xml @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/positions/src/main/java/cc/winboll/studio/positions/App.java b/positions/src/main/java/cc/winboll/studio/positions/App.java new file mode 100644 index 0000000..ded1168 --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/App.java @@ -0,0 +1,496 @@ +package cc.winboll.studio.positions; + +import android.app.Activity; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageInfo; +import android.content.res.Resources; +import android.graphics.Typeface; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.TextUtils; +import android.view.Menu; +import android.view.MenuItem; +import android.view.ViewGroup; +import android.widget.HorizontalScrollView; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.Toast; + +import cc.winboll.studio.libaes.utils.WinBoLLActivityManager; +import cc.winboll.studio.libappbase.GlobalApplication; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.positions.services.MainService; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +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.lang.Thread.UncaughtExceptionHandler; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 全局Application应用入口类 + * 功能概括: + * 1. 全局初始化管理、工具类框架初始化 + * 2. 应用全局空转状态统一管理 + * 3. IO读写通用工具封装 + * 4. 全局崩溃捕获、崩溃日志本地保存、崩溃弹窗页面 + * @Author 豆包&ZhanGSKen + * @CreateTime 2026/05/03 12:23:00 + * @EditTime 2026/05/03 15:02:47 + */ +public class App extends GlobalApplication { + + //===================== 全局静态常量与变量 ===================== + public static final String TAG = "App"; + private static Handler MAIN_HANDLER = new Handler(Looper.getMainLooper()); + private static App sInstance; + + private static final String SP_NAME = "app_idle_config"; + private static final String SP_KEY_IDLE_RUNNING = "is_idle_running"; + + // 应用全局空转状态标记 + private static boolean isAppIdleRunning = false; + + //===================== 空转状态对外方法 ===================== + /** + * 获取当前应用空转运行状态 + * @return 是否处于空转状态 + */ + public static boolean isAppIdleRunning() { + if (isAppIdleRunning) { + LogUtils.d(TAG, "isAppIdleRunning -> 当前应用处于空转运行状态"); + } + return isAppIdleRunning; + } + + /** + * 设置应用空转运行状态 + * @param idleRunning 空转开关状态 + */ + public static void setAppIdleRunning(boolean idleRunning) { + LogUtils.d(TAG, "setAppIdleRunning -> 传入参数 idleRunning = " + idleRunning); + if (isDebugging()) { + isAppIdleRunning = idleRunning; + saveIdleRunningToSp(idleRunning); + LogUtils.i(TAG, "setAppIdleRunning -> 调试模式,空转状态设置生效并持久化"); + + // 重启MainService服务以同步新的空转状态(stopService对未运行服务无效,故无需判断状态) + if (sInstance != null) { + LogUtils.i(TAG, "setAppIdleRunning -> 重启MainService服务以同步空转状态"); + Intent serviceIntent = new Intent(sInstance, MainService.class); + sInstance.stopService(serviceIntent); + sInstance.startService(serviceIntent); + } + } else { + LogUtils.i(TAG, "setAppIdleRunning -> 非调试模式,空转设置无效"); + LogUtils.i(TAG, "Non-debug state, app idle setting is meaningless."); + } + } + + /** + * 从SharedPreferences加载空转状态 + */ + private static void loadIdleRunningFromSp() { + try { + if (sInstance != null) { + SharedPreferences sp = sInstance.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE); + isAppIdleRunning = sp.getBoolean(SP_KEY_IDLE_RUNNING, false); + LogUtils.i(TAG, "loadIdleRunningFromSp -> 从SP加载空转状态:" + isAppIdleRunning); + } + } catch (Exception e) { + LogUtils.e(TAG, "loadIdleRunningFromSp -> 加载空转状态失败:" + e.getMessage()); + isAppIdleRunning = false; + } + } + + /** + * 保存空转状态到SharedPreferences + * @param idleRunning 空转状态 + */ + private static void saveIdleRunningToSp(boolean idleRunning) { + try { + if (sInstance != null) { + SharedPreferences sp = sInstance.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE); + sp.edit().putBoolean(SP_KEY_IDLE_RUNNING, idleRunning).apply(); + LogUtils.i(TAG, "saveIdleRunningToSp -> 空转状态已保存:" + idleRunning); + } + } catch (Exception e) { + LogUtils.e(TAG, "saveIdleRunningToSp -> 保存空转状态失败:" + e.getMessage()); + } + } + + //===================== 应用生命周期 ===================== + @Override + public void onCreate() { + super.onCreate(); + sInstance = this; + LogUtils.i(TAG, "onCreate -> 全局Application初始化开始"); + + setIsDebugging(BuildConfig.DEBUG); + WinBoLLActivityManager.init(this); + ToastUtils.init(this); + + loadIdleRunningFromSp(); + + LogUtils.i(TAG, "onCreate -> 全局组件初始化全部完成"); + } + + //===================== IO通用工具方法 ===================== + /** + * 流式读写输入输出流 + * @param input 输入流 + * @param output 输出流 + * @throws IOException IO读写异常 + */ + public static void write(InputStream input, OutputStream output) throws IOException { + byte[] buf = new byte[1024 * 8]; + int len; + while ((len = input.read(buf)) != -1) { + output.write(buf, 0, len); + } + } + + /** + * 将字节数组写入指定文件 + * @param file 目标文件 + * @param data 字节数据 + * @throws IOException IO读写异常 + */ + public static void write(File file, byte[] data) throws IOException { + File parent = file.getParentFile(); + if (parent != null && !parent.exists()) { + parent.mkdirs(); + } + + ByteArrayInputStream input = new ByteArrayInputStream(data); + FileOutputStream output = new FileOutputStream(file); + try { + write(input, output); + } finally { + closeIO(input, output); + } + } + + /** + * 输入流转为UTF-8字符串 + * @param input 输入流 + * @return 转换后的文本 + * @throws IOException IO读写异常 + */ + public static String toString(InputStream input) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + write(input, output); + try { + return output.toString("UTF-8"); + } finally { + closeIO(input, output); + } + } + + /** + * 批量关闭IO流 + * @param closeables 可关闭对象 + */ + public static void closeIO(Closeable... closeables) { + for (Closeable closeable : closeables) { + try { + if (closeable != null) { + closeable.close(); + } + } catch (IOException ignored) { + + } + } + } + + //===================== 全局崩溃捕获管理类 ===================== + public static class CrashHandler { + + public static final UncaughtExceptionHandler DEFAULT_UNCAUGHT_EXCEPTION_HANDLER = Thread.getDefaultUncaughtExceptionHandler(); + private static CrashHandler sInstance; + private PartCrashHandler mPartCrashHandler; + + public static CrashHandler getInstance() { + if (sInstance == null) { + sInstance = new CrashHandler(); + } + return sInstance; + } + + /** + * 注册全局崩溃捕获 + */ + public void registerGlobal(Context context) { + registerGlobal(context, null); + } + + /** + * 注册全局崩溃并自定义崩溃日志保存目录 + * @param crashDir 崩溃日志路径 + */ + public void registerGlobal(Context context, String crashDir) { + LogUtils.d(TAG, "CrashHandler -> 注册全局异常捕获器"); + Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionHandlerImpl(context.getApplicationContext(), crashDir)); + } + + /** + * 解除全局崩溃捕获 + */ + public void unregister() { + LogUtils.d(TAG, "CrashHandler -> 解除全局异常捕获"); + Thread.setDefaultUncaughtExceptionHandler(DEFAULT_UNCAUGHT_EXCEPTION_HANDLER); + } + + /** + * 注册局部前台异常捕获 + */ + public void registerPart(Context context) { + unregisterPart(context); + mPartCrashHandler = new PartCrashHandler(context.getApplicationContext()); + MAIN_HANDLER.postAtFrontOfQueue(mPartCrashHandler); + LogUtils.d(TAG, "CrashHandler -> 局部异常监听已注册"); + } + + /** + * 解除局部异常捕获 + */ + public void unregisterPart(Context context) { + if (mPartCrashHandler != null) { + mPartCrashHandler.isRunning.set(false); + mPartCrashHandler = null; + LogUtils.d(TAG, "CrashHandler -> 局部异常监听已销毁"); + } + } + + /** + * 局部前台Looper异常捕获 + */ + private static class PartCrashHandler implements Runnable { + private final Context mContext; + public AtomicBoolean isRunning = new AtomicBoolean(true); + + public PartCrashHandler(Context context) { + this.mContext = context; + } + + @Override + public void run() { + while (isRunning.get()) { + try { + Looper.loop(); + } catch (final Throwable e) { + if (isRunning.get()) { + MAIN_HANDLER.post(new Runnable() { + @Override + public void run() { + Toast.makeText(mContext, e.toString(), Toast.LENGTH_LONG).show(); + } + }); + } else { + if (e instanceof RuntimeException) { + throw (RuntimeException) e; + } else { + throw new RuntimeException(e); + } + } + } + } + } + } + + /** + * 全局未捕获异常处理实现 + */ + private static class UncaughtExceptionHandlerImpl implements UncaughtExceptionHandler { + private static DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy_MM_dd-HH_mm_ss"); + private final Context mContext; + private final File mCrashDir; + + public UncaughtExceptionHandlerImpl(Context context, String crashDir) { + this.mContext = context; + this.mCrashDir = TextUtils.isEmpty(crashDir) ? new File(mContext.getExternalCacheDir(), "crash") : new File(crashDir); + } + + @Override + public void uncaughtException(Thread thread, Throwable throwable) { + try { + String log = buildLog(throwable); + writeLog(log); + + Intent intent = new Intent(mContext, CrashActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra(Intent.EXTRA_TEXT, log); + mContext.startActivity(intent); + + android.os.Process.killProcess(android.os.Process.myPid()); + System.exit(0); + } catch (Throwable e) { + if (DEFAULT_UNCAUGHT_EXCEPTION_HANDLER != null) { + DEFAULT_UNCAUGHT_EXCEPTION_HANDLER.uncaughtException(thread, throwable); + } + } + } + + /** + * 组装崩溃详细日志信息 + */ + private String buildLog(Throwable throwable) { + String time = DATE_FORMAT.format(new Date()); + String versionName = "unknown"; + long versionCode = 0; + + try { + PackageInfo packageInfo = mContext.getPackageManager().getPackageInfo(mContext.getPackageName(), 0); + versionName = packageInfo.versionName; + versionCode = Build.VERSION.SDK_INT >= 28 ? packageInfo.getLongVersionCode() : packageInfo.versionCode; + } catch (Throwable ignored) { + + } + + LinkedHashMap head = new LinkedHashMap(); + head.put("Time Of Crash", time); + head.put("Device", String.format("%s, %s", Build.MANUFACTURER, Build.MODEL)); + head.put("Android Version", String.format("%s (%d)", Build.VERSION.RELEASE, Build.VERSION.SDK_INT)); + head.put("App Version", String.format("%s (%d)", versionName, versionCode)); + head.put("Kernel", getKernel()); + head.put("Support Abis", Build.VERSION.SDK_INT >= 21 && Build.SUPPORTED_ABIS != null ? Arrays.toString(Build.SUPPORTED_ABIS) : "unknown"); + head.put("Fingerprint", Build.FINGERPRINT); + + StringBuilder builder = new StringBuilder(); + for (String key : head.keySet()) { + if (builder.length() != 0) { + builder.append("\n"); + } + builder.append(key); + builder.append(" : "); + builder.append(head.get(key)); + } + builder.append("\n\n"); + builder.append(throwable.toString()); + + return builder.toString(); + } + + /** + * 写入崩溃日志到本地文件 + */ + private void writeLog(String log) { + String time = DATE_FORMAT.format(new Date()); + File file = new File(mCrashDir, "crash_" + time + ".txt"); + try { + write(file, log.getBytes("UTF-8")); + LogUtils.d(TAG, "崩溃日志已保存至本地文件"); + } catch (Throwable e) { + LogUtils.e(TAG, "崩溃日志写入失败"); + } + } + + /** + * 获取系统内核版本信息 + */ + private static String getKernel() { + try { + return App.toString(new FileInputStream("/proc/version")).trim(); + } catch (Throwable e) { + return e.getMessage(); + } + } + } + } + + //===================== 崩溃展示页面 ===================== + public static final class CrashActivity extends Activity { + + private String mLog; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + LogUtils.i(TAG, "CrashActivity -> 崩溃展示页面已启动"); + + setTheme(android.R.style.Theme_DeviceDefault); + setTitle("App Crash"); + mLog = getIntent().getStringExtra(Intent.EXTRA_TEXT); + + ScrollView contentView = new ScrollView(this); + contentView.setFillViewport(true); + HorizontalScrollView horizontalScrollView = new HorizontalScrollView(this); + + TextView textView = new TextView(this); + int padding = dp2px(16); + textView.setPadding(padding, padding, padding, padding); + textView.setText(mLog); + textView.setTextIsSelectable(true); + textView.setTypeface(Typeface.DEFAULT); + textView.setLinksClickable(true); + + horizontalScrollView.addView(textView); + contentView.addView(horizontalScrollView, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + setContentView(contentView); + } + + /** + * 重启当前应用 + */ + private void restart() { + Intent intent = getPackageManager().getLaunchIntentForPackage(getPackageName()); + if (intent != null) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + } + finish(); + android.os.Process.killProcess(android.os.Process.myPid()); + System.exit(0); + } + + /** + * dp转px通用方法 + */ + private static int dp2px(float dpValue) { + final float scale = Resources.getSystem().getDisplayMetrics().density; + return (int) (dpValue * scale + 0.5f); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + menu.add(0, android.R.id.copy, 0, android.R.string.copy) + .setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM); + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.copy) { + ClipboardManager cm = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + cm.setPrimaryClip(ClipData.newPlainText(getPackageName(), mLog)); + LogUtils.d(TAG, "CrashActivity -> 崩溃日志已复制到剪贴板"); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onBackPressed() { + restart(); + } + } +} + diff --git a/positions/src/main/java/cc/winboll/studio/positions/MainActivity.java b/positions/src/main/java/cc/winboll/studio/positions/MainActivity.java new file mode 100644 index 0000000..1b26d54 --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/MainActivity.java @@ -0,0 +1,403 @@ +package cc.winboll.studio.positions; + +import android.Manifest; +import android.app.Activity; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.os.Build; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.Button; +import android.widget.CompoundButton; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.Switch; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; + +import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity; +import cc.winboll.studio.libaes.utils.AESThemeUtil; +import cc.winboll.studio.libaes.utils.DevelopUtils; +import cc.winboll.studio.libaes.views.ADsBannerView; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.positions.activities.AboutActivity; +import cc.winboll.studio.positions.activities.LocationActivity; +import cc.winboll.studio.positions.activities.SettingsActivity; +import cc.winboll.studio.positions.activities.WinBoLLActivity; +import cc.winboll.studio.positions.handlers.AppIdleRunningModeHandler; +import cc.winboll.studio.positions.utils.AppConfigsUtil; +import cc.winboll.studio.positions.utils.ServiceUtil; +import cc.winboll.studio.positions.services.IdleGpsService; +import cc.winboll.studio.positions.services.MainService; +import cc.winboll.studio.positions.R; +import android.os.Handler; +import android.os.Looper; +import android.os.Handler; +import android.os.Handler; +import android.os.Looper; +import android.content.Intent; +import android.os.Handler; + +/** + * 主页面控制器 + * 功能简述: + * 1. 位置服务启停开关控制 + * 2. 页面菜单跳转与主题管理 + * 3. 应用空转状态接收与日志实时输出展示 + * 4. 全局权限申请与权限结果回调处理 + * @Author 豆包&ZhanGSKen + * @CreateTime 2026/05/03 12:23:00 + * @EditTime 2026/05/03 15:42:15 + */ +public class MainActivity extends WinBoLLActivity implements IWinBoLLActivity { + + // ===================== 常量定义 ===================== + public static final String TAG = "MainActivity"; + private static final int REQUEST_LOCATION_PERMISSIONS = 1001; + private static final int REQUEST_BACKGROUND_LOCATION_PERMISSION = 1002; + + // ===================== UI控件声明 ===================== + private Toolbar mToolbar; + private Switch mServiceSwitch; + private Button mManagePositionsButton; + private ADsBannerView mADsBannerView; + private TextView mTvIdleLog; + private ScrollView mScrollIdleLog; + + // ===================== 业务标记与回调 ===================== + private boolean isServiceBound = false; + private OnAppIdleRunningListener mIdleRunningListener; + + /** + * 应用空转状态回调内部接口 + */ + public interface OnAppIdleRunningListener { + void onIdleStatusChange(boolean isRunning); + void onIdleLogReceive(String log); + } + + // ===================== 回调绑定方法 ===================== + public void setOnAppIdleRunningListener(OnAppIdleRunningListener listener) { + this.mIdleRunningListener = listener; + LogUtils.i(TAG, "setOnAppIdleRunningListener -> 空转监听绑定完成"); + } + + public OnAppIdleRunningListener getOnAppIdleRunningListener() { + return mIdleRunningListener; + } + + // ===================== 生命周期重写 ===================== + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + LogUtils.i(TAG, "onCreate -> MainActivity页面创建完成"); + + initToolbar(); + initViews(); + setLLMainBackgroundColor(); + + if (!checkLocationPermissions()) { + LogUtils.d(TAG, "onCreate -> 定位权限未授予,发起权限申请"); + requestLocationPermissions(); + } + + mADsBannerView = findViewById(R.id.adsbanner); + initAppIdleHandler(); + refreshIdleLogLayout(); + + // 根据调试模式控制日志区域的显示 + if (App.isDebugging()) { + mScrollIdleLog.setVisibility(View.VISIBLE); + LogUtils.d(TAG, "onCreate -> 调试模式,显示日志区域"); + } else { + mScrollIdleLog.setVisibility(View.GONE); + LogUtils.d(TAG, "onCreate -> 非调试模式,隐藏日志区域"); + } + } + + @Override + protected void onResume() { + super.onResume(); + LogUtils.d(TAG, "onResume -> 页面恢复可见"); + if (App.isAppIdleRunning()) { + IdleGpsService.getInstance().start(); + } + if (mADsBannerView != null) { + mADsBannerView.resumeADs(MainActivity.this); + } + // 重新加载菜单以根据当前调试状态刷新 + invalidateOptionsMenu(); + LogUtils.d(TAG, "onResume -> 重新加载菜单完成"); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + LogUtils.i(TAG, "onDestroy -> MainActivity页面销毁释放资源"); + if (mADsBannerView != null) { + mADsBannerView.releaseAdResources(); + } + mIdleRunningListener = null; + } + + // ===================== 初始化相关方法 ===================== + private void initToolbar() { + mToolbar = (Toolbar) findViewById(R.id.toolbar); + setSupportActionBar(mToolbar); + if (getSupportActionBar() != null) { + getSupportActionBar().setTitle(getString(R.string.app_name)); + } + LogUtils.d(TAG, "initToolbar -> 顶部工具栏初始化完毕"); + } + + private void initViews() { + mTvIdleLog = (TextView) findViewById(R.id.tv_idle_log); + mScrollIdleLog = (ScrollView) findViewById(R.id.scroll_idle_log); + mServiceSwitch = (Switch) findViewById(R.id.switch_service_control); + mManagePositionsButton = (Button) findViewById(R.id.btn_manage_positions); + + boolean serviceEnable = AppConfigsUtil.getInstance(this).isEnableMainService(true); + mServiceSwitch.setChecked(serviceEnable); + mManagePositionsButton.setEnabled(serviceEnable); + + mServiceSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + //切换开关时先校验权限,无权限直接强制关闭开关 + if(isChecked && !checkLocationPermissions()){ + mServiceSwitch.setChecked(false); + Toast.makeText(MainActivity.this,"未获取定位权限,无法开启GPS服务",Toast.LENGTH_SHORT).show(); + return; + } + + LogUtils.d(TAG, "onCheckedChanged -> 服务开关状态变更,isChecked = " + isChecked); + if (isChecked) { + ServiceUtil.startAutoService(MainActivity.this); + } else { + ServiceUtil.stopAutoService(MainActivity.this); + } + mManagePositionsButton.setEnabled(isChecked); + refreshManageButtonState(); + } + }); + LogUtils.i(TAG, "initViews -> 全部UI控件初始化绑定完成"); + } + + private void initAppIdleHandler() { + AppIdleRunningModeHandler.init(MainActivity.this); + setOnAppIdleRunningListener(new OnAppIdleRunningListener() { + @Override + public void onIdleStatusChange(boolean isRunning) { + refreshIdleLogLayout(); + appendIdleLog("IdleRunning Status : " + isRunning); + } + + @Override + public void onIdleLogReceive(String log) { + appendIdleLog(log); + } + }); + LogUtils.i(TAG, "initAppIdleHandler -> 空转处理器初始化与监听绑定完成"); + } + + private void setLLMainBackgroundColor() { + TypedArray typedArray = getTheme().obtainStyledAttributes(new int[]{android.R.attr.colorAccent}); + int colorAccent = typedArray.getColor(0, Color.GRAY); + typedArray.recycle(); + LinearLayout llmain = findViewById(R.id.llmain); + llmain.setBackgroundColor(colorAccent); + } + + // ===================== 空转日志输出工具 ===================== + private void appendIdleLog(final String logText) { + runOnUiThread(new Runnable() { + @Override + public void run() { + String allLog = mTvIdleLog.getText().toString(); + mTvIdleLog.setText(allLog + logText + "\n"); + mScrollIdleLog.post(new Runnable() { + @Override + public void run() { + mScrollIdleLog.fullScroll(ScrollView.FOCUS_DOWN); + } + }); + } + }); + } + + private void refreshIdleLogLayout() { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (App.isAppIdleRunning()) { + mScrollIdleLog.setBackgroundResource(R.drawable.shape_log_border); + } else { + //关闭空转时:检测权限,无权限强制关闭GPS开关 + if(!checkLocationPermissions()){ + mServiceSwitch.setChecked(false); + ServiceUtil.stopAutoService(MainActivity.this); + } + mTvIdleLog.setText(""); + mScrollIdleLog.setBackgroundColor(Color.TRANSPARENT); + LogUtils.d(TAG, "refreshIdleLogLayout -> 非空转状态:日志清空、边框已移除"); + } + refreshToolbarSubTitle(); + refreshManageButtonState(); + } + }); + } + + private void refreshToolbarSubTitle() { + if(getSupportActionBar() == null){ + return; + } + if(App.isAppIdleRunning()){ + getSupportActionBar().setSubtitle("当前处于空转运行状态"); + }else{ + getSupportActionBar().setSubtitle(""); + } + } + + /** + * 空转状态专属:按钮强制可点击 + 文字追加提示 + */ + private void refreshManageButtonState() { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (App.isAppIdleRunning()) { + mManagePositionsButton.setText("位置与任务管理【当前空转中】"); + mManagePositionsButton.setEnabled(true); + } else { + mManagePositionsButton.setText("位置与任务管理"); + boolean serviceEnable = AppConfigsUtil.getInstance(MainActivity.this).isEnableMainService(true); + mManagePositionsButton.setEnabled(serviceEnable); + } + } + }); + } + + // ===================== 权限处理相关 ===================== + private boolean checkLocationPermissions() { + int foregroundPerm = checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION); + boolean hasForegroundPerm = (foregroundPerm == PackageManager.PERMISSION_GRANTED); + boolean hasBackgroundPerm = true; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + int backgroundPerm = checkSelfPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION); + hasBackgroundPerm = (backgroundPerm == PackageManager.PERMISSION_GRANTED); + } + return hasForegroundPerm && hasBackgroundPerm; + } + + private void requestLocationPermissions() { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) + != PackageManager.PERMISSION_GRANTED) { + String[] foregroundPermissions = new String[]{Manifest.permission.ACCESS_FINE_LOCATION}; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + requestPermissions(foregroundPermissions, REQUEST_LOCATION_PERMISSIONS); + } + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION) + != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[]{Manifest.permission.ACCESS_BACKGROUND_LOCATION}, + REQUEST_BACKGROUND_LOCATION_PERMISSION); + } + } + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + LogUtils.d(TAG, "onRequestPermissionsResult -> 权限回调 requestCode = " + requestCode); + + if (requestCode == REQUEST_LOCATION_PERMISSIONS) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + requestLocationPermissions(); + } else { + Toast.makeText(this, "需要前台定位权限才能使用该功能", Toast.LENGTH_SHORT).show(); + //权限被拒绝,强制关闭开关 + mServiceSwitch.setChecked(false); + ServiceUtil.stopAutoService(MainActivity.this); + mManagePositionsButton.setEnabled(false); + } + } else if (requestCode == REQUEST_BACKGROUND_LOCATION_PERMISSION) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + Toast.makeText(this, "已获得后台定位权限", Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(this, "拒绝后台权限将无法在后台持续定位", Toast.LENGTH_SHORT).show(); + } + } + } + + // ===================== 菜单与页面跳转 ===================== + @Override + public boolean onCreateOptionsMenu(Menu menu) { + AESThemeUtil.inflateMenu(this, menu); + if (App.isDebugging()) { + DevelopUtils.inflateMenu(this, menu); + getMenuInflater().inflate(R.menu.toolbar_main_idle, menu); + } + getMenuInflater().inflate(R.menu.toolbar_main, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int menuItemId = item.getItemId(); + LogUtils.d(TAG, "onOptionsItemSelected -> 点击菜单ID = " + menuItemId); + + if (AESThemeUtil.onAppThemeItemSelected(this, item)) { + recreate(); + } else if (DevelopUtils.onDevelopItemSelected(this, item)) { + LogUtils.d(TAG, "onOptionsItemSelected -> 进入开发工具菜单"); + } else if (menuItemId == R.id.item_idle_switch) { + boolean idleNow = App.isAppIdleRunning(); + boolean idleNew = !idleNow; + App.setAppIdleRunning(idleNew); + AppIdleRunningModeHandler.sendIdleSwitch(idleNew); + AppIdleRunningModeHandler.sendIdleLog("菜单手动切换空转状态:" + idleNew); + LogUtils.d(TAG, "onOptionsItemSelected -> 空转状态已切换,当前:" + idleNew); + refreshIdleLogLayout(); + } else if (menuItemId == R.id.item_settings) { + Intent intent = new Intent(); + intent.setClass(this, SettingsActivity.class); + startActivity(intent); + } else if (menuItemId == R.id.item_about) { + Intent intent = new Intent(); + intent.setClass(this, AboutActivity.class); + startActivity(intent); + } else { + return super.onOptionsItemSelected(item); + } + return true; + } + + public void onPositions(View view) { + LogUtils.d(TAG, "onPositions -> 跳转位置任务管理页面"); + startActivity(new Intent(MainActivity.this, LocationActivity.class)); + } + + // ===================== 接口实现 ===================== + @Override + public Activity getActivity() { + return this; + } + + @Override + public String getTag() { + return TAG; + } +} + diff --git a/positions/src/main/java/cc/winboll/studio/positions/activities/AboutActivity.java b/positions/src/main/java/cc/winboll/studio/positions/activities/AboutActivity.java new file mode 100644 index 0000000..ef47cce --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/activities/AboutActivity.java @@ -0,0 +1,81 @@ +package cc.winboll.studio.positions.activities; + +import android.app.Activity; +import android.os.Bundle; +import android.view.View; +import androidx.appcompat.widget.Toolbar; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.models.APPInfo; +import cc.winboll.studio.libappbase.views.AboutView; +import cc.winboll.studio.positions.R; + +/** + * @Author 豆包&ZhanGSKen + * @Date 2026/01/13 11:25 + * @Describe 应用介绍窗口 + */ +public class AboutActivity extends WinBoLLActivity { + + public static final String TAG = "AboutActivity"; + private Toolbar mToolbar; + @Override + public Activity getActivity() { + return this; + } + + @Override + public String getTag() { + return TAG; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_about); + + // 设置工具栏 + initToolbar(); + + AboutView aboutView = findViewById(R.id.aboutview); + aboutView.setAPPInfo(genDefaultAppInfo()); + } + + private void initToolbar() { + LogUtils.d(TAG, "initToolbar() 开始初始化"); + mToolbar = findViewById(R.id.toolbar); + if (mToolbar == null) { + LogUtils.e(TAG, "initToolbar() | Toolbar未找到"); + return; + } + setSupportActionBar(mToolbar); + mToolbar.setSubtitle(getTag()); + mToolbar.setTitleTextAppearance(this, R.style.Toolbar_TitleText); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + mToolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "导航栏 点击返回按钮"); + finish(); + } + }); + LogUtils.d(TAG, "initToolbar() 配置完成"); + } + + private APPInfo genDefaultAppInfo() { + LogUtils.d(TAG, "genDefaultAppInfo() 调用"); + String branchName = "positions"; + APPInfo appInfo = new APPInfo(); + appInfo.setAppName(getString(R.string.app_name)); + appInfo.setAppIcon(R.drawable.ic_winboll); + appInfo.setAppDescription(getString(R.string.app_description)); + appInfo.setAppGitName("Positions"); + appInfo.setAppGitOwner("Studio"); + appInfo.setAppGitAPPBranch(branchName); + appInfo.setAppGitAPPSubProjectFolder(branchName); + appInfo.setAppHomePage("https://www.winboll.cc/apks/index.php?project=Positions"); + appInfo.setAppAPKName("Positions"); + appInfo.setAppAPKFolderName("Positions"); + LogUtils.d(TAG, "genDefaultAppInfo: 应用信息已生成"); + return appInfo; + } +} diff --git a/positions/src/main/java/cc/winboll/studio/positions/activities/LocationActivity.java b/positions/src/main/java/cc/winboll/studio/positions/activities/LocationActivity.java new file mode 100644 index 0000000..412f826 --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/activities/LocationActivity.java @@ -0,0 +1,487 @@ +package cc.winboll.studio.positions.activities; + +import android.app.Activity; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Bundle; +import android.os.IBinder; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.widget.Toolbar; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import cc.winboll.studio.libaes.dialogs.YesNoAlertDialog; +import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.positions.App; +import cc.winboll.studio.positions.MainActivity; +import cc.winboll.studio.positions.R; +import cc.winboll.studio.positions.adapters.PositionAdapter; +import cc.winboll.studio.positions.models.PositionModel; +import cc.winboll.studio.positions.services.IdleGpsService; +import cc.winboll.studio.positions.services.MainService; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * @Author 豆包&ZhanGSKen + * @CreateTime 2025/09/29 18:22:00 + * @EditTime 2026/05/03 16:36:54 + * @Describe 位置列表页面,适配MainService GPS接口、规范服务交互、完善生命周期资源释放,新增应用空转状态副标题提示 + */ +public class LocationActivity extends WinBoLLActivity implements IWinBoLLActivity { + // 常量 + public static final String TAG = "LocationActivity"; + + // 成员属性有序排版 + private Toolbar mToolbar; + private RecyclerView mRvPosition; + private PositionAdapter mPositionAdapter; + + private MainService mMainService; + private final AtomicBoolean isServiceBound = new AtomicBoolean(false); + private final AtomicBoolean isAdapterInited = new AtomicBoolean(false); + + private MainService.GpsUpdateListener mGpsUpdateListener; + private PositionModel mCurrentGpsPos; + private final ArrayList mLocalPosCache = new ArrayList(); + + // 服务连接回调 + private final ServiceConnection mServiceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(final ComponentName name, final IBinder service) { + LogUtils.d(TAG, "onServiceConnected invoke"); + if (!(service instanceof MainService.LocalBinder)) { + LogUtils.e(TAG, "服务绑定失败:Binder类型不匹配"); + isServiceBound.set(false); + return; + } + + try { + final MainService.LocalBinder binder = (MainService.LocalBinder) service; + mMainService = binder.getService(); + isServiceBound.set(true); + LogUtils.d(TAG, "MainService bind success"); + + syncDataFromMainService(); + registerGpsListener(); + initPositionAdapter(); + } catch (final Exception e) { + LogUtils.e(TAG, "服务绑定异常:" + e.getMessage()); + isServiceBound.set(false); + mMainService = null; + showToast("服务初始化失败,无法加载数据"); + } + } + + @Override + public void onServiceDisconnected(final ComponentName name) { + LogUtils.w(TAG, "MainService 服务断开"); + mMainService = null; + isServiceBound.set(false); + isAdapterInited.set(false); + } + }; + + // 接口方法实现 + @Override + public Activity getActivity() { + return this; + } + + @Override + public String getTag() { + return TAG; + } + + /** + * 刷新空转状态副标题 + */ + private void refreshIdleStatusTitle() { + LogUtils.d(TAG, "refreshIdleStatusTitle invoke"); + if (mToolbar == null) { + LogUtils.w(TAG, "Toolbar为空,跳过刷新"); + return; + } + final boolean idleStatus = App.isAppIdleRunning(); + if (idleStatus) { + mToolbar.setSubtitle("当前状态:应用正在空转运行"); + LogUtils.d(TAG, "检测应用空转状态为 : " + idleStatus); + } else { + mToolbar.setSubtitle(getTag()); + } + } + + /** + * 初始化页面控件 + */ + private void initView() { + LogUtils.d(TAG, "initView invoke"); + mRvPosition = findViewById(R.id.rv_position_list); + final LinearLayoutManager layoutManager = new LinearLayoutManager(this); + layoutManager.setOrientation(LinearLayoutManager.VERTICAL); + mRvPosition.setLayoutManager(layoutManager); + mLocalPosCache.clear(); + } + + /** + * 绑定后台服务 + */ + private void bindMainService() { + LogUtils.d(TAG, "bindMainService invoke"); + if (isServiceBound.get()) { + LogUtils.w(TAG, "服务已绑定,无需重复执行"); + return; + } + final Intent serviceIntent = new Intent(this, MainService.class); + final boolean bindSuccess = bindService(serviceIntent, mServiceConnection, BIND_AUTO_CREATE); + if (!bindSuccess) { + LogUtils.e(TAG, "发起服务绑定请求失败"); + showToast("服务绑定失败,无法加载位置数据"); + } + } + + /** + * 同步服务数据至本地缓存 + */ + private void syncDataFromMainService() { + LogUtils.d(TAG, "syncDataFromMainService invoke"); + if (!isServiceBound.get() || mMainService == null) { + LogUtils.w(TAG, "服务未就绪,使用本地缓存"); + return; + } + try { + final ArrayList servicePosList = mMainService.getPositionList(); + synchronized (mLocalPosCache) { + mLocalPosCache.clear(); + if (servicePosList != null && !servicePosList.isEmpty()) { + mLocalPosCache.addAll(servicePosList); + } + } + LogUtils.d(TAG, "同步完成,缓存数量 : " + mLocalPosCache.size()); + } catch (final Exception e) { + LogUtils.e(TAG, "数据同步异常 : " + e.getMessage()); + } + } + + /** + * 初始化列表适配器 + */ + private void initPositionAdapter() { + LogUtils.d(TAG, "initPositionAdapter invoke"); + if (isAdapterInited.get() || !isServiceBound.get() + || mMainService == null || mRvPosition == null) { + LogUtils.w(TAG, "适配器初始化条件不满足,跳过"); + return; + } + + try { + mPositionAdapter = new PositionAdapter(this, mLocalPosCache, mMainService); + mPositionAdapter.setOnDeleteClickListener(new PositionAdapter.OnDeleteClickListener() { + @Override + public void onDeleteClick(final int position) { + LogUtils.d(TAG, "onDeleteClick position = " + position); + YesNoAlertDialog.show(LocationActivity.this, "删除位置提示", + "是否删除此项锚点位置?", new YesNoAlertDialog.OnDialogResultListener() { + @Override + public void onNo() { + + } + + @Override + public void onYes() { + if (position < 0 || position >= mLocalPosCache.size()) { + LogUtils.w(TAG, "删除索引参数非法"); + return; + } + final PositionModel deletePos = mLocalPosCache.get(position); + if (deletePos != null && !deletePos.getPositionId().isEmpty()) { + mMainService.removePosition(deletePos.getPositionId()); + synchronized (mLocalPosCache) { + mLocalPosCache.remove(position); + } + mPositionAdapter.notifyItemRemoved(position); + showToast("删除位置成功:" + deletePos.getMemo()); + } + } + }); + } + }); + + mPositionAdapter.setOnSavePositionClickListener(new PositionAdapter.OnSavePositionClickListener() { + @Override + public void onSavePositionClick(final int position, final PositionModel updatedPos) { + LogUtils.d(TAG, "onSavePositionClick position = " + position); + if (position < 0 || position >= mLocalPosCache.size() || updatedPos == null) { + LogUtils.w(TAG, "保存参数非法"); + showToast("服务未就绪,保存失败"); + return; + } + mMainService.updatePosition(updatedPos); + synchronized (mLocalPosCache) { + mLocalPosCache.set(position, updatedPos); + } + mPositionAdapter.notifyItemChanged(position); + showToast("保存位置成功:" + updatedPos.getMemo()); + } + }); + + mRvPosition.setAdapter(mPositionAdapter); + isAdapterInited.set(true); + LogUtils.d(TAG, "适配器初始化完成"); + } catch (final Exception e) { + LogUtils.e(TAG, "适配器初始化异常 : " + e.getMessage()); + isAdapterInited.set(false); + mPositionAdapter = null; + showToast("位置列表初始化失败,请重试"); + } + } + + /** + * 通用Toast弹窗 + */ + private void showToast(final String content) { + if (isFinishing() || isDestroyed()) { + LogUtils.w(TAG, "页面已销毁,取消Toast"); + return; + } + Toast.makeText(this, content, Toast.LENGTH_SHORT).show(); + } + + /** + * 新增位置按钮事件 + */ + public void addNewPosition(final View view) { + LogUtils.d(TAG, "addNewPosition invoke"); + final InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null && getCurrentFocus() != null) { + imm.hideSoftInputFromWindow(getCurrentFocus().getWindowToken(), 0); + } + + if (!isServiceBound.get() || mMainService == null) { + LogUtils.w(TAG, "服务未绑定,无法新增"); + showToast("服务未就绪,无法新增位置"); + return; + } + + final PositionModel newPos = new PositionModel(); + newPos.setPositionId(PositionModel.genPositionId()); + if (mCurrentGpsPos != null) { + newPos.setLongitude(mCurrentGpsPos.getLongitude()); + newPos.setLatitude(mCurrentGpsPos.getLatitude()); + newPos.setMemo("当前GPS位置(可编辑)"); + } else { + newPos.setLongitude(116.404267); + newPos.setLatitude(39.915119); + newPos.setMemo("默认位置(可编辑备注)"); + } + newPos.setIsSimpleView(true); + newPos.setIsEnableRealPositionDistance(true); + + mMainService.addPosition(newPos); + synchronized (mLocalPosCache) { + mLocalPosCache.add(newPos); + } + LogUtils.d(TAG, "新增位置成功,ID : " + newPos.getPositionId()); + + if (isAdapterInited.get() && mPositionAdapter != null) { + mPositionAdapter.notifyItemInserted(mLocalPosCache.size() - 1); + } + showToast("新增位置成功(已启用GPS距离计算)"); + } + + /** + * 初始化GPS监听实例 + */ + private void initGpsUpdateListener() { + LogUtils.d(TAG, "initGpsUpdateListener invoke"); + mGpsUpdateListener = new MainService.GpsUpdateListener() { + @Override + public void onGpsPositionUpdated(final PositionModel currentGpsPos) { + if (currentGpsPos == null || isFinishing() || isDestroyed()) { + return; + } + mCurrentGpsPos = currentGpsPos; + LogUtils.d(TAG, "GPS更新 -> 纬度:" + currentGpsPos.getLatitude() + " 经度:" + currentGpsPos.getLongitude()); + + final TextView tvLat = (TextView) findViewById(R.id.tv_latitude); + final TextView tvLon = (TextView) findViewById(R.id.tv_longitude); + final TextView tvTime = (TextView) findViewById(R.id.tv_timenow); + + tvLat.setText(String.format("当前纬度:%f", currentGpsPos.getLatitude())); + tvLon.setText(String.format("当前经度:%f", currentGpsPos.getLongitude())); + + final long currentTime = System.currentTimeMillis(); + final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()); + final String timeStr = sdf.format(new Date(currentTime)); + tvTime.setText("现在时间:" + timeStr); + } + + @Override + public void onGpsStatusChanged(final String status) { + if (status == null || isFinishing() || isDestroyed()) { + return; + } + LogUtils.d(TAG, "GPS状态变更 : " + status); + if (status.contains("未开启") || status.contains("权限") || status.contains("失败")) { + ToastUtils.show("GPS提示:" + status); + } + } + }; + } + + /** + * 刷新GPS监听源(根据空转状态自动切换) + */ + private void refreshGpsListener() { + LogUtils.d(TAG, "refreshGpsListener invoke"); + // 统一注销旧监听(防止重复注册) + IdleGpsService.getInstance().unregisterGpsUpdateListener(mGpsUpdateListener); + if (mMainService != null) { + try { mMainService.unregisterGpsUpdateListener(mGpsUpdateListener); } catch (Exception e) {} + } + // 重新注册正确的监听 + registerGpsListener(); + } + + /** + * 注册GPS监听 + */ + private void registerGpsListener() { + LogUtils.d(TAG, "registerGpsListener invoke"); + if (isFinishing() || isDestroyed() || mGpsUpdateListener == null) { + return; + } + try { + if (App.isAppIdleRunning()) { + IdleGpsService.getInstance().registerGpsUpdateListener(mGpsUpdateListener); + LogUtils.d(TAG, "空转GPS监听注册成功"); + } else { + if (mMainService != null) { + mMainService.registerGpsUpdateListener(mGpsUpdateListener); + LogUtils.d(TAG, "系统GPS监听注册成功"); + } + } + } catch (final Exception e) { + LogUtils.e(TAG, "GPS注册异常 : " + e.getMessage()); + } + } + + /** + * 反注册GPS监听 + */ + private void unregisterGpsListener() { + LogUtils.d(TAG, "unregisterGpsListener invoke"); + if (mGpsUpdateListener == null) { + return; + } + try { + // 尝试从空转服务注销 + IdleGpsService.getInstance().unregisterGpsUpdateListener(mGpsUpdateListener); + } catch (Exception e) { + // ignore + } + try { + // 尝试从主服务注销 + if (mMainService != null) { + mMainService.unregisterGpsUpdateListener(mGpsUpdateListener); + } + } catch (final Exception e) { + LogUtils.e(TAG, "GPS反注册异常 : " + e.getMessage()); + } + } + + // 生命周期方法 + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + LogUtils.d(TAG, "onCreate invoke"); + setContentView(R.layout.activity_location); + + mToolbar = findViewById(R.id.toolbar); + setSupportActionBar(mToolbar); + mToolbar.setTitleTextAppearance(this, R.style.Toolbar_TitleText); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + mToolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View v) { + LogUtils.d(TAG, "点击返回导航按钮"); + final Intent intent = new Intent(LocationActivity.this, MainActivity.class); + startActivity(intent); + finish(); + } + }); + + refreshIdleStatusTitle(); + initView(); + initGpsUpdateListener(); + bindMainService(); + } + + @Override + protected void onResume() { + super.onResume(); + LogUtils.d(TAG, "onResume invoke"); + refreshIdleStatusTitle(); + refreshGpsListener(); // 根据当前空转状态刷新GPS源 + + if (isServiceBound.get() && mMainService != null && !isAdapterInited.get()) { + syncDataFromMainService(); + initPositionAdapter(); + } else if (isServiceBound.get() && mMainService != null && mPositionAdapter != null) { + syncDataFromMainService(); + mPositionAdapter.notifyDataSetChanged(); + } + } + + @Override + protected void onPause() { + super.onPause(); + LogUtils.d(TAG, "onPause invoke"); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + LogUtils.d(TAG, "onDestroy invoke,开始释放资源"); + + unregisterGpsListener(); + + if (mPositionAdapter != null) { + mPositionAdapter.release(); + mPositionAdapter = null; + LogUtils.d(TAG, "Adapter资源释放完成"); + } + + if (isServiceBound.get()) { + try { + unbindService(mServiceConnection); + LogUtils.d(TAG, "服务解绑完成"); + } catch (final IllegalArgumentException e) { + LogUtils.e(TAG, "解绑异常:服务已提前解绑"); + } + isServiceBound.set(false); + mMainService = null; + } + + synchronized (mLocalPosCache) { + mLocalPosCache.clear(); + } + mCurrentGpsPos = null; + mGpsUpdateListener = null; + isAdapterInited.set(false); + LogUtils.d(TAG, "全部资源释放完毕"); + } +} + diff --git a/positions/src/main/java/cc/winboll/studio/positions/activities/SettingsActivity.java b/positions/src/main/java/cc/winboll/studio/positions/activities/SettingsActivity.java new file mode 100644 index 0000000..9b432f1 --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/activities/SettingsActivity.java @@ -0,0 +1,51 @@ +package cc.winboll.studio.positions.activities; + +import android.app.Activity; +import android.os.Bundle; +import android.view.View; +import androidx.appcompat.widget.Toolbar; +import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.positions.R; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/12/07 23:29 + * @Describe 应用设置活动窗口 + */ +public class SettingsActivity extends WinBoLLActivity implements IWinBoLLActivity { + + public static final String TAG = "SettingsActivity"; + + private Toolbar mToolbar; + + @Override + public Activity getActivity() { + return this; + } + + @Override + public String getTag() { + return TAG; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_settings); + + mToolbar = findViewById(R.id.toolbar); + setSupportActionBar(mToolbar); + mToolbar.setSubtitle(getTag()); + mToolbar.setTitleTextAppearance(this, R.style.Toolbar_TitleText); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + mToolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "【导航栏】点击返回"); + finish(); + } + }); + } +} diff --git a/positions/src/main/java/cc/winboll/studio/positions/activities/WinBoLLActivity.java b/positions/src/main/java/cc/winboll/studio/positions/activities/WinBoLLActivity.java new file mode 100644 index 0000000..fba149f --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/activities/WinBoLLActivity.java @@ -0,0 +1,80 @@ +package cc.winboll.studio.positions.activities; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/09/29 00:11 + * @Describe WinBoLL 窗口基础类 + */ +import android.app.Activity; +import android.os.Bundle; +import android.view.MenuItem; +import androidx.appcompat.app.AppCompatActivity; +import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity; +import cc.winboll.studio.libaes.models.AESThemeBean; +import cc.winboll.studio.libaes.utils.AESThemeUtil; +import cc.winboll.studio.libaes.utils.WinBoLLActivityManager; +import cc.winboll.studio.libappbase.LogUtils; + +public class WinBoLLActivity extends AppCompatActivity implements IWinBoLLActivity { + + public static final String TAG = "WinBoLLActivity"; + + protected volatile AESThemeBean.ThemeType mThemeType; + + @Override + public Activity getActivity() { + return this; + } + + @Override + public String getTag() { + return TAG; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + mThemeType = getThemeType(); + setThemeStyle(); + super.onCreate(savedInstanceState); + } + + AESThemeBean.ThemeType getThemeType() { + return AESThemeBean.getThemeStyleType(AESThemeUtil.getThemeTypeID(getApplicationContext())); + } + + void setThemeStyle() { + setTheme(AESThemeUtil.getThemeTypeID(getApplicationContext())); + } + + + @Override + protected void onResume() { + super.onResume(); + LogUtils.d(TAG, String.format("onResume %s", getTag())); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + /*if (item.getItemId() == R.id.item_log) { + WinBoLLActivityManager.getInstance().startLogActivity(this); + return true; + } else if (item.getItemId() == R.id.item_home) { + startActivity(new Intent(this, MainActivity.class)); + return true; + }*/ + // 在switch语句中处理每个ID,并在处理完后返回true,未处理的情况返回false。 + return super.onOptionsItemSelected(item); + } + + @Override + protected void onPostCreate(Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + WinBoLLActivityManager.getInstance().add(this); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + WinBoLLActivityManager.getInstance().registeRemove(this); + } +} diff --git a/positions/src/main/java/cc/winboll/studio/positions/adapters/PositionAdapter.java b/positions/src/main/java/cc/winboll/studio/positions/adapters/PositionAdapter.java new file mode 100644 index 0000000..1f98eac --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/adapters/PositionAdapter.java @@ -0,0 +1,639 @@ +package cc.winboll.studio.positions.adapters; + +/** + * @Describe 位置数据适配器(修复视图复用资源加载,支持滚动后重新绑定数据,Java 7语法适配) + * @Author 豆包&ZhanGSKen + * @CreateTime 2025-09-29 20:25:00 + * @EditTime 2026-03-31 23:14:55 + */ + +import android.content.Context; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; +import android.widget.EditText; +import android.widget.RadioGroup; +import android.widget.TextView; +import androidx.recyclerview.widget.RecyclerView; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.positions.R; +import cc.winboll.studio.positions.models.PositionModel; +import cc.winboll.studio.positions.models.PositionTaskModel; +import cc.winboll.studio.positions.services.MainService; +import cc.winboll.studio.positions.views.PositionTaskListView; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.concurrent.ConcurrentHashMap; + +public class PositionAdapter extends RecyclerView.Adapter +implements MainService.TaskUpdateListener { + + public static final String TAG = "PositionAdapter"; + + // 视图类型常量 + private static final int VIEW_TYPE_SIMPLE = 0; + private static final int VIEW_TYPE_EDIT = 1; + + // 默认配置常量 + private static final String DEFAULT_MEMO = "无备注"; + private static final String DEFAULT_TASK_DESC = "新任务"; + private static final int DEFAULT_TASK_DISTANCE = 50; + private static final String DISTANCE_FORMAT = "实时距离:%.1f 米"; + private static final String DISTANCE_DISABLED = "实时距离:未启用"; + private static final String DISTANCE_ERROR = "实时距离:计算失败"; + + // 核心成员变量 + private final Context mContext; + private final ArrayList mCachedPositionList; + private final WeakReference mMainServiceRef; + private final ConcurrentHashMap mSimpleTaskViewMap; + private final ConcurrentHashMap mEditTaskViewMap; + private final ConcurrentHashMap mPosDistanceViewMap; + + // 回调接口 + public interface OnDeleteClickListener { + void onDeleteClick(int position); + } + + public interface OnSavePositionClickListener { + void onSavePositionClick(int position, PositionModel updatedPos); + } + + private OnDeleteClickListener mOnDeleteListener; + private OnSavePositionClickListener mOnSavePosListener; + + // ========================================================================= + // 构造函数 + // ========================================================================= + public PositionAdapter(Context context, ArrayList cachedPositionList, MainService mainService) { + LogUtils.d(TAG, "PositionAdapter 构造函数开始,context=" + context + + ",cachedPositionList=" + (cachedPositionList != null ? cachedPositionList.size() : 0) + + ",mainService=" + mainService); + + this.mContext = context; + this.mCachedPositionList = (cachedPositionList != null) ? cachedPositionList : new ArrayList(); + this.mMainServiceRef = new WeakReference(mainService); + this.mSimpleTaskViewMap = new ConcurrentHashMap(); + this.mEditTaskViewMap = new ConcurrentHashMap(); + this.mPosDistanceViewMap = new ConcurrentHashMap(); + + if (mainService != null) { + mainService.registerTaskUpdateListener(this); + LogUtils.d(TAG, "已注册 MainService 任务监听"); + } else { + LogUtils.w(TAG, "构造函数:MainService 为空,无法初始化任务视图"); + } + + LogUtils.d(TAG, "PositionAdapter 初始化完成,位置数量=" + mCachedPositionList.size()); + } + + // ========================================================================= + // RecyclerView 核心方法 + // ========================================================================= + @Override + public int getItemViewType(int position) { + PositionModel posModel = getPositionByIndex(position); + return (posModel != null && posModel.isSimpleView()) ? VIEW_TYPE_SIMPLE : VIEW_TYPE_EDIT; + } + + @Override + public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + LogUtils.d(TAG, "onCreateViewHolder viewType=" + viewType); + LayoutInflater inflater = LayoutInflater.from(mContext); + if (viewType == VIEW_TYPE_SIMPLE) { + View simpleView = inflater.inflate(R.layout.item_position_simple, parent, false); + return new SimpleViewHolder(simpleView); + } else { + View editView = inflater.inflate(R.layout.item_position_edit, parent, false); + return new EditViewHolder(editView); + } + } + + @Override + public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { + LogUtils.d(TAG, "onBindViewHolder position=" + position); + PositionModel posModel = getPositionByIndex(position); + if (posModel == null) { + LogUtils.w(TAG, "onBindViewHolder:位置模型为空,索引=" + position); + return; + } + final String posId = posModel.getPositionId(); + MainService mainService = mMainServiceRef.get(); + + if (holder instanceof SimpleViewHolder) { + LogUtils.d(TAG, "绑定 SimpleViewHolder,posId=" + posId); + SimpleViewHolder simpleHolder = (SimpleViewHolder) holder; + bindSimplePositionData(simpleHolder, posModel); + initAndBindSimpleTaskView(simpleHolder.ptlvSimpleTasks, posId, mainService); + mSimpleTaskViewMap.put(posId, simpleHolder.ptlvSimpleTasks); + mPosDistanceViewMap.put(posId, simpleHolder.tvSimpleDistance); + + } else if (holder instanceof EditViewHolder) { + LogUtils.d(TAG, "绑定 EditViewHolder,posId=" + posId); + EditViewHolder editHolder = (EditViewHolder) holder; + bindEditPositionData(editHolder, posModel, position); + initAndBindEditTaskView(editHolder.ptlvEditTasks, posId, mainService, editHolder.btnAddTask); + mEditTaskViewMap.put(posId, editHolder.ptlvEditTasks); + mPosDistanceViewMap.put(posId, editHolder.tvEditDistance); + } + } + + @Override + public void onViewDetachedFromWindow(RecyclerView.ViewHolder holder) { + super.onViewDetachedFromWindow(holder); + PositionModel posModel = getPositionByIndex(holder.getAdapterPosition()); + if (posModel == null || TextUtils.isEmpty(posModel.getPositionId())) { + return; + } + String posId = posModel.getPositionId(); + + if (mPosDistanceViewMap.containsKey(posId)) { + TextView distanceView = mPosDistanceViewMap.get(posId); + if (distanceView == null || !distanceView.isAttachedToWindow()) { + mPosDistanceViewMap.remove(posId); + LogUtils.d(TAG, "视图脱离:移除无效距离控件缓存 posId=" + posId); + } + } + + if (holder instanceof SimpleViewHolder && mSimpleTaskViewMap.containsKey(posId)) { + PositionTaskListView taskView = mSimpleTaskViewMap.get(posId); + if (taskView == null || !taskView.isAttachedToWindow()) { + mSimpleTaskViewMap.remove(posId); + LogUtils.d(TAG, "视图脱离:移除无效简单任务视图缓存 posId=" + posId); + } + } + + if (holder instanceof EditViewHolder && mEditTaskViewMap.containsKey(posId)) { + PositionTaskListView taskView = mEditTaskViewMap.get(posId); + if (taskView == null || !taskView.isAttachedToWindow()) { + mEditTaskViewMap.remove(posId); + LogUtils.d(TAG, "视图脱离:移除无效编辑任务视图缓存 posId=" + posId); + } + } + } + + @Override + public int getItemCount() { + return mCachedPositionList.size(); + } + + // ========================================================================= + // 位置数据绑定 + // ========================================================================= + private void bindSimplePositionData(SimpleViewHolder holder, final PositionModel posModel) { + LogUtils.d(TAG, "bindSimplePositionData posId=" + posModel.getPositionId()); + holder.tvSimpleLon.setText(String.format("经度:%.6f", posModel.getLongitude())); + holder.tvSimpleLat.setText(String.format("纬度:%.6f", posModel.getLatitude())); + + String memo = posModel.getMemo(); + holder.tvSimpleMemo.setText("备注:" + (TextUtils.isEmpty(memo) ? DEFAULT_MEMO : memo)); + updateDistanceDisplay(holder.tvSimpleDistance, posModel); + + holder.itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + posModel.setIsSimpleView(false); + notifyItemChanged(getPositionIndexById(posModel.getPositionId())); + LogUtils.d(TAG, "简单视图点击:切换编辑模式 posId=" + posModel.getPositionId()); + } + }); + } + + private void bindEditPositionData(final EditViewHolder holder, final PositionModel posModel, final int position) { + LogUtils.d(TAG, "bindEditPositionData posId=" + posModel.getPositionId() + " position=" + position); + final String posId = posModel.getPositionId(); + + holder.tvEditLon.setText(String.format("经度:%.6f", posModel.getLongitude())); + holder.tvEditLat.setText(String.format("纬度:%.6f", posModel.getLatitude())); + + String memo = posModel.getMemo(); + if (!TextUtils.isEmpty(memo)) { + holder.etEditMemo.setText(memo); + holder.etEditMemo.setSelection(memo.length()); + } else { + holder.etEditMemo.setText(""); + } + + updateDistanceDisplay(holder.tvEditDistance, posModel); + + holder.rgDistanceSwitch.check(posModel.isEnableRealPositionDistance() + ? R.id.rb_distance_enable + : R.id.rb_distance_disable); + + holder.btnCancel.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + posModel.setIsSimpleView(true); + notifyItemChanged(position); + hideSoftKeyboard(v); + LogUtils.d(TAG, "取消编辑:切换简单视图 posId=" + posId); + } + }); + + holder.btnDelete.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (mOnDeleteListener != null) { + mOnDeleteListener.onDeleteClick(position); + } + hideSoftKeyboard(v); + LogUtils.d(TAG, "触发删除位置 posId=" + posId); + } + }); + + holder.btnSave.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + String newMemo = holder.etEditMemo.getText().toString().trim(); + boolean isDistanceEnable = (holder.rgDistanceSwitch.getCheckedRadioButtonId() == R.id.rb_distance_enable); + + PositionModel updatedPos = new PositionModel(); + updatedPos.setPositionId(posId); + updatedPos.setLongitude(posModel.getLongitude()); + updatedPos.setLatitude(posModel.getLatitude()); + updatedPos.setMemo(newMemo); + updatedPos.setIsEnableRealPositionDistance(isDistanceEnable); + updatedPos.setIsSimpleView(true); + + if (mOnSavePosListener != null) { + mOnSavePosListener.onSavePositionClick(position, updatedPos); + } + + posModel.setMemo(newMemo); + posModel.setIsEnableRealPositionDistance(isDistanceEnable); + posModel.setIsSimpleView(true); + notifyItemChanged(position); + hideSoftKeyboard(v); + + LogUtils.d(TAG, "保存位置 posId=" + posId + " 新备注=" + newMemo + " 距离启用=" + isDistanceEnable); + } + }); + } + + // ========================================================================= + // PositionTaskListView 集成 + // ========================================================================= + private void initAndBindSimpleTaskView(PositionTaskListView taskView, String posId, MainService mainService) { + LogUtils.d(TAG, "initAndBindSimpleTaskView posId=" + posId); + if (taskView == null || TextUtils.isEmpty(posId) || mainService == null) { + LogUtils.w(TAG, "初始化简单任务视图失败:参数无效"); + return; + } + taskView.init(mainService, posId); + taskView.setViewStatus(PositionTaskListView.VIEW_MODE_SIMPLE); + taskView.syncTasksFromMainService(); + + taskView.setOnTaskUpdatedListener(new PositionTaskListView.OnTaskUpdatedListener() { + @Override + public void onTaskUpdated(String positionId, ArrayList updatedTasks) { + LogUtils.d(TAG, "简单模式任务更新 posId=" + positionId + " 任务数=" + updatedTasks.size()); + } + }); + } + + private void initAndBindEditTaskView(final PositionTaskListView taskView, final String posId, + MainService mainService, Button btnAddTask) { + LogUtils.d(TAG, "initAndBindEditTaskView posId=" + posId); + if (taskView == null || TextUtils.isEmpty(posId) || mainService == null || btnAddTask == null) { + LogUtils.w(TAG, "初始化编辑任务视图失败:参数无效"); + return; + } + taskView.init(mainService, posId); + taskView.setViewStatus(PositionTaskListView.VIEW_MODE_EDIT); + taskView.syncTasksFromMainService(); + + btnAddTask.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + PositionTaskModel newTask = new PositionTaskModel(); + newTask.setTaskId(PositionTaskModel.genTaskId()); + newTask.setPositionId(posId); + newTask.setTaskDescription(DEFAULT_TASK_DESC); + newTask.setDiscussDistance(DEFAULT_TASK_DISTANCE); + newTask.setIsEnable(true); + newTask.setIsBingo(false); + + taskView.addNewTask(newTask); + hideSoftKeyboard(v); + LogUtils.d(TAG, "新增任务 posId=" + posId + " taskId=" + newTask.getTaskId()); + } + }); + + taskView.setOnTaskUpdatedListener(new PositionTaskListView.OnTaskUpdatedListener() { + @Override + public void onTaskUpdated(String positionId, ArrayList updatedTasks) { + LogUtils.d(TAG, "编辑模式任务更新 posId=" + positionId + " 任务数=" + updatedTasks.size()); + } + }); + } + + // ========================================================================= + // 工具方法 + // ========================================================================= + private void updateDistanceDisplay(TextView distanceView, PositionModel posModel) { + if (distanceView == null || posModel == null) { + LogUtils.w(TAG, "updateDistanceDisplay:参数为空"); + return; + } + if (!posModel.isEnableRealPositionDistance()) { + distanceView.setText(DISTANCE_DISABLED); + distanceView.setTextColor(mContext.getResources().getColor(R.color.gray)); + return; + } + double distance = posModel.getRealPositionDistance(); + if (distance < 0) { + distanceView.setText(DISTANCE_ERROR); + distanceView.setTextColor(mContext.getResources().getColor(R.color.red)); + return; + } + distanceView.setText(String.format(DISTANCE_FORMAT, distance)); + if (distance <= 100) { + distanceView.setTextColor(mContext.getResources().getColor(R.color.green)); + } else if (distance <= 500) { + distanceView.setTextColor(mContext.getResources().getColor(R.color.yellow)); + } else { + distanceView.setTextColor(mContext.getResources().getColor(R.color.red)); + } + } + + private PositionModel getPositionByIndex(int index) { + if (mCachedPositionList == null || index < 0 || index >= mCachedPositionList.size()) { + LogUtils.w(TAG, "getPositionByIndex:无效索引 index=" + index); + return null; + } + return mCachedPositionList.get(index); + } + + private int getPositionIndexById(String positionId) { + if (TextUtils.isEmpty(positionId) || mCachedPositionList == null || mCachedPositionList.isEmpty()) { + LogUtils.w(TAG, "getPositionIndexById:参数无效"); + return -1; + } + for (int i = 0; i < mCachedPositionList.size(); i++) { + PositionModel pos = mCachedPositionList.get(i); + if (positionId.equals(pos.getPositionId())) { + return i; + } + } + LogUtils.w(TAG, "未找到位置ID=" + positionId); + return -1; + } + + public void updateSinglePositionDistance(String positionId) { + LogUtils.d(TAG, "updateSinglePositionDistance posId=" + positionId); + if (TextUtils.isEmpty(positionId) || mPosDistanceViewMap.isEmpty()) { + return; + } + MainService mainService = getMainServiceWithRetry(2); + if (mainService == null) { + LogUtils.e(TAG, "无法获取 MainService"); + return; + } + PositionModel latestPos = null; + try { + ArrayList servicePosList = mainService.getPositionList(); + if (servicePosList != null && !servicePosList.isEmpty()) { + Iterator iter = servicePosList.iterator(); + while (iter.hasNext()) { + PositionModel pos = (PositionModel) iter.next(); + if (positionId.equals(pos.getPositionId())) { + latestPos = pos; + break; + } + } + } + } catch (Exception e) { + LogUtils.e(TAG, "获取位置数据异常", e); + return; + } + if (latestPos == null) { + LogUtils.w(TAG, "未找到位置 posId=" + positionId); + return; + } + final TextView distanceView = mPosDistanceViewMap.get(positionId); + if (distanceView != null && distanceView.isAttachedToWindow()) { + final PositionModel finalLatestPos = latestPos; + distanceView.post(new Runnable() { + @Override + public void run() { + updateDistanceDisplay(distanceView, finalLatestPos); + } + }); + } else { + mPosDistanceViewMap.remove(positionId); + } + } + + public void updateAllPositionData(ArrayList newPosList) { + LogUtils.d(TAG, "updateAllPositionData 新数据数量=" + (newPosList != null ? newPosList.size() : 0)); + if (newPosList == null) { + return; + } + ArrayList validPosList = new ArrayList(); + for (PositionModel pos : newPosList) { + if (TextUtils.isEmpty(pos.getPositionId()) + || pos.getLongitude() < -180 || pos.getLongitude() > 180 + || pos.getLatitude() < -90 || pos.getLatitude() > 90) { + continue; + } + validPosList.add(pos); + } + ConcurrentHashMap uniquePosMap = new ConcurrentHashMap(); + for (PositionModel pos : validPosList) { + uniquePosMap.put(pos.getPositionId(), pos); + } + ArrayList uniquePosList = new ArrayList(uniquePosMap.values()); + + mCachedPositionList.clear(); + mCachedPositionList.addAll(uniquePosList); + mPosDistanceViewMap.clear(); + mSimpleTaskViewMap.clear(); + mEditTaskViewMap.clear(); + notifyDataSetChanged(); + + LogUtils.d(TAG, "全量更新完成,有效数量=" + uniquePosList.size()); + } + + private void hideSoftKeyboard(View view) { + if (mContext == null || view == null) { + return; + } + InputMethodManager imm = (InputMethodManager) mContext.getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.hideSoftInputFromWindow(view.getWindowToken(), 0); + } + } + + private MainService getMainServiceWithRetry(int retryCount) { + MainService mainService = mMainServiceRef.get(); + if (mainService != null) { + return mainService; + } + for (int i = 0; i < retryCount; i++) { + try { + Thread.sleep(100); + mainService = mMainServiceRef.get(); + if (mainService != null) { + LogUtils.d(TAG, "重试获取 MainService 成功,第" + (i + 1) + "次"); + return mainService; + } + } catch (InterruptedException e) { + LogUtils.e(TAG, "重试被中断", e); + Thread.currentThread().interrupt(); + break; + } + } + LogUtils.e(TAG, "重试" + retryCount + "次仍未获取到 MainService"); + return null; + } + + // ========================================================================= + // 任务更新监听 + // ========================================================================= + @Override + public void onTaskUpdated() { + LogUtils.d(TAG, "onTaskUpdated:收到服务任务更新"); + if (!mSimpleTaskViewMap.isEmpty()) { + Iterator> iter = mSimpleTaskViewMap.entrySet().iterator(); + while (iter.hasNext()) { + ConcurrentHashMap.Entry entry = iter.next(); + PositionTaskListView taskView = entry.getValue(); + if (taskView != null && taskView.isAttachedToWindow()) { + taskView.syncTasksFromMainService(); + } else { + iter.remove(); + } + } + } + if (!mEditTaskViewMap.isEmpty()) { + Iterator> iter = mEditTaskViewMap.entrySet().iterator(); + while (iter.hasNext()) { + ConcurrentHashMap.Entry entry = iter.next(); + PositionTaskListView taskView = entry.getValue(); + if (taskView != null && taskView.isAttachedToWindow()) { + taskView.syncTasksFromMainService(); + } else { + iter.remove(); + } + } + } + } + + // ========================================================================= + // 外部回调设置 + // ========================================================================= + public void setOnDeleteClickListener(OnDeleteClickListener listener) { + LogUtils.d(TAG, "setOnDeleteClickListener listener=" + listener); + this.mOnDeleteListener = listener; + } + + public void setOnSavePositionClickListener(OnSavePositionClickListener listener) { + LogUtils.d(TAG, "setOnSavePositionClickListener listener=" + listener); + this.mOnSavePosListener = listener; + } + + // ========================================================================= + // 资源释放 + // ========================================================================= + public void release() { + LogUtils.d(TAG, "release:开始释放 Adapter 资源"); + MainService mainService = mMainServiceRef.get(); + if (mainService != null) { + mainService.unregisterTaskUpdateListener(this); + LogUtils.d(TAG, "已反注册任务监听"); + } + + if (!mSimpleTaskViewMap.isEmpty()) { + Iterator> iter = mSimpleTaskViewMap.entrySet().iterator(); + while (iter.hasNext()) { + PositionTaskListView taskView = iter.next().getValue(); + if (taskView != null) { + taskView.clearData(); + taskView.setOnTaskUpdatedListener(null); + } + iter.remove(); + } + } + + if (!mEditTaskViewMap.isEmpty()) { + Iterator> iter = mEditTaskViewMap.entrySet().iterator(); + while (iter.hasNext()) { + PositionTaskListView taskView = iter.next().getValue(); + if (taskView != null) { + taskView.clearData(); + taskView.setOnTaskUpdatedListener(null); + } + iter.remove(); + } + } + + mPosDistanceViewMap.clear(); + if (mCachedPositionList != null) { + mCachedPositionList.clear(); + } + mOnDeleteListener = null; + mOnSavePosListener = null; + + LogUtils.d(TAG, "release:资源释放完成"); + } + + // ========================================================================= + // ViewHolder + // ========================================================================= + public static class SimpleViewHolder extends RecyclerView.ViewHolder { + TextView tvSimpleLon; + TextView tvSimpleLat; + TextView tvSimpleMemo; + TextView tvSimpleDistance; + PositionTaskListView ptlvSimpleTasks; + + public SimpleViewHolder(View itemView) { + super(itemView); + tvSimpleLon = (TextView) itemView.findViewById(R.id.tv_simple_longitude); + tvSimpleLat = (TextView) itemView.findViewById(R.id.tv_simple_latitude); + tvSimpleMemo = (TextView) itemView.findViewById(R.id.tv_simple_memo); + tvSimpleDistance = (TextView) itemView.findViewById(R.id.tv_simple_distance); + ptlvSimpleTasks = (PositionTaskListView) itemView.findViewById(R.id.ptlv_simple_tasks); + } + } + + public static class EditViewHolder extends RecyclerView.ViewHolder { + TextView tvEditLon; + TextView tvEditLat; + EditText etEditMemo; + TextView tvEditDistance; + RadioGroup rgDistanceSwitch; + Button btnCancel; + Button btnDelete; + Button btnSave; + Button btnAddTask; + TextView tvTaskCount; + PositionTaskListView ptlvEditTasks; + + public EditViewHolder(View itemView) { + super(itemView); + tvEditLon = (TextView) itemView.findViewById(R.id.tv_edit_longitude); + tvEditLat = (TextView) itemView.findViewById(R.id.tv_edit_latitude); + etEditMemo = (EditText) itemView.findViewById(R.id.et_edit_memo); + tvEditDistance = (TextView) itemView.findViewById(R.id.tv_edit_distance); + rgDistanceSwitch = (RadioGroup) itemView.findViewById(R.id.rg_distance_switch); + btnCancel = (Button) itemView.findViewById(R.id.btn_edit_cancel); + btnDelete = (Button) itemView.findViewById(R.id.btn_edit_delete); + btnSave = (Button) itemView.findViewById(R.id.btn_edit_save); + btnAddTask = (Button) itemView.findViewById(R.id.btn_add_task); + tvTaskCount = (TextView) itemView.findViewById(R.id.tv_task_count); + ptlvEditTasks = (PositionTaskListView) itemView.findViewById(R.id.ptlv_edit_tasks); + } + } + + // ========================================================================= + // 方法说明类(保留不动) + // ========================================================================= + public static class PositionTaskListViewRequiredMethods { + } +} + diff --git a/positions/src/main/java/cc/winboll/studio/positions/handlers/AppIdleRunningModeHandler.java b/positions/src/main/java/cc/winboll/studio/positions/handlers/AppIdleRunningModeHandler.java new file mode 100644 index 0000000..c00a7a3 --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/handlers/AppIdleRunningModeHandler.java @@ -0,0 +1,143 @@ +package cc.winboll.studio.positions.handlers; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; + +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.positions.App; +import cc.winboll.studio.positions.MainActivity; + +/** + * 应用空转事务处理器 + * 作用:接收空转开关消息、空转日志消息,回调MainActivity内部接口实现UI联动 + * @Author 豆包&ZhanGSKen + * @CreateTime 2026/05/03 12:23:00 + * @EditTime 2026/05/03 14:42:18 + */ +public class AppIdleRunningModeHandler extends Handler { + + //===================== 常量标识 ===================== + public static final String TAG = "AppIdleRunningModeHandler"; + public static final int MSG_IDLE_MODE_SWITCH = 1001; + public static final int MSG_IDLE_LOG_PRINT = 1002; + + //===================== 成员变量 ===================== + private static AppIdleRunningModeHandler mHandler; + private MainActivity mMainActivity; + + //===================== 静态初始化 ===================== + static { + mHandler = new AppIdleRunningModeHandler(); + LogUtils.d(TAG, "静态代码块:默认主线程Looper完成初始化"); + } + + //===================== 构造方法 ===================== + /** + * 私有无参构造,禁止外部直接实例化 + */ + private AppIdleRunningModeHandler() { + super(Looper.getMainLooper()); + } + + /** + * 带MainActivity绑定构造 + * @param activity 主页面实例 + */ + public AppIdleRunningModeHandler(MainActivity activity) { + super(Looper.getMainLooper()); + this.mMainActivity = activity; + LogUtils.d(TAG, "构造方法:完成MainActivity绑定初始化"); + } + + //===================== 对外初始化与获取 ===================== + /** + * 全局静态初始化、重新绑定MainActivity + * @param activity 主页面实例 + */ + public static void init(MainActivity activity) { + if (mHandler == null) { + mHandler = new AppIdleRunningModeHandler(activity); + } else { + mHandler.mMainActivity = activity; + } + LogUtils.i(TAG, "init -> AppIdleRunningModeHandler初始化绑定成功"); + } + + /** + * 获取当前绑定的MainActivity实例 + * @return 已绑定的Activity + */ + public MainActivity getBindMainActivity() { + return mMainActivity; + } + + //===================== 对外静态发送方法 ===================== + /** + * 发送空转开关控制消息 + * @param isOpen 是否开启空转状态 + */ + public static void sendIdleSwitch(boolean isOpen) { + if (!App.isAppIdleRunning()) { + LogUtils.d(TAG, "sendIdleSwitch -> 当前非空转状态,函数执行无效"); + return; + } + LogUtils.d(TAG, "sendIdleSwitch -> 发送空转开关信号,参数isOpen = " + isOpen); + + Message message = Message.obtain(); + message.what = MSG_IDLE_MODE_SWITCH; + message.obj = isOpen; + mHandler.sendMessage(message); + } + + /** + * 发送空转日志打印消息 + * @param logText 待输出的日志内容 + */ + public static void sendIdleLog(String logText) { + if (!App.isAppIdleRunning()) { + LogUtils.d(TAG, "sendIdleLog -> 当前非空转状态,函数执行无效"); + return; + } + LogUtils.d(TAG, "sendIdleLog -> 发送空转日志消息"); + + Message message = Message.obtain(); + message.what = MSG_IDLE_LOG_PRINT; + message.obj = logText; + mHandler.sendMessage(message); + } + + //===================== 消息接收处理 ===================== + @Override + public void handleMessage(Message msg) { + super.handleMessage(msg); + + // 全局状态校验 + if (!App.isAppIdleRunning()) { + return; + } + // 空指针安全防护 + if (mMainActivity == null || mMainActivity.getOnAppIdleRunningListener() == null) { + LogUtils.d(TAG, "handleMessage -> Activity或监听接口为空,终止回调"); + return; + } + + switch (msg.what) { + case MSG_IDLE_MODE_SWITCH: + boolean idleState = (boolean) msg.obj; + App.setAppIdleRunning(idleState); + LogUtils.i(TAG, "handleMessage -> 空转状态已变更:" + idleState); + // 回调主页面接口 + mMainActivity.getOnAppIdleRunningListener().onIdleStatusChange(idleState); + break; + + case MSG_IDLE_LOG_PRINT: + String logContent = (String) msg.obj; + LogUtils.i(TAG, logContent); + // 回调主页面日志接收接口 + mMainActivity.getOnAppIdleRunningListener().onIdleLogReceive(logContent); + break; + } + } +} + diff --git a/positions/src/main/java/cc/winboll/studio/positions/models/AppConfigsModel.java b/positions/src/main/java/cc/winboll/studio/positions/models/AppConfigsModel.java new file mode 100644 index 0000000..feb0aa0 --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/models/AppConfigsModel.java @@ -0,0 +1,75 @@ +package cc.winboll.studio.positions.models; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/10/01 04:50 + * @Describe AppConfigsModel + */ + import cc.winboll.studio.libappbase.BaseBean; +import android.util.JsonWriter; +import android.util.JsonReader; +import java.io.IOException; + +public class AppConfigsModel extends BaseBean { + + public static final String TAG = "AppConfigsModel"; + + boolean isEnableMainService; + + public AppConfigsModel(boolean isEnableMainService) { + this.isEnableMainService = isEnableMainService; + } + + public AppConfigsModel() { + this.isEnableMainService = false; + } + + public void setIsEnableMainService(boolean isEnableMainService) { + this.isEnableMainService = isEnableMainService; + } + + public boolean isEnableMainService() { + return isEnableMainService; + } + + @Override + public String getName() { + return AppConfigsModel.class.getName(); + } + + // JSON序列化(保存位置数据) + @Override + public void writeThisToJsonWriter(JsonWriter jsonWriter) throws IOException { + super.writeThisToJsonWriter(jsonWriter); + jsonWriter.name("isEnableDistanceRefreshService").value(isEnableMainService()); + } + + // JSON反序列化(加载位置数据,校验字段) + @Override + public boolean initObjectsFromJsonReader(JsonReader jsonReader, String name) throws IOException { + if (super.initObjectsFromJsonReader(jsonReader, name)) { + return true; + } else { + if (name.equals("isEnableDistanceRefreshService")) { + setIsEnableMainService(jsonReader.nextBoolean()); + } else { + return false; + } + } + return true; + } + + // 从JSON读取位置数据 + @Override + public BaseBean readBeanFromJsonReader(JsonReader jsonReader) throws IOException { + jsonReader.beginObject(); + while (jsonReader.hasNext()) { + String name = jsonReader.nextName(); + if (!initObjectsFromJsonReader(jsonReader, name)) { + jsonReader.skipValue(); // 跳过未知字段 + } + } + jsonReader.endObject(); + return this; + } +} diff --git a/positions/src/main/java/cc/winboll/studio/positions/models/PositionModel.java b/positions/src/main/java/cc/winboll/studio/positions/models/PositionModel.java new file mode 100644 index 0000000..7c7c9ca --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/models/PositionModel.java @@ -0,0 +1,219 @@ +package cc.winboll.studio.positions.models; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/09/29 18:57 + * @Describe 位置数据模型 + */ +import android.util.JsonReader; +import android.util.JsonWriter; +import cc.winboll.studio.libappbase.BaseBean; +import java.io.IOException; +import java.util.UUID; + +public class PositionModel extends BaseBean { + + public static final String TAG = "PositionModel"; + // 位置唯一标识符(与任务的positionId绑定) + String positionId; + // 经度(范围:-180~180) + double longitude; + // 纬度(范围:-90~90) + double latitude; + // 位置备注(空值时显示“无备注”) + String memo; + // 定位点与指定点实时距离长度 + double realPositionDistance; + // 是否启用实时距离计算 + boolean isEnableRealPositionDistance; + // 是否显示简单视图(true=简单视图,false=编辑视图) + boolean isSimpleView = true; + + // 带参构造(强制初始化位置ID和经纬度) + public PositionModel(String positionId, double longitude, double latitude, String memo, boolean isEnableRealPositionDistance) { + this.positionId = (positionId == null || positionId.trim().isEmpty()) ? genPositionId() : positionId; + this.longitude = Math.max(-180, Math.min(180, longitude)); // 经度范围限制 + this.latitude = Math.max(-90, Math.min(90, latitude)); // 纬度范围限制 + this.memo = (memo == null || memo.trim().isEmpty()) ? "无备注" : memo; + this.isEnableRealPositionDistance = isEnableRealPositionDistance; + } + + // 无参构造(默认值初始化,避免空指针) + public PositionModel() { + this.positionId = genPositionId(); + this.longitude = 0.0; + this.latitude = 0.0; + this.memo = "无备注"; + this.isEnableRealPositionDistance = false; + } + + public void setRealPositionDistance(double realPositionDistance) { + this.realPositionDistance = realPositionDistance; + } + + public double getRealPositionDistance() { + return realPositionDistance; + } + + // ---------------------- Getter/Setter(确保字段有效性) ---------------------- + public void setPositionId(String positionId) { + this.positionId = (positionId == null || positionId.trim().isEmpty()) ? genPositionId() : positionId; + } + + public String getPositionId() { + return positionId; + } + + public void setIsEnableRealPositionDistance(boolean isEnableRealPositionDistance) { + this.isEnableRealPositionDistance = isEnableRealPositionDistance; + } + + public boolean isEnableRealPositionDistance() { + return isEnableRealPositionDistance; + } + + public void setIsSimpleView(boolean isSimpleView) { + this.isSimpleView = isSimpleView; + } + + public boolean isSimpleView() { + return isSimpleView; + } + + public void setMemo(String memo) { + this.memo = (memo == null || memo.trim().isEmpty()) ? "无备注" : memo; + } + + public String getMemo() { + return memo; + } + + public void setLongitude(double longitude) { + this.longitude = Math.max(-180, Math.min(180, longitude)); // 限制经度范围 + } + + public double getLongitude() { + return longitude; + } + + public void setLatitude(double latitude) { + this.latitude = Math.max(-90, Math.min(90, latitude)); // 限制纬度范围 + } + + public double getLatitude() { + return latitude; + } + + // ---------------------- 父类方法重写 ---------------------- + @Override + public String getName() { + return PositionModel.class.getName(); + } + + // 生成唯一位置ID(与任务ID格式一致,确保关联匹配) + public static String genPositionId() { + UUID uniqueUuid = UUID.randomUUID(); + return uniqueUuid.toString(); // 36位标准UUID(含横杠,确保与任务ID格式统一) + } + + // JSON序列化(保存位置数据) + @Override + public void writeThisToJsonWriter(JsonWriter jsonWriter) throws IOException { + super.writeThisToJsonWriter(jsonWriter); + jsonWriter.name("positionId").value(getPositionId()); + jsonWriter.name("longitude").value(getLongitude()); + jsonWriter.name("latitude").value(getLatitude()); + jsonWriter.name("memo").value(getMemo()); + jsonWriter.name("isEnableRealPositionDistance").value(isEnableRealPositionDistance()); + } + + // JSON反序列化(加载位置数据,校验字段) + @Override + public boolean initObjectsFromJsonReader(JsonReader jsonReader, String name) throws IOException { + if (super.initObjectsFromJsonReader(jsonReader, name)) { + return true; + } else { + if (name.equals("positionId")) { + setPositionId(jsonReader.nextString()); + } else if (name.equals("longitude")) { + setLongitude(jsonReader.nextDouble()); + } else if (name.equals("latitude")) { + setLatitude(jsonReader.nextDouble()); + } else if (name.equals("memo")) { + setMemo(jsonReader.nextString()); + } else if (name.equals("isEnableRealPositionDistance")) { + setIsEnableRealPositionDistance(jsonReader.nextBoolean()); + } else { + return false; + } + } + return true; + } + + // 从JSON读取位置数据 + @Override + public BaseBean readBeanFromJsonReader(JsonReader jsonReader) throws IOException { + jsonReader.beginObject(); + while (jsonReader.hasNext()) { + String name = jsonReader.nextName(); + if (!initObjectsFromJsonReader(jsonReader, name)) { + jsonReader.skipValue(); // 跳过未知字段 + } + } + jsonReader.endObject(); + return this; + } + + // ---------------------- 核心工具方法:计算两点距离(Haversine公式,确保精度) ---------------------- + /** + * 计算两个位置之间的直线距离(地球表面最短距离) + * @param position1 第一个位置(非null) + * @param position2 第二个位置(非null) + * @param isKilometer 是否返回千米单位:true→千米,false→米 + * @return 距离(保留1位小数,符合显示需求) + * @throws IllegalArgumentException 位置为null或经纬度无效时抛出 + */ + public static double calculatePositionDistance(PositionModel position1, PositionModel position2, boolean isKilometer) { + // 1. 校验参数有效性(避免计算异常) + if (position1 == null || position2 == null) { + throw new IllegalArgumentException("位置对象不能为null"); + } + double lon1 = position1.getLongitude(); + double lat1 = position1.getLatitude(); + double lon2 = position2.getLongitude(); + double lat2 = position2.getLatitude(); + // 经纬度范围二次校验(确保有效) + if (lat1 < -90 || lat1 > 90 || lat2 < -90 || lat2 > 90 + || lon1 < -180 || lon1 > 180 || lon2 < -180 || lon2 > 180) { + throw new IllegalArgumentException("经纬度值无效(纬度:-90~90,经度:-180~180)"); + } + + // 2. Haversine公式计算(地球半径取6371km,行业标准) + final double EARTH_RADIUS_KM = 6371; + double radLat1 = Math.toRadians(lat1); // 角度转弧度 + double radLat2 = Math.toRadians(lat2); + double radLon1 = Math.toRadians(lon1); + double radLon2 = Math.toRadians(lon2); + + double deltaLat = radLat2 - radLat1; // 纬度差 + double deltaLon = radLon2 - radLon1; // 经度差 + + // 核心公式 + double a = Math.pow(Math.sin(deltaLat / 2), 2) + + Math.cos(radLat1) * Math.cos(radLat2) + * Math.pow(Math.sin(deltaLon / 2), 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + double distanceKm = EARTH_RADIUS_KM * c; // 距离(千米) + + // 3. 单位转换+精度处理(保留1位小数,符合显示需求) + double distance; + if (isKilometer) { + distance = Math.round(distanceKm * 10.0) / 10.0; // 千米→1位小数 + } else { + distance = Math.round(distanceKm * 1000 * 10.0) / 10.0; // 米→1位小数 + } + + return distance; + } +} + diff --git a/positions/src/main/java/cc/winboll/studio/positions/models/PositionTaskModel.java b/positions/src/main/java/cc/winboll/studio/positions/models/PositionTaskModel.java new file mode 100644 index 0000000..1e9dd47 --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/models/PositionTaskModel.java @@ -0,0 +1,207 @@ +package cc.winboll.studio.positions.models; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/09/30 02:48 + * @Describe 位置任务数据模型 + */ +import android.util.JsonReader; +import android.util.JsonWriter; +import cc.winboll.studio.libappbase.BaseBean; +import java.io.IOException; +import java.util.UUID; + +public class PositionTaskModel extends BaseBean { + + public static final String TAG = "PositionTaskModel"; + // 任务标识符(唯一) + String taskId; + // 绑定的位置标识符(与PositionModel的positionId一一对应) + String positionId; + // 任务描述 + String taskDescription; + // 任务距离条件:是否大于设定距离 + boolean isGreaterThan; + // 任务距离条件:是否小于设定距离(与isGreaterThan互斥) + boolean isLessThan; + // 任务条件距离(单位:米) + int discussDistance; + // 任务开始启用时间 + long startTime; + // 任务是否已触发 + boolean isBingo = false; + // 是否启用任务 + boolean isEnable; + + // 带参构造(强制传入positionId,确保任务与位置绑定) + public PositionTaskModel(String taskId, String positionId, String taskDescription, boolean isGreaterThan, int discussDistance, long startTime, boolean isEnable) { + this.taskId = (taskId == null || taskId.trim().isEmpty()) ? genTaskId() : taskId; // 空ID自动生成 + this.positionId = positionId; // 强制绑定位置ID + this.taskDescription = (taskDescription == null || taskDescription.trim().isEmpty()) ? "新任务" : taskDescription; + this.isGreaterThan = isGreaterThan; + this.isLessThan = !isGreaterThan; // 确保互斥 + this.discussDistance = Math.max(discussDistance, 1); // 距离最小1米,避免无效值 + this.startTime = startTime; + this.isEnable = isEnable; + } + + // 无参构造(初始化默认值,positionId需后续设置) + public PositionTaskModel() { + this.taskId = genTaskId(); + this.positionId = ""; + this.taskDescription = "新任务"; + this.isGreaterThan = true; + this.isLessThan = false; // 初始互斥 + this.discussDistance = 100; // 默认100米 + this.startTime = System.currentTimeMillis(); + this.isEnable = true; + } + + public void setStartTime(long startTime) { + this.startTime = startTime; + } + + public long getStartTime() { + return startTime; + } + + public void setIsBingo(boolean isBingo) { + this.isBingo = isBingo; + } + + public boolean isBingo() { + return isBingo; + } + + // ---------------------- Getter/Setter(确保positionId不为空,距离有效) ---------------------- + public void setTaskId(String taskId) { + this.taskId = (taskId == null || taskId.trim().isEmpty()) ? genTaskId() : taskId; + } + + public String getTaskId() { + return taskId; + } + + public void setPositionId(String positionId) { + this.positionId = (positionId == null || positionId.trim().isEmpty()) ? "" : positionId; // 空值防护 + } + + public String getPositionId() { + return positionId; + } + + public void setTaskDescription(String taskDescription) { + this.taskDescription = (taskDescription == null || taskDescription.trim().isEmpty()) ? "新任务" : taskDescription; + } + + public String getTaskDescription() { + return taskDescription; + } + + // 修复:确保isGreaterThan和isLessThan互斥 + public void setIsGreaterThan(boolean isGreaterThan) { + this.isGreaterThan = isGreaterThan; + this.isLessThan = !isGreaterThan; // 关键:小于 = 非大于 + } + + public boolean isGreaterThan() { + return isGreaterThan; + } + + // 修复:确保isLessThan和isGreaterThan互斥 + public void setIsLessThan(boolean isLessThan) { + this.isLessThan = isLessThan; + this.isGreaterThan = !isLessThan; // 关键:大于 = 非小于 + } + + public boolean isLessThan() { + return isLessThan; + } + + public void setDiscussDistance(int discussDistance) { + this.discussDistance = Math.max(discussDistance, 1); // 距离最小1米,避免0或负数 + } + + public int getDiscussDistance() { + return discussDistance; + } + + public void setIsEnable(boolean isEnable) { + this.isEnable = isEnable; + } + + public boolean isEnable() { + return isEnable; + } + + // ---------------------- 父类方法重写 ---------------------- + @Override + public String getName() { + return PositionTaskModel.class.getName(); + } + + // 生成唯一任务ID(与PositionModel保持一致格式) + public static String genTaskId() { + UUID uniqueUuid = UUID.randomUUID(); + return uniqueUuid.toString(); // 36位标准UUID(含横杠,确保唯一) + } + + // JSON序列化(保存任务数据,包含所有字段) + @Override + public void writeThisToJsonWriter(JsonWriter jsonWriter) throws IOException { + super.writeThisToJsonWriter(jsonWriter); + jsonWriter.name("taskId").value(getTaskId()); + jsonWriter.name("positionId").value(getPositionId()); + jsonWriter.name("taskDescription").value(getTaskDescription()); + jsonWriter.name("isGreaterThan").value(isGreaterThan()); + jsonWriter.name("isLessThan").value(isLessThan()); + jsonWriter.name("discussDistance").value(getDiscussDistance()); + jsonWriter.name("startTime").value(getStartTime()); + jsonWriter.name("isEnable").value(isEnable()); + } + + // JSON反序列化(加载任务数据,校验字段有效性) + @Override + public boolean initObjectsFromJsonReader(JsonReader jsonReader, String name) throws IOException { + if (super.initObjectsFromJsonReader(jsonReader, name)) { + return true; + } else { + if (name.equals("taskId")) { + setTaskId(jsonReader.nextString()); + } else if (name.equals("positionId")) { + setPositionId(jsonReader.nextString()); + } else if (name.equals("taskDescription")) { + setTaskDescription(jsonReader.nextString()); + } else if (name.equals("isGreaterThan")) { + setIsGreaterThan(jsonReader.nextBoolean()); + } else if (name.equals("isLessThan")) { + setIsLessThan(jsonReader.nextBoolean()); + } else if (name.equals("discussDistance")) { + setDiscussDistance(jsonReader.nextInt()); + } else if (name.equals("startTime")) { + setStartTime(jsonReader.nextLong()); + } else if (name.equals("isEnable")) { + setIsEnable(jsonReader.nextBoolean()); + } else { + return false; + } + } + return true; + } + + // 从JSON读取任务数据(确保反序列化完整) + @Override + public BaseBean readBeanFromJsonReader(JsonReader jsonReader) throws IOException { + jsonReader.beginObject(); + while (jsonReader.hasNext()) { + String name = jsonReader.nextName(); + if (!initObjectsFromJsonReader(jsonReader, name)) { + jsonReader.skipValue(); // 跳过未知字段,避免崩溃 + } + } + jsonReader.endObject(); + return this; + } + +} + diff --git a/positions/src/main/java/cc/winboll/studio/positions/services/AssistantService.java b/positions/src/main/java/cc/winboll/studio/positions/services/AssistantService.java new file mode 100644 index 0000000..14317e1 --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/services/AssistantService.java @@ -0,0 +1,101 @@ +package cc.winboll.studio.positions.services; + +/** + * @Author ZhanGSKen + * @Date 2024/07/19 14:30:57 + * @Describe 应用主要服务组件类守护进程服务组件类 + */ +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.IBinder; +import cc.winboll.studio.positions.services.MainService; +import cc.winboll.studio.positions.utils.AppConfigsUtil; +import cc.winboll.studio.positions.utils.ServiceUtil; + +public class AssistantService extends Service { + + public final static String TAG = "AssistantService"; + public static final String EXTRA_IS_SETTING_TO_ENABLE = "EXTRA_IS_SETTING_TO_ENABLE"; + + MyServiceConnection mMyServiceConnection; + volatile boolean mIsServiceRunning; + AppConfigsUtil mAppConfigsUtil; + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onCreate() { + super.onCreate(); + mAppConfigsUtil = AppConfigsUtil.getInstance(this); + if (mMyServiceConnection == null) { + mMyServiceConnection = new MyServiceConnection(); + } + // 设置运行参数 + mIsServiceRunning = false; + if (mAppConfigsUtil.isEnableMainService(true)) { + run(); + } + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (mAppConfigsUtil.isEnableMainService(true)) { + run(); + } + + return mAppConfigsUtil.isEnableMainService(true) ? Service.START_STICKY : super.onStartCommand(intent, flags, startId); + } + + @Override + public void onDestroy() { + mIsServiceRunning = false; + super.onDestroy(); + } + + // + // 运行服务内容 + // + void run() { + if (mAppConfigsUtil.isEnableMainService(true)) { + if (mIsServiceRunning == false) { + // 设置运行状态 + mIsServiceRunning = true; + // 唤醒和绑定主进程 + wakeupAndBindMain(); + } + } + } + + // + // 唤醒和绑定主进程 + // + void wakeupAndBindMain() { + if (ServiceUtil.isServiceAlive(getApplicationContext(), MainService.class.getName()) == false) { + startForegroundService(new Intent(AssistantService.this, MainService.class)); + } + + bindService(new Intent(AssistantService.this, MainService.class), mMyServiceConnection, Context.BIND_IMPORTANT); + } + + // + // 主进程与守护进程连接时需要用到此类 + // + class MyServiceConnection implements ServiceConnection { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + } + + @Override + public void onServiceDisconnected(ComponentName name) { + if (mAppConfigsUtil.isEnableMainService(true)) { + wakeupAndBindMain(); + } + } + } +} diff --git a/positions/src/main/java/cc/winboll/studio/positions/services/DistanceRefreshService.java b/positions/src/main/java/cc/winboll/studio/positions/services/DistanceRefreshService.java new file mode 100644 index 0000000..0e48a34 --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/services/DistanceRefreshService.java @@ -0,0 +1,433 @@ +//package cc.winboll.studio.positions.services; +// +///** +// * @Author ZhanGSKen&豆包大模型 +// * @Date 2025/09/30 19:53 +// * @Describe 位置距离服务:管理数据+定时计算距离+适配Adapter(Java 7 兼容)+ GPS信号加载 +// */ +//import android.app.Service; +//import android.content.Context; +//import android.content.Intent; +//import android.content.pm.PackageManager; +//import android.location.Location; +//import android.location.LocationListener; +//import android.location.LocationManager; +//import android.os.Binder; +//import android.os.Build; +//import android.os.Bundle; +//import android.os.IBinder; +//import android.os.Looper; +//import android.widget.Toast; +//import cc.winboll.studio.libappbase.LogUtils; +//import cc.winboll.studio.positions.adapters.PositionAdapter; +//import cc.winboll.studio.positions.models.AppConfigsModel; +//import cc.winboll.studio.positions.models.PositionModel; +//import cc.winboll.studio.positions.models.PositionTaskModel; +//import cc.winboll.studio.positions.utils.NotificationUtil; +//import java.util.ArrayList; +//import java.util.HashSet; +//import java.util.Iterator; +//import java.util.Set; +//import java.util.concurrent.Executors; +//import java.util.concurrent.ScheduledExecutorService; +//import java.util.concurrent.TimeUnit; +// +///** +// * 核心职责: +// * 1. 实现 PositionAdapter.DistanceServiceInterface 接口,解耦Adapter与服务 +// * 2. 单例式管理位置/任务数据,提供安全增删改查接口 +// * 3. 后台单线程定时计算可见位置距离,主线程回调更新UI +// * 4. 内置GPS信号加载(通过LocationManager实时获取位置,解决“等待GPS信号”问题) +// * 5. 服务启动时启动前台通知(保活后台GPS功能,符合系统规范) +// * 6. 严格Java 7语法:无Lambda/Stream,显式迭代器/匿名内部类 +// */ +//public class DistanceRefreshService extends Service { +// public static final String TAG = "DistanceRefreshService"; +// +// +// // 服务状态与配置 +// private boolean isServiceRunning = false; +// +// private static final int REFRESH_INTERVAL = 3; // 距离刷新间隔(秒) +// // 前台通知相关:记录是否已启动前台服务(避免重复调用startForeground) +// private boolean isForegroundServiceStarted = false; +// +// +// +// // 服务绑定与UI回调 +// private final IBinder mBinder = new DistanceBinder(); +// +// +// +// +// /** +// * 在主线程显示Toast(避免子线程无法显示Toast的问题) +// */ +// private void showToastOnMainThread(final String message) { +// if (Looper.myLooper() == Looper.getMainLooper()) { +// Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); +// } else { +// new android.os.Handler(Looper.getMainLooper()).post(new Runnable() { +// @Override +// public void run() { +// Toast.makeText(DistanceRefreshService.this, message, Toast.LENGTH_SHORT).show(); +// } +// }); +// } +// } +// +// // ---------------------- Binder 内部类(供外部绑定服务) ---------------------- +// public class DistanceBinder extends Binder { +// /** +// * 外部绑定后获取服务实例(安全暴露服务引用) +// */ +// public DistanceRefreshService getService() { +// return DistanceRefreshService.this; +// } +// } +// +// +// +// @Override +// public void onCreate() { +// super.onCreate(); +// +// // 初始化GPS管理器(提前获取系统服务,避免启动时延迟) +// mLocationManager = (LocationManager) getSystemService(Context.LOCATION_SERVICE); +// LogUtils.d(TAG, "服务 onCreate:初始化完成,等待启动命令"); +// run(); +// } +// +// @Override +// public int onStartCommand(Intent intent, int flags, int startId) { +// run(); +// AppConfigsModel bean = AppConfigsModel.loadBean(DistanceRefreshService.this, AppConfigsModel.class); +// boolean isEnableService = (bean == null) ? false : bean.isEnableMainService(); +// // 服务启用时返回START_STICKY(被杀死后尝试重启),禁用时返回默认值 +// return isEnableService ? Service.START_STICKY : super.onStartCommand(intent, flags, startId); +// } +// +// public void run() { +// // 仅服务未运行时启动(避免重复启动) +// if (!isServiceRunning) { +// isServiceRunning = true; +// +// +// +// startDistanceRefreshTask(); // 启动定时距离计算 +// startForegroundNotification(); // 启动前台通知 +// +// LogUtils.d(TAG, "服务 onStartCommand:启动成功,刷新间隔=" + REFRESH_INTERVAL + "秒,前台通知+GPS已启动"); +// } else { +// LogUtils.w(TAG, "服务 onStartCommand:已在运行,无需重复启动(前台通知:" + (isForegroundServiceStarted ? "已启动" : "未启动") + " | GPS:" + (isGpsEnabled ? "已开启" : "未开启") + ")"); +// // 异常场景恢复:补全未启动的组件 +// if (!isForegroundServiceStarted) { +// startForegroundNotification(); +// LogUtils.d(TAG, "服务 run:前台通知未启动,已恢复"); +// } +// if (isServiceRunning && !isGpsEnabled) { +// startGpsLocation(); +// LogUtils.d(TAG, "服务 run:GPS未启动,已恢复"); +// } +// } +// } +// +// @Override +// public IBinder onBind(Intent intent) { +// return null; // 按你的业务逻辑返回,无绑定需求则保留null +// //LogUtils.d(TAG, "服务 onBind:外部绑定成功(运行状态:" + (isServiceRunning ? "是" : "否") + " | GPS状态:" + (isGpsEnabled ? "可用" : "不可用") + ")"); +// //return mBinder; // 返回Binder实例,供外部获取服务 +// } +// +// /*@Override +// public boolean onUnbind(Intent intent) { +// LogUtils.d(TAG, "服务 onUnbind:外部解绑,清理回调与可见位置"); +// // 解绑后清理资源,避免内存泄漏 +// mDistanceReceiver = null; +// mVisiblePositionIds.clear(); +// // 解绑时不停止GPS(服务仍在后台运行,需持续获取位置) +// return super.onUnbind(intent); +// }*/ +// +// @Override +// public void onDestroy() { +// super.onDestroy(); +// +// LogUtils.d(TAG, "服务 onDestroy:销毁完成,资源已释放(GPS+前台通知+线程池)"); +// } +// +// // ---------------------- 前台服务通知管理(与GPS状态联动优化) ---------------------- +// /** +// * 启动前台服务通知(调用NotificationUtils创建通知,确保仅启动一次) +// */ +// private void startForegroundNotification() { +// // 1. 校验:避免重复调用startForeground(系统不允许重复启动) +// if (isForegroundServiceStarted) { +// LogUtils.w(TAG, "startForegroundNotification:前台通知已启动,无需重复执行"); +// return; +// } +// +// try { +//// 2. 初始化通知状态文本(根据GPS初始状态动态显示,避免固定“等待”) +// String initialStatus; +// if (isGpsPermissionGranted && isGpsEnabled) { +// initialStatus = "GPS已就绪,正在获取位置(刷新间隔" + REFRESH_INTERVAL + "秒)"; +// } else if (!isGpsPermissionGranted) { +// initialStatus = "缺少定位权限,无法获取GPS位置"; +// } else { +// initialStatus = "GPS未开启,请在设置中打开"; +// } +// +// +//// 5. 标记前台服务已启动 +// isForegroundServiceStarted = true; +// LogUtils.d(TAG, "startForegroundNotification:前台服务通知启动成功,初始状态:" + initialStatus); +// +// } catch (Exception e) { +//// 捕获异常(如上下文失效、通知渠道未创建) +// isForegroundServiceStarted = false; +// LogUtils.d(TAG, "startForegroundNotification:前台通知启动失败" + e); +// } +// } +// +// +// +// /** +// +// - 主线程回调Adapter更新UI(避免跨线程操作UI异常) +// */ +// /*private void notifyDistanceUpdateToUI(final String positionId) { +// if (Looper.myLooper() == Looper.getMainLooper()) { +// if (mDistanceReceiver != null) { +// mDistanceReceiver.onDistanceUpdate(positionId); +// } +// } else { +// new android.os.Handler(Looper.getMainLooper()).post(new Runnable() { +// @Override +// public void run() { +// if (mDistanceReceiver != null) { +// mDistanceReceiver.onDistanceUpdate(positionId); +// } +// } +// }); +// } +// }*/ +// +// +// +// +// +//// ---------------------- 实现 PositionAdapter.DistanceServiceInterface 接口 ---------------------- +// +// public ArrayList getPositionList() { +// if (!isServiceRunning) { +// LogUtils.w(TAG, "getPositionList:服务未运行,返回空列表"); +// return new ArrayList(); +// } +// return new ArrayList(mPositionList); +// } +// +// +// public ArrayList getPositionTasksList() { +// if (!isServiceRunning) { +// LogUtils.w(TAG, "getPositionTasksList:服务未运行,返回空列表"); +// return new ArrayList(); +// } +// return new ArrayList(mTaskList); +// } +// +// +// +// +// /*public void setOnDistanceUpdateReceiver(PositionAdapter.OnDistanceUpdateReceiver receiver) { +// this.mDistanceReceiver = receiver; +// LogUtils.d(TAG, "setOnDistanceUpdateReceiver:回调接收器已设置(" + (receiver != null ? "有效" : "无效") + ")"); +// }*/ +// +// public void addVisibleDistanceView(String positionId) { +// if (!isServiceRunning || positionId == null) { +// LogUtils.w(TAG, "addVisibleDistanceView:服务未运行/位置ID无效,添加失败"); +// return; +// } +// if (mVisiblePositionIds.add(positionId)) { +// LogUtils.d(TAG, "addVisibleDistanceView:添加成功(位置ID=" + positionId + ",当前可见数=" + mVisiblePositionIds.size() + ")"); +//// 新增:添加可见位置后,立即更新通知(显示最新可见数量) +// if (isForegroundServiceStarted && mCurrentGpsPosition != null) { +// syncGpsStatusToNotification(); +// } +// } +// } +// +// public void removeVisibleDistanceView(String positionId) { +// if (positionId == null) { +// LogUtils.w(TAG, "removeVisibleDistanceView:位置ID为空,移除失败"); +// return; +// } +// if (mVisiblePositionIds.remove(positionId)) { +// int remainingCount = mVisiblePositionIds.size(); +// LogUtils.d(TAG, "removeVisibleDistanceView:移除成功(位置ID=" + positionId + ",当前可见数=" + remainingCount + ")"); +//// 新增:移除可见位置后,更新通知(同步数量变化) +// if (isForegroundServiceStarted && mCurrentGpsPosition != null) { +// syncGpsStatusToNotification(); +// } +// } +// } +// +// public void clearVisibleDistanceViews() { +// mVisiblePositionIds.clear(); +// LogUtils.d(TAG, "clearVisibleDistanceViews:所有可见位置已清空"); +//// 新增:清空可见位置后,更新通知(提示计算暂停) +// if (isForegroundServiceStarted) { +// updateNotificationGpsStatus("无可见位置,距离计算暂停"); +// } +// } +// +//// ---------------------- 数据管理接口(修复原有语法错误+优化逻辑) ---------------------- +// /** +// +// - 获取服务运行状态 +// */ +// public boolean isServiceRunning() { +// return isServiceRunning; +// } +// +// /** +// +// - 添加位置(修复迭代器泛型缺失问题) +// */ +// public void addPosition(PositionModel position) { +// if (!isServiceRunning || position == null || position.getPositionId() == null) { +// LogUtils.w(TAG, "addPosition:服务未运行/数据无效,添加失败"); +// return; +// }// 修复:显式声明PositionModel泛型,避免类型转换警告 +// boolean isDuplicate = false; +// Iterator posIter = mPositionList.iterator(); +// while (posIter.hasNext()) { +// PositionModel existingPos = (PositionModel)posIter.next(); +// if (position.getPositionId().equals(existingPos.getPositionId())) { +// isDuplicate = true; +// break; +// } +// }if (!isDuplicate) { +// mPositionList.add(position); +// LogUtils.d(TAG, "addPosition:添加成功(位置ID=" + position.getPositionId() + ",总数=" + mPositionList.size() + ")"); +// } else { +// LogUtils.w(TAG, "addPosition:位置ID=" + position.getPositionId() + "已存在,添加失败"); +// } +// } +// +// /** +// +// - 删除位置(修复任务删除时的类型转换错误) +// */ +// public void removePosition(String positionId) { +// if (!isServiceRunning || positionId == null) { +// LogUtils.w(TAG, "removePosition:服务未运行/位置ID无效,删除失败"); +// return; +// }// 1. 删除位置 +// boolean isRemoved = false; +// Iterator posIter = mPositionList.iterator(); +// while (posIter.hasNext()) { +// PositionModel pos = (PositionModel)posIter.next(); +// if (positionId.equals(pos.getPositionId())) { +// posIter.remove(); +// isRemoved = true; +// break; +// } +// }if (isRemoved) { +//// 修复:任务列表迭代时用PositionTaskModel泛型(原错误用PositionModel导致转换失败) +// Iterator taskIter = mTaskList.iterator(); +// while (taskIter.hasNext()) { +// PositionTaskModel task = (PositionTaskModel)taskIter.next(); +// if (positionId.equals(task.getPositionId())) { +// taskIter.remove(); +// } +// }// 3. 移除可见位置 +// mVisiblePositionIds.remove(positionId); +// LogUtils.d(TAG, "removePosition:删除成功(位置ID=" + positionId + ",剩余位置数=" + mPositionList.size() + ",剩余任务数=" + mTaskList.size() + ")"); +// } else { +// LogUtils.w(TAG, "removePosition:位置ID=" + positionId + "不存在,删除失败"); +// } +// } +// +// /** +// +// - 更新位置信息(修复代码格式+迭代器泛型) +// */ +// public void updatePosition(PositionModel updatedPosition) { +// if (!isServiceRunning || updatedPosition == null || updatedPosition.getPositionId() == null) { +// LogUtils.w(TAG, "updatePosition:服务未运行/数据无效,更新失败"); +// return; +// }boolean isUpdated = false; +// Iterator posIter = mPositionList.iterator(); +// while (posIter.hasNext()) { +// PositionModel pos = (PositionModel)posIter.next(); +// if (updatedPosition.getPositionId().equals(pos.getPositionId())) { +// pos.setMemo(updatedPosition.getMemo()); +// pos.setIsEnableRealPositionDistance(updatedPosition.isEnableRealPositionDistance()); +// if (!updatedPosition.isEnableRealPositionDistance()) { +// pos.setRealPositionDistance(-1); +// //notifyDistanceUpdateToUI(pos.getPositionId()); +// } +// isUpdated = true; +// break; +// } +// }if (isUpdated) { +// LogUtils.d(TAG, "updatePosition:更新成功(位置ID=" + updatedPosition.getPositionId() + ")"); +// } else { +// LogUtils.w(TAG, "updatePosition:位置ID=" + updatedPosition.getPositionId() + "不存在,更新失败"); +// } +// } +// +// /** +// +// - 同步任务列表(修复泛型缺失+代码格式) +// */ +// public void syncAllPositionTasks(ArrayList tasks) { +// if (!isServiceRunning || tasks == null) { +// LogUtils.w(TAG, "syncAllPositionTasks:服务未运行/任务列表为空,同步失败"); +// return; +// }// 1. 清空旧任务 +// mTaskList.clear(); +//// 2. 添加新任务(修复泛型+去重逻辑) +// Set taskIdSet = new HashSet(); +// Iterator taskIter = tasks.iterator(); +// while (taskIter.hasNext()) { +// PositionTaskModel task = (PositionTaskModel)taskIter.next(); +// if (task != null && task.getTaskId() != null && !taskIdSet.contains(task.getTaskId())) { +// taskIdSet.add(task.getTaskId()); +// mTaskList.add(task); +// } +// }LogUtils.d(TAG, "syncAllPositionTasks:同步成功(接收任务数=" + tasks.size() + ",去重后=" + mTaskList.size() + ")"); +// } +// +// +// +// +//// ---------------------- 补充:修复LocationProvider引用缺失问题(避免编译错误) ---------------------- +//// 注:原代码中onStatusChanged使用LocationProvider枚举,需补充静态导入或显式声明 +//// 此处通过内部静态类定义,解决系统API引用问题(兼容Java 7语法) +// private static class LocationProvider { +// public static final int AVAILABLE = 2; +// public static final int OUT_OF_SERVICE = 0; +// public static final int TEMPORARILY_UNAVAILABLE = 1; +// } +// +//// ---------------------- 补充:Context引用工具(避免服务销毁后Context失效) ---------------------- +// /*private Context getSafeContext() { +// // 服务未销毁时返回自身Context,已销毁时返回应用Context(避免内存泄漏) +// if (isDestroyed()) { +// return getApplicationContext(); +// } +// return this; +// }*/ +// +//// 注:isDestroyed()为API 17+方法,若需兼容更低版本,可添加版本判断 +// /*private boolean isDestroyed() { +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { +// return super.isDestroyed(); +// } +// // 低版本通过状态标记间接判断(服务销毁时会置为false) +// return !isServiceRunning && !isForegroundServiceStarted; +// }*/ +//} diff --git a/positions/src/main/java/cc/winboll/studio/positions/services/IdleGpsService.java b/positions/src/main/java/cc/winboll/studio/positions/services/IdleGpsService.java new file mode 100644 index 0000000..7c75003 --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/services/IdleGpsService.java @@ -0,0 +1,182 @@ +package cc.winboll.studio.positions.services; + +import android.os.Handler; +import android.os.Looper; + +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.positions.handlers.AppIdleRunningModeHandler; +import cc.winboll.studio.positions.models.PositionModel; + +import java.util.ArrayList; +import java.util.List; + +/** + * 空转GPS模拟服务 + * 在应用空转模式下,模拟系统GPS服务向客户端发送坐标数据 + */ +public class IdleGpsService { + + private static final long MOCK_INTERVAL_MS = 5000; // 模拟坐标更新间隔(5秒) + private static final long BEARING_INTERVAL_MS = 1000; // 角动量递增间隔(1秒) + private static final double EARTH_RADIUS_M = 6371000; // 地球平均半径(米) + private static final double ANCHOR_LAT = 39.9042; // 固定锚点纬度(北京) + private static final double ANCHOR_LON = 116.4074; // 固定锚点经度(北京) + private static final double CIRCLE_RADIUS_M = 50; // 移动轨迹半径(米) + + private static IdleGpsService instance; + private final List listeners = new ArrayList<>(); + private final Handler handler; + private final Runnable updateRunnable; + private final Runnable bearingRunnable; + private final PositionModel mockPosition; + private boolean isRunning; + private int currentBearing = 1; // 当前角动量度数(1-360) + + private IdleGpsService() { + handler = new Handler(Looper.getMainLooper()); + mockPosition = new PositionModel(); + mockPosition.setPositionId("mock_idle_pos"); + mockPosition.setMemo("空转模拟坐标"); + + updateRunnable = new Runnable() { + @Override + public void run() { + if (isRunning) { + calculatePosition(); + notifyListeners(mockPosition); + AppIdleRunningModeHandler.sendIdleLog("模拟GPS数据更新 -> 纬度:" + mockPosition.getLatitude() + ", 经度:" + mockPosition.getLongitude() + ", 角度:" + currentBearing + "°"); + handler.postDelayed(this, MOCK_INTERVAL_MS); + } + } + }; + + bearingRunnable = new Runnable() { + @Override + public void run() { + if (isRunning) { + currentBearing++; + if (currentBearing > 360) { + currentBearing = 1; + } + handler.postDelayed(this, BEARING_INTERVAL_MS); + } + } + }; + } + + private void calculatePosition() { + double bearingRad = Math.toRadians(currentBearing); + double angularDistance = CIRCLE_RADIUS_M / EARTH_RADIUS_M; + + double anchorLatRad = Math.toRadians(ANCHOR_LAT); + double anchorLonRad = Math.toRadians(ANCHOR_LON); + + double newLatRad = Math.asin( + Math.sin(anchorLatRad) * Math.cos(angularDistance) + + Math.cos(anchorLatRad) * Math.sin(angularDistance) * Math.cos(bearingRad) + ); + + double newLonRad = anchorLonRad + Math.atan2( + Math.sin(bearingRad) * Math.sin(angularDistance) * Math.cos(anchorLatRad), + Math.cos(angularDistance) - Math.sin(anchorLatRad) * Math.sin(newLatRad) + ); + + mockPosition.setLatitude(Math.toDegrees(newLatRad)); + mockPosition.setLongitude(Math.toDegrees(newLonRad)); + } + + public static IdleGpsService getInstance() { + if (instance == null) { + instance = new IdleGpsService(); + } + return instance; + } + + /** + * 启动空转模拟服务 + */ + public void start() { + if (isRunning) return; + isRunning = true; + currentBearing = 1; + AppIdleRunningModeHandler.sendIdleLog("空转GPS服务已启动"); + notifyStatusChange("空转GPS服务已启动"); + ToastUtils.show("空转GPS服务已启动"); + handler.post(updateRunnable); + handler.post(bearingRunnable); + } + + /** + * 停止空转模拟服务 + */ + public void stop() { + if (!isRunning) return; + isRunning = false; + handler.removeCallbacks(updateRunnable); + handler.removeCallbacks(bearingRunnable); + notifyStatusChange("空转GPS服务已停止"); + AppIdleRunningModeHandler.sendIdleLog("空转GPS服务已停止"); + } + + /** + * 注册GPS监听 + */ + public void registerGpsUpdateListener(MainService.GpsUpdateListener listener) { + synchronized (listeners) { + if (!listeners.contains(listener)) { + listeners.add(listener); + } + } + if (!isRunning) { + start(); + } + } + + /** + * 注销GPS监听 + */ + public void unregisterGpsUpdateListener(MainService.GpsUpdateListener listener) { + synchronized (listeners) { + listeners.remove(listener); + } + if (listeners.isEmpty() && isRunning) { + stopMockUpdate(); + } + } + + private void startMockUpdate() { + if (isRunning) return; + isRunning = true; + currentBearing = 1; + notifyStatusChange("空转GPS服务已启动"); + handler.post(updateRunnable); + handler.post(bearingRunnable); + } + + private void stopMockUpdate() { + isRunning = false; + handler.removeCallbacks(updateRunnable); + handler.removeCallbacks(bearingRunnable); + notifyStatusChange("空转GPS服务已停止"); + } + + private void notifyListeners(PositionModel pos) { + synchronized (listeners) { + for (MainService.GpsUpdateListener listener : listeners) { + listener.onGpsPositionUpdated(pos); + } + } + } + + private void notifyStatusChange(String status) { + synchronized (listeners) { + for (MainService.GpsUpdateListener listener : listeners) { + listener.onGpsStatusChanged(status); + } + } + } + + public boolean isRunning() { + return isRunning; + } +} diff --git a/positions/src/main/java/cc/winboll/studio/positions/services/MainService.java b/positions/src/main/java/cc/winboll/studio/positions/services/MainService.java new file mode 100644 index 0000000..517cfcf --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/services/MainService.java @@ -0,0 +1,1133 @@ +package cc.winboll.studio.positions.services; + +/** + * @Author ZhanGSKen + * @Date 2024/07/19 14:30:57 + * @Describe 应用主要服务组件类 + */ +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.pm.PackageManager; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.location.LocationProvider; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.text.TextUtils; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.positions.App; +import cc.winboll.studio.positions.models.PositionModel; +import cc.winboll.studio.positions.models.PositionTaskModel; +import cc.winboll.studio.positions.utils.AppConfigsUtil; +import cc.winboll.studio.positions.utils.DistanceCalculatorUtil; +import cc.winboll.studio.positions.utils.NotificationUtil; +import cc.winboll.studio.positions.utils.ServiceUtil; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; // 新增:定时器时间单位依赖 + +public class MainService extends Service { + + public static final String TAG = "MainService"; + + public static final String EXTRA_IS_SETTING_TO_ENABLE = "EXTRA_IS_SETTING_TO_ENABLE"; + + // ---------------------- 新增:定时器相关变量 ---------------------- + private ScheduledExecutorService taskCheckTimer; // 任务校验定时器 + private static final long TASK_CHECK_INTERVAL = 1; // 定时间隔(1分钟) + private static final long TASK_CHECK_INIT_DELAY = 1; // 初始延迟(1分钟:立即启动) + + // GPS监听接口(Java 7 标准接口定义,无Lambda依赖) + public interface GpsUpdateListener { + void onGpsPositionUpdated(PositionModel currentGpsPos); + void onGpsStatusChanged(String status); + } + + // 空转GPS监听实例(用于注册到IdleGpsService) + private final GpsUpdateListener mIdleGpsListener = new GpsUpdateListener() { + @Override + public void onGpsPositionUpdated(PositionModel currentGpsPos) { + handleGpsPositionUpdate(currentGpsPos); + } + + @Override + public void onGpsStatusChanged(String status) { + handleGpsStatusChange(status); + } + }; + + // 中央处理:GPS 位置更新 + private void handleGpsPositionUpdate(PositionModel pos) { + if (pos == null) return; + syncCurrentGpsPosition(pos); + DistanceCalculatorUtil.getInstance(MainService.this).checkAllTaskTriggerCondition(pos); + String src = App.isAppIdleRunning() ? " (空转)" : ""; + LogUtils.d(TAG, "GPS位置更新:纬度=" + pos.getLatitude() + ",经度=" + pos.getLongitude() + src); + } + + // 中央处理:GPS 状态变化 + private void handleGpsStatusChange(String status) { + LogUtils.d(TAG, "GPS状态变化:" + status); + notifyAllGpsStatusListeners(status); + updateNotificationGpsStatus(status); + } + + // 任务更新监听接口(Java 7 风格,供Adapter监听任务变化) + public interface TaskUpdateListener { + void onTaskUpdated(); + } + + // 监听管理(弱引用+线程安全集合,适配Java 7,避免内存泄漏+并发异常) + private final Set> mGpsListeners = new HashSet>(); + private final Set> mTaskListeners = new HashSet>(); + private final Object mListenerLock = new Object(); // 监听操作锁,保证线程安全 + + // 原有核心变量(Java 7 显式初始化,无Java 8+语法) + private LocalBinder mLocalBinder; //持有 LocalBinder 实例(用于暴露服务) + private LocationManager mLocationManager; + private LocationListener mGpsLocationListener; + private static final long GPS_UPDATE_INTERVAL = 2000; // GPS更新间隔:2秒 + private static final float GPS_UPDATE_DISTANCE = 1; // GPS更新距离阈值:1米 + private boolean isGpsEnabled = false; // GPS是否启用标记 + private boolean isGpsPermissionGranted = false; // 定位权限是否授予标记 + + // 数据存储集合(Java 7 基础集合,避免Stream/forEach等Java 8+特性) + private final ArrayList mPositionList = new ArrayList(); // 位置数据列表 + private final ArrayList mAllTasks = new ArrayList();// 任务数据列表 + private static PositionModel _mCurrentGpsPosition; // 当前GPS定位数据 + private boolean isListeningToIdleGps = false; // 当前是否监听空转GPS + + // 服务相关变量(Java 7 显式声明,保持原逻辑) + MyServiceConnection mMyServiceConnection; + volatile static boolean _mIsServiceRunning; // 服务运行状态(volatile保证可见性) + AppConfigsUtil mAppConfigsUtil; + private ScheduledExecutorService distanceExecutor = Executors.newSingleThreadScheduledExecutor(); // 单线程池处理距离计算 + private final Set mVisiblePositionIds = new HashSet(); // 可见位置ID集合 + + // 单例+应用上下文(Java 7 静态变量,保证服务实例唯一+上下文安全) + private static volatile MainService sInstance; + private static Context sAppContext; + + + // ========================================================================= + // 新增:定时器初始化方法(创建单线程定时器,每1分钟调用任务校验) + // ========================================================================= + private void initTaskCheckTimer() { + // 先销毁旧定时器(避免重复创建导致多线程问题) + if (taskCheckTimer != null && !taskCheckTimer.isShutdown()) { + taskCheckTimer.shutdown(); + } + + // 创建单线程定时器(确保任务串行执行,避免并发异常) + taskCheckTimer = Executors.newSingleThreadScheduledExecutor(); + // 定时任务:初始延迟1分钟,每1分钟执行一次 + taskCheckTimer.scheduleAtFixedRate(new Runnable() { + @Override + public void run() { + LogUtils.d(TAG, "定时任务触发:开始校验任务(间隔1分钟)"); + // 调用任务校验核心方法(与GPS位置变化时逻辑一致) + DistanceCalculatorUtil.getInstance(MainService.this).checkAllTaskTriggerCondition(MainService._mCurrentGpsPosition); + } + }, TASK_CHECK_INIT_DELAY, TASK_CHECK_INTERVAL, TimeUnit.MINUTES); + + LogUtils.d(TAG, "任务校验定时器已启动(间隔:" + TASK_CHECK_INTERVAL + "分钟)"); + } + + // ========================================================================= + // 新增:定时器销毁方法(服务销毁时调用,避免内存泄漏) + // ========================================================================= + private void destroyTaskCheckTimer() { + if (taskCheckTimer != null && !taskCheckTimer.isShutdown()) { + taskCheckTimer.shutdown(); // 优雅关闭:等待已提交任务执行完成 + try { + // 等待1秒,若未终止则强制关闭 + if (!taskCheckTimer.awaitTermination(1, TimeUnit.SECONDS)) { + taskCheckTimer.shutdownNow(); // 强制终止未完成任务 + } + } catch (InterruptedException e) { + taskCheckTimer.shutdownNow(); // 捕获中断异常,强制关闭 + Thread.currentThread().interrupt(); // 恢复线程中断状态 + } finally { + taskCheckTimer = null; // 置空,避免重复操作 + LogUtils.d(TAG, "任务校验定时器已销毁"); + } + } + } + + // ========================================================================= + // 任务操作核心接口(Java 7 实现,全迭代器遍历,无ConcurrentModificationException) + // ========================================================================= + /** + * 新增任务(Adapter调用,通过MainService统一管理任务,保证数据一致性) + * @param newTask 待新增的任务模型 + */ + public void addTask(PositionTaskModel newTask) { + // 参数校验(Java 7 基础判断,无Optional等Java 8+特性) + if (newTask == null || TextUtils.isEmpty(newTask.getPositionId())) { + LogUtils.w(TAG, "addPositionTask:任务为空或未绑定位置ID,新增失败"); + return; + } + + // 任务去重(Java 7 迭代器遍历,避免增强for循环删除/新增导致的并发异常) + boolean isDuplicate = false; + Iterator taskIter = mAllTasks.iterator(); + while (taskIter.hasNext()) { + PositionTaskModel task = taskIter.next(); + if (newTask.getTaskId().equals(task.getTaskId())) { + isDuplicate = true; + break; + } + } + if (isDuplicate) { + LogUtils.w(TAG, "addPositionTask:任务ID已存在(" + newTask.getTaskId() + "),新增失败"); + return; + } + + // 新增任务+持久化+通知刷新(全Java 7 语法) + mAllTasks.add(newTask); + saveAllTasks(); + notifyTaskUpdated(); // 通知所有监听者(如Adapter)任务已更新 + LogUtils.d(TAG, "addPositionTask:成功(位置ID=" + newTask.getPositionId() + ",任务ID=" + newTask.getTaskId() + ")"); + } + + /** + * 获取指定位置的所有任务(Adapter显示任务数量用,数据来源唯一) + * @param positionId 位置ID + * @return 该位置绑定的所有任务(返回新列表,避免外部修改原数据) + */ + public ArrayList getTasksByPositionId(String positionId) { + ArrayList posTasks = new ArrayList(); + if (TextUtils.isEmpty(positionId) || mAllTasks.isEmpty()) { + return posTasks; + } + + // 筛选任务(Java 7 迭代器遍历,安全筛选) + Iterator taskIter = mAllTasks.iterator(); + while (taskIter.hasNext()) { + PositionTaskModel task = taskIter.next(); + if (positionId.equals(task.getPositionId())) { + posTasks.add(task); + } + } + return posTasks; + } + + /** + * 获取所有任务(Adapter全量刷新用,返回拷贝避免原数据被外部修改) + * @return 所有任务的拷贝列表 + */ + public ArrayList getAllTasks() { + return mAllTasks; // Java 7 集合拷贝方式 + } + + public void updateTask(PositionTaskModel updatedTask) { + if (updatedTask == null || updatedTask.getTaskId() == null) return; + for (int i = 0; i < mAllTasks.size(); i++) { + PositionTaskModel task = mAllTasks.get(i); + if (updatedTask.getTaskId().equals(task.getTaskId())) { + mAllTasks.set(i, updatedTask); // 替换为更新后的任务 + break; + } + } + saveAllTasks(); // 持久化更新后的数据 + } + + // 4. 仅更新任务启用状态(优化性能,避免全量字段更新) + public void updateTaskStatus(PositionTaskModel task) { + if (task == null || task.getTaskId() == null) return; + for (PositionTaskModel item : mAllTasks) { + if (task.getTaskId().equals(item.getTaskId())) { + item.setIsEnable(task.isEnable()); // 只更新启用状态字段 + break; + } + } + saveAllTasks(); // 持久化状态变更 + } + + + /** + * 删除任务(Adapter调用,通过迭代器安全删除,避免并发异常) + * @param taskId 待删除任务的ID + */ + public void deleteTask(final String taskId) { + if (TextUtils.isEmpty(taskId) || mAllTasks.isEmpty()) { + LogUtils.w(TAG, "deletePositionTask:任务ID为空或列表为空,删除失败"); + return; + } + + // 迭代器删除(Java 7 唯一安全删除集合元素的方式) + Iterator taskIter = mAllTasks.iterator(); + while (taskIter.hasNext()) { + PositionTaskModel task = taskIter.next(); + if (taskId.equals(task.getTaskId())) { + taskIter.remove(); // 迭代器安全删除,无ConcurrentModificationException + saveAllTasks(); + notifyTaskUpdated(); + LogUtils.d(TAG, "deletePositionTask:成功(任务ID=" + taskId + ")"); + break; + } + } + } + + /** + * 注册任务更新监听(Java 7 弱引用管理,避免内存泄漏) + * @param listener 任务更新监听者(如Adapter) + */ + public void registerTaskUpdateListener(TaskUpdateListener listener) { + if (listener == null) { + LogUtils.w(TAG, "registerTaskUpdateListener:监听者为空,跳过"); + return; + } + synchronized (mListenerLock) { // 加锁保证多线程注册安全 + mTaskListeners.add(new WeakReference(listener)); + } + } + + /** + * 反注册任务更新监听(Java 7 迭代器清理,避免内存泄漏) + * @param listener 待反注册的监听者 + */ + public void unregisterTaskUpdateListener(TaskUpdateListener listener) { + if (listener == null) { + LogUtils.w(TAG, "unregisterTaskUpdateListener:监听者为空,跳过"); + return; + } + synchronized (mListenerLock) { + Iterator> iter = mTaskListeners.iterator(); + while (iter.hasNext()) { + WeakReference ref = iter.next(); + // 清理目标监听者或已被回收的弱引用 + if (ref.get() == listener || ref.get() == null) { + iter.remove(); + } + } + } + } + + /** + * 通知所有任务监听者更新(Java 7 匿名内部类实现主线程回调,无Lambda) + */ + private void notifyTaskUpdated() { + synchronized (mListenerLock) { + Iterator> iter = mTaskListeners.iterator(); + while (iter.hasNext()) { + final WeakReference ref = iter.next(); + if (ref.get() != null) { + // 判断是否在主线程,不在则切换(Java 7 匿名Runnable,无Lambda) + if (Looper.myLooper() == Looper.getMainLooper()) { + ref.get().onTaskUpdated(); + } else { + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + ref.get().onTaskUpdated(); + } + }); + } + } else { + iter.remove(); // 清理已回收的弱引用,避免内存泄漏 + } + } + } + } + + + // ========================================================================= + // 原有基础方法(Java 7 语法调整:移除所有Lambda/方法引用,用匿名内部类替代) + // ========================================================================= + /** + * 获取服务单例(Java 7 静态同步方法,保证线程安全) + * @param context 上下文 + * @return MainService实例(未绑定成功时返回null) + */ + public static synchronized MainService getInstance(Context context) { + if (sInstance == null) { + if (AppConfigsUtil.getInstance(context).isEnableMainService(true)) { + Intent intent = new Intent(context.getApplicationContext(), MainService.class); + context.getApplicationContext().startService(intent); + } + return null; + } + if (sAppContext == null) { + sAppContext = sInstance.getApplicationContext(); + } + return sInstance; + } + + /** + * 服务绑定回调(Java 7 基础实现,无默认方法等Java 8+特性) + */ + @Override + public IBinder onBind(Intent intent) { + // 返回 LocalBinder,使Activity能通过Binder获取MainService实例 + return mLocalBinder; + } + + /** + * 服务创建回调(初始化单例、上下文、配置、服务连接等) + */ + @Override + public void onCreate() { + LogUtils.d(TAG, "onCreate"); + super.onCreate(); + sInstance = this; + sAppContext = getApplicationContext(); + + // 初始化 LocalBinder(关键:将MainService实例传入Binder) + mLocalBinder = new LocalBinder(this); + + _mIsServiceRunning = false; + mAppConfigsUtil = AppConfigsUtil.getInstance(this); + + // 初始化服务连接(Java 7 显式判断,无Optional) + if (mMyServiceConnection == null) { + mMyServiceConnection = new MyServiceConnection(); + } + + if (mAppConfigsUtil.isEnableMainService(true)) { + if (App.isAppIdleRunning()) { + IdleGpsService.getInstance().start(); + } + run(); // 启动服务核心逻辑 + } + } + + /** + * 服务核心逻辑(启动前台服务、初始化GPS、加载数据等) + * 【关键修改】新增定时器初始化,每1分钟调用任务校验 + */ + public void run() { + if (mAppConfigsUtil.isEnableMainService(true)) { + if (!_mIsServiceRunning) { + _mIsServiceRunning = true; + wakeupAndBindAssistant(); // 唤醒并绑定辅助服务 + + // 启动前台服务(Java 7 显式调用,无方法引用) + String initialStatus = "[ Positions ] is in Service."; + if (App.isAppIdleRunning()) { + initialStatus += " [IDLE RUNNING]"; + } + NotificationUtil.createForegroundServiceNotification(this, initialStatus); + startForeground(NotificationUtil.FOREGROUND_SERVICE_NOTIFICATION_ID, + NotificationUtil.createForegroundServiceNotification(this, initialStatus)); + + // 初始化GPS相关(Java 7 基础API调用) + mLocationManager = (LocationManager) sInstance.getApplicationContext().getSystemService(Context.LOCATION_SERVICE); + initGpsLocationListener(); + startGpsLocation(); + + // 加载本地数据(Java 7 静态方法调用,无方法引用) + PositionModel.loadBeanList(MainService.this, mPositionList, PositionModel.class); + PositionTaskModel.loadBeanList(MainService.this, mAllTasks, PositionTaskModel.class); + + // 提示与日志(Java 7 基础调用) + ToastUtils.show(initialStatus); + LogUtils.i(TAG, initialStatus); + + // ---------------------- 关键新增:启动任务校验定时器 ---------------------- + //checkAllTaskTriggerCondition(); + initTaskCheckTimer(); + } + } + } + + /** + * 获取服务运行状态 + * @return true=运行中,false=未运行 + */ + public boolean isServiceRunning() { + return _mIsServiceRunning; + } + + /** + * 服务销毁回调(清理资源、停止GPS、清空数据、反注册监听等) + * 【关键修改】新增定时器销毁,避免内存泄漏 + */ + @Override + public void onDestroy() { + super.onDestroy(); + sInstance = null; + + // 清理资源(Java 7 顺序调用,无Stream等特性) + stopGpsLocation(); + clearAllData(); + stopForeground(true); + + // 清理所有监听者(Java 7 加锁+清空,避免内存泄漏) + synchronized (mListenerLock) { + mGpsListeners.clear(); + mTaskListeners.clear(); + } + + // ---------------------- 关键新增:销毁任务校验定时器 ---------------------- + destroyTaskCheckTimer(); + // 销毁距离计算线程池(原有逻辑,补充确保线程安全) + if (distanceExecutor != null && !distanceExecutor.isShutdown()) { + distanceExecutor.shutdown(); + } + + // 重置状态变量 + _mIsServiceRunning = false; + isGpsEnabled = false; + mLocationManager = null; + } + + + // ========================================================================= + // 位置操作方法(Java 7 语法,全迭代器/基础循环,无Java 8+特性) + // ========================================================================= + /** + * 获取所有位置数据(返回原列表,供外部读取) + * @return 位置列表 + */ + public ArrayList getPositionList() { + return mPositionList; + } + + /** + * 获取当前GPS位置 + * @return 当前GPS定位模型(未获取时返回null) + */ + public PositionModel getCurrentGpsPosition() { + return _mCurrentGpsPosition; + } + + /** + * 删除指定位置(Java 7 迭代器安全删除) + * @param targetPosId 待删除位置的ID + */ + public void removePosition(String targetPosId) { + if (TextUtils.isEmpty(targetPosId) || mPositionList.isEmpty()) { + LogUtils.w(TAG, "removePosition:参数无效"); + return; + } + // 迭代器遍历删除(Java 7 安全方式) + Iterator iter = mPositionList.iterator(); + while (iter.hasNext()) { + PositionModel pos = iter.next(); + if (targetPosId.equals(pos.getPositionId())) { + iter.remove(); + savePositionList(); + break; + } + } + } + + /** + * 更新位置数据(Java 7 基础for循环,无Stream筛选) + * @param updatedPos 更新后的位置模型 + */ + public void updatePosition(PositionModel updatedPos) { + if (updatedPos == null || TextUtils.isEmpty(updatedPos.getPositionId()) || mPositionList.isEmpty()) { + LogUtils.w(TAG, "updatePosition:参数无效"); + return; + } + // 基础for循环查找并更新(Java 7 标准写法) + for (int i = 0; i < mPositionList.size(); i++) { + PositionModel oldPos = mPositionList.get(i); + if (updatedPos.getPositionId().equals(oldPos.getPositionId())) { + mPositionList.set(i, updatedPos); + savePositionList(); + break; + } + } + } + + /** + * 同步所有位置任务(全量替换,用于批量更新) + * @param newTaskList 新的任务列表 + */ + public void syncAllPositionTasks(ArrayList newTaskList) { + if (newTaskList == null) { + LogUtils.w(TAG, "syncAllPositionTasks:新列表为空"); + return; + } + // 全量替换+持久化+通知(Java 7 基础集合操作) + mAllTasks.clear(); + mAllTasks.addAll(newTaskList); + saveAllTasks(); + notifyTaskUpdated(); + } + + /** + * 新增位置(Java 7 增强for循环去重,无Stream) + * @param newPos 待新增的位置模型 + */ + public void addPosition(PositionModel newPos) { + if (newPos == null) { + LogUtils.w(TAG, "addPosition:位置为空"); + return; + } + // 位置去重(Java 7 增强for循环,无Stream.filter) + boolean isDuplicate = false; + for (PositionModel pos : mPositionList) { + if (newPos.getPositionId().equals(pos.getPositionId())) { + isDuplicate = true; + break; + } + } + if (!isDuplicate) { + mPositionList.add(newPos); + savePositionList(); + } + } + + /** + * 持久化位置数据(Java 7 静态方法调用,保持原逻辑) + */ + void savePositionList() { + LogUtils.d(TAG, String.format("savePositionList : size=%d", mPositionList.size())); + PositionModel.saveBeanList(MainService.this, mPositionList, PositionModel.class); + } + + /** + * 持久化任务数据(Java 7 静态方法调用,保持原逻辑) + */ + public void saveAllTasks() { + LogUtils.d(TAG, String.format("saveTaskList : size=%d", mAllTasks.size())); + PositionTaskModel.saveBeanList(MainService.this, mAllTasks, PositionTaskModel.class); + } + + /** + * 清空所有数据(位置+任务+GPS缓存,Java 7 集合clear方法) + */ + public void clearAllData() { + mPositionList.clear(); + mAllTasks.clear(); + _mCurrentGpsPosition = null; + LogUtils.d(TAG, "clearAllData:已清空所有数据"); + } + + /** + * 同步当前GPS位置(更新缓存+通知监听者+同步通知栏,全Java 7 语法) + * @param position 最新GPS位置模型 + */ + public void syncCurrentGpsPosition(PositionModel position) { + if (position == null) { + LogUtils.w(TAG, "syncCurrentGpsPosition:位置为空"); + return; + } + this._mCurrentGpsPosition = position; + LogUtils.d(TAG, "syncCurrentGpsPosition:成功(纬度=" + position.getLatitude() + ",经度=" + position.getLongitude() + ")"); + notifyAllGpsListeners(position); + + // 服务运行中才同步通知栏状态 + if (_mIsServiceRunning) { + syncGpsStatusToNotification(); + } + } + + /** + * 同步GPS状态到前台通知(Java 7 匿名Runnable切换主线程,无Lambda) + */ + private void syncGpsStatusToNotification() { + if (!_mIsServiceRunning || _mCurrentGpsPosition == null) { + return; + } + // 根据空转状态决定通知前缀(区分空转GPS与真实GPS) + String prefix = App.isAppIdleRunning() ? "空转GPS" : "GPS位置"; + // 格式化通知内容(Java 7 String.format,使用%.15f显示全部GPS精度) + String gpsStatus = String.format( + "%s:北纬%.15f° 东经%.15f° | 可见位置:%d个", + prefix, + _mCurrentGpsPosition.getLatitude(), + _mCurrentGpsPosition.getLongitude(), + mVisiblePositionIds.size() + ); + if (App.isAppIdleRunning()) { + gpsStatus += " [IDLE RUNNING]"; + } + final String finalGpsStatus = gpsStatus; + // 主线程判断+切换(Java 7 匿名内部类) + if (Looper.myLooper() == Looper.getMainLooper()) { + NotificationUtil.updateForegroundServiceStatus(this, finalGpsStatus); + } else { + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + NotificationUtil.updateForegroundServiceStatus(MainService.this, finalGpsStatus); + } + }); + } + } + + + + + // ========================================================================= + // 服务生命周期+辅助服务相关(Java 7 语法,无Lambda/方法引用) + // ========================================================================= + /** + * 服务启动命令(每次startService调用时触发,重启服务核心逻辑) + */ + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + boolean isSettingToEnable = false; + if (intent != null) { + isSettingToEnable = intent.getBooleanExtra(EXTRA_IS_SETTING_TO_ENABLE, false); + if (isSettingToEnable) { + if (App.isAppIdleRunning()) { + IdleGpsService.getInstance().start(); + } + run(); // 重启服务核心逻辑(保证服务启动后进入运行状态) + } + } + + // 如果被设置为自启动就返回START_STICKY:服务被异常杀死后,系统会尝试重启(原逻辑保留) + // 否则就启动默认参数 + return isSettingToEnable ? Service.START_STICKY : super.onStartCommand(intent, flags, startId); + } + + /** + * 服务连接内部类(Java 7 静态内部类,避免持有外部类强引用导致内存泄漏) + */ + private class MyServiceConnection implements ServiceConnection { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + // 原逻辑保留(空实现,如需绑定辅助服务可补充具体逻辑) + } + + @Override + public void onServiceDisconnected(ComponentName name) { + // 辅助服务断开时,重新唤醒绑定(原逻辑保留) + if (mAppConfigsUtil.isEnableMainService(true)) { + wakeupAndBindAssistant(); + } + } + } + + /** + * 唤醒并绑定辅助服务(检查服务状态,未存活则启动+绑定) + */ + void wakeupAndBindAssistant() { + // 检查辅助服务是否存活(Java 7 静态方法调用,无方法引用) + if (!ServiceUtil.isServiceAlive(getApplicationContext(), AssistantService.class.getName())) { + // 启动+绑定辅助服务(Java 7 显式Intent,无Lambda) + startService(new Intent(MainService.this, AssistantService.class)); + bindService(new Intent(MainService.this, AssistantService.class), mMyServiceConnection, Context.BIND_IMPORTANT); + } + } + + + // ========================================================================= + // GPS相关核心方法(Java 7 语法,匿名内部类实现LocationListener,无Lambda) + // ========================================================================= + /** + * 构造函数(Java 7 显式初始化线程池+GPS监听器,无默认构造函数简化) + */ + public MainService() { + distanceExecutor = Executors.newSingleThreadScheduledExecutor(); + initGpsLocationListener(); + } + + /** + * 初始化GPS监听器(Java 7 匿名内部类实现LocationListener,无Lambda) + */ + private void initGpsLocationListener() { + LogUtils.d(TAG, "initGpsLocationListener"); + mGpsLocationListener = new LocationListener() { + @Override + public void onLocationChanged(Location location) { + if (location != null) { + // 封装GPS位置为PositionModel(Java 7 显式setter调用) + PositionModel gpsPos = new PositionModel(); + gpsPos.setLatitude(location.getLatitude()); + gpsPos.setLongitude(location.getLongitude()); + gpsPos.setPositionId("CURRENT_GPS_POS"); + gpsPos.setMemo("实时GPS位置"); + + // 调用中央处理方法 + handleGpsPositionUpdate(gpsPos); + } + } + + @Override + public void onStatusChanged(String provider, int status, Bundle extras) { + // 仅处理GPS_PROVIDER状态变化(Java 7 基础判断) + if (provider.equals(LocationManager.GPS_PROVIDER)) { + String statusDesc = ""; + // 状态枚举判断(Java 7 switch,无增强switch) + switch (status) { + case LocationProvider.AVAILABLE: + statusDesc = "GPS状态:已就绪(可用)"; + break; + case LocationProvider.OUT_OF_SERVICE: + statusDesc = "GPS状态:无服务(信号弱)"; + break; + case LocationProvider.TEMPORARILY_UNAVAILABLE: + statusDesc = "GPS状态:临时不可用(遮挡)"; + break; + } + handleGpsStatusChange(statusDesc); + } + } + + @Override + public void onProviderEnabled(String provider) { + // GPS启用时更新状态+通知+重启定位(Java 7 基础逻辑) + if (provider.equals(LocationManager.GPS_PROVIDER)) { + isGpsEnabled = true; + handleGpsStatusChange("GPS已开启(用户手动打开)"); + startGpsLocation(); + } + } + + @Override + public void onProviderDisabled(String provider) { + // GPS禁用时清空状态+通知+提示(Java 7 基础逻辑) + if (provider.equals(LocationManager.GPS_PROVIDER)) { + isGpsEnabled = false; + _mCurrentGpsPosition = null; + handleGpsStatusChange("GPS已关闭(用户手动关闭)"); + ToastUtils.show("GPS已关闭,无法获取位置,请在设置中开启"); + } + } + }; + } + + /** + * 检查GPS就绪状态(权限+启用状态,Java 7 基础权限判断,无Stream) + * @return true=GPS就绪,false=未就绪 + */ + private boolean checkGpsReady() { + // 检查定位权限(Java 7 基础权限API,无权限请求框架依赖) + isGpsPermissionGranted = checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED; + + // 初始化LocationManager(Java 7 显式判断,无Optional) + if (mLocationManager == null) { + mLocationManager = (LocationManager) getSystemService(Context.LOCATION_SERVICE); + } + // 检查GPS是否启用(系统LocationManager API,Java 7 兼容) + isGpsEnabled = mLocationManager.isProviderEnabled(LocationManager.GPS_PROVIDER); + + // 权限未授予:提示+日志+通知 + if (!isGpsPermissionGranted) { + String tip = "GPS准备失败:缺少精确定位权限"; + LogUtils.e(TAG, tip); + notifyAllGpsStatusListeners(tip); + updateNotificationGpsStatus("缺少定位权限,无法获取GPS"); + ToastUtils.show("请授予定位权限,否则无法获取GPS位置"); + return false; + } + // GPS未启用:提示+日志+通知 + if (!isGpsEnabled) { + String tip = "GPS准备失败:系统GPS未开启"; + LogUtils.e(TAG, tip); + notifyAllGpsStatusListeners(tip); + updateNotificationGpsStatus("GPS未开启,请在设置中打开"); + ToastUtils.show("GPS已关闭,请在设置中开启以获取位置"); + return false; + } + + LogUtils.d(TAG, "GPS准备就绪:权限已获取,GPS已开启"); + return true; + } + + /** + * 启动GPS定位(Java 7 异常处理,无try-with-resources,显式捕获SecurityException) + * 【关键修改】根据应用空转状态切换数据源(IdleGpsService 或 系统GPS) + */ + private void startGpsLocation() { + // 检查空转状态:如果处于空转,使用 IdleGpsService + if (App.isAppIdleRunning()) { + if (isListeningToIdleGps) return; // 已在监听空转GPS,无需重复注册 + stopGpsLocation(); // 停止系统GPS监听(如果正在运行) + IdleGpsService.getInstance().registerGpsUpdateListener(mIdleGpsListener); + isListeningToIdleGps = true; + LogUtils.d(TAG, "启动GPS定位:使用空转模拟数据"); + handleGpsStatusChange("空转GPS监听中..."); + return; + } + + // 系统GPS逻辑 + if (isListeningToIdleGps) { + // 之前是空转,现在切换到系统GPS + IdleGpsService.getInstance().unregisterGpsUpdateListener(mIdleGpsListener); + isListeningToIdleGps = false; + } + + if (!checkGpsReady()) { + return; + } + + try { + // 注册GPS位置更新(Java 7 标准LocationManager API,指定Looper为主线程) + mLocationManager.requestLocationUpdates( + LocationManager.GPS_PROVIDER, + GPS_UPDATE_INTERVAL, + GPS_UPDATE_DISTANCE, + mGpsLocationListener, + Looper.getMainLooper() + ); + + // 获取最后已知GPS位置(缓存位置,避免首次定位等待) + Location lastKnownLocation = mLocationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER); + if (lastKnownLocation != null) { + PositionModel lastGpsPos = new PositionModel(); + lastGpsPos.setLatitude(lastKnownLocation.getLatitude()); + lastGpsPos.setLongitude(lastKnownLocation.getLongitude()); + lastGpsPos.setPositionId("CURRENT_GPS_POS"); + handleGpsPositionUpdate(lastGpsPos); + LogUtils.d(TAG, "已获取缓存GPS位置:纬度=" + lastKnownLocation.getLatitude()); + } else { + String tip = "无缓存GPS位置,等待实时定位..."; + handleGpsStatusChange(tip); + updateNotificationGpsStatus("GPS搜索中(请移至开阔地带)"); + } + + } catch (SecurityException e) { + // 定位权限异常(Java 7 显式捕获,无Lambda异常处理) + String error = "启动GPS失败(权限异常):" + e.getMessage(); + LogUtils.e(TAG, error); + handleGpsStatusChange(error); + isGpsPermissionGranted = false; + updateNotificationGpsStatus("定位权限异常,无法获取GPS"); + } catch (Exception e) { + // 其他异常(如LocationManager为空、系统服务异常等) + String error = "启动GPS失败:" + e.getMessage(); + LogUtils.e(TAG, error); + handleGpsStatusChange(error); + updateNotificationGpsStatus("GPS启动失败,尝试重试..."); + } + } + + /** + * 停止GPS定位(Java 7 异常处理,移除监听器避免内存泄漏) + * 【关键修改】根据当前监听源停止对应的服务 + */ + private void stopGpsLocation() { + if (isListeningToIdleGps) { + IdleGpsService.getInstance().unregisterGpsUpdateListener(mIdleGpsListener); + isListeningToIdleGps = false; + LogUtils.d(TAG, "停止GPS定位:已注销空转GPS监听"); + } else { + // 校验参数:避免空指针+权限未授予时调用 + if (mLocationManager != null && mGpsLocationListener != null && isGpsPermissionGranted) { + try { + mLocationManager.removeUpdates(mGpsLocationListener); + String tip = "GPS定位已停止(移除监听器)"; + LogUtils.d(TAG, tip); + handleGpsStatusChange(tip); + } catch (Exception e) { + String error = "停止GPS失败:" + e.getMessage(); + LogUtils.e(TAG, error); + handleGpsStatusChange(error); + } + } + } + } + + /** + * 发送任务触发通知(更新前台通知+显示Toast,Java 7 匿名Runnable切换主线程) + * @param task 触发的任务 + * @param bindPos 任务绑定的位置 + * @param currentDistance 当前距离 + */ + public void sendTaskTriggerNotification(final PositionTaskModel task, PositionModel bindPos, double currentDistance) { + /*if (!_mIsServiceRunning) { + return; + }*/ + + // 格式化通知内容(Java 7 String.format,无TextBlock等Java 15+特性) + final String triggerContent = String.format( + "任务触发:%s\n位置:%s\n当前距离:%.1f米(条件:%s%d米)", + task.getTaskDescription(), + bindPos.getMemo(), + currentDistance, + task.isGreaterThan() ? ">" : "<", + task.getDiscussDistance() + ); + + // 更新前台通知(主线程判断+切换) + updateNotificationGpsStatus(triggerContent); + + // 显示Toast(主线程安全调用,Java 7 匿名内部类) + if (Looper.myLooper() == Looper.getMainLooper()) { + ToastUtils.show(triggerContent); + NotificationUtil.show(MainService.this, task.getTaskId(), task.getPositionId(), task.getTaskDescription()); + } else { + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + ToastUtils.show(triggerContent); + NotificationUtil.show(MainService.this, task.getTaskId(), task.getPositionId(), task.getTaskDescription()); + } + }); + } + LogUtils.i(TAG, "任务触发通知:" + triggerContent); + } + + + /** + * 更新前台通知的GPS状态(Java 7 主线程切换,匿名Runnable实现) + * @param statusText 通知显示的状态文本 + */ + void updateNotificationGpsStatus(final String statusText) { + if (_mIsServiceRunning) { + String text = statusText; + if (App.isAppIdleRunning()) { + text += " [IDLE RUNNING]"; + } + final String finalText = text; + // 判断当前线程是否为主线程,避免UI操作在子线程 + if (Looper.myLooper() == Looper.getMainLooper()) { + NotificationUtil.updateForegroundServiceStatus(this, finalText); + } else { + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + NotificationUtil.updateForegroundServiceStatus(MainService.this, finalText); + } + }); + } + } + } + + + // ========================================================================= + // GPS监听通知相关方法(Java 7 迭代器遍历弱引用集合,避免内存泄漏) + // ========================================================================= + /** + * 通知所有GPS监听者位置更新(Java 7 迭代器+弱引用管理,无Stream) + * @param currentGpsPos 当前最新GPS位置 + */ + public void notifyAllGpsListeners(PositionModel currentGpsPos) { + if (currentGpsPos == null || mGpsListeners.isEmpty()) { + return; + } + synchronized (mListenerLock) { + Iterator> iter = mGpsListeners.iterator(); + while (iter.hasNext()) { + WeakReference ref = iter.next(); + GpsUpdateListener listener = ref.get(); + if (listener != null) { + notifySingleListener(listener, currentGpsPos); + } else { + iter.remove(); // 清理已被GC回收的监听者,避免内存泄漏 + } + } + } + } + + /** + * 通知单个GPS监听者位置更新(主线程安全,Java 7 匿名Runnable) + * @param listener 单个监听者 + * @param currentGpsPos 当前GPS位置 + */ + private void notifySingleListener(final GpsUpdateListener listener, final PositionModel currentGpsPos) { + if (Looper.myLooper() == Looper.getMainLooper()) { + listener.onGpsPositionUpdated(currentGpsPos); + } else { + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + listener.onGpsPositionUpdated(currentGpsPos); + } + }); + } + } + + /** + * 通知所有GPS监听者状态变化(如GPS开启/关闭、信号弱等,Java 7 迭代器) + * @param status GPS状态描述文本 + */ + private void notifyAllGpsStatusListeners(final String status) { + if (status == null || mGpsListeners.isEmpty()) { + return; + } + synchronized (mListenerLock) { + Iterator> iter = mGpsListeners.iterator(); + while (iter.hasNext()) { + WeakReference ref = iter.next(); + final GpsUpdateListener listener = ref.get(); + if (listener != null) { + // 主线程切换,避免监听者在子线程处理UI + if (Looper.myLooper() == Looper.getMainLooper()) { + listener.onGpsStatusChanged(status); + } else { + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + listener.onGpsStatusChanged(status); + } + }); + } + } else { + iter.remove(); // 清理无效弱引用 + } + } + } + } + + /** + * 注册GPS更新监听(Java 7 弱引用添加,避免监听者内存泄漏) + * @param listener GPS更新监听者(如Activity/Adapter) + */ + public void registerGpsUpdateListener(GpsUpdateListener listener) { + if (listener == null) { + LogUtils.w(TAG, "registerGpsUpdateListener:监听者为空"); + return; + } + synchronized (mListenerLock) { + mGpsListeners.add(new WeakReference(listener)); + LogUtils.d(TAG, "GPS监听注册成功,当前数量:" + mGpsListeners.size()); + // 注册后立即推送当前GPS位置(避免监听者错过初始数据) + if (_mCurrentGpsPosition != null) { + notifySingleListener(listener, _mCurrentGpsPosition); + } + } + } + + /** + * 反注册GPS更新监听(Java 7 迭代器清理,避免内存泄漏) + * @param listener 待反注册的GPS监听者 + */ + public void unregisterGpsUpdateListener(GpsUpdateListener listener) { + if (listener == null) { + LogUtils.w(TAG, "unregisterGpsUpdateListener:监听者为空"); + return; + } + synchronized (mListenerLock) { + Iterator> iter = mGpsListeners.iterator(); + while (iter.hasNext()) { + WeakReference ref = iter.next(); + // 匹配目标监听者或已回收的弱引用,直接移除 + if (ref.get() == listener || ref.get() == null) { + iter.remove(); + LogUtils.d(TAG, "GPS监听反注册成功,当前数量:" + mGpsListeners.size()); + break; + } + } + } + } + + // 补全 LocalBinder 定义(与 LocationActivity 中的 LocalBinder 保持一致) + // 注意:若 LocationActivity 已定义 LocalBinder,此处可删除;建议统一在 MainService 中定义,避免重复 + public class LocalBinder extends android.os.Binder { + private MainService mService; + + public LocalBinder(MainService service) { + this.mService = service; + } + + public MainService getService() { + return mService; + } + } + +} + + diff --git a/positions/src/main/java/cc/winboll/studio/positions/utils/AppConfigsUtil.java b/positions/src/main/java/cc/winboll/studio/positions/utils/AppConfigsUtil.java new file mode 100644 index 0000000..3110de7 --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/utils/AppConfigsUtil.java @@ -0,0 +1,68 @@ +package cc.winboll.studio.positions.utils; +import android.content.Context; +import cc.winboll.studio.positions.models.AppConfigsModel; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/10/01 12:15 + * @Describe AppConfigsUtils + */ +public class AppConfigsUtil { + + public static final String TAG = "AppConfigsUtils"; + + // 1. 私有静态成员变量(volatile 防止指令重排,确保实例初始化完全) + private static volatile AppConfigsUtil sInstance; + Context mContext; + AppConfigsModel mAppConfigsModel; + + // 2. 私有构造方法(禁止外部 new 实例) + private AppConfigsUtil(Context context) { + // 可选:防止通过反射创建实例(增强单例安全性) + if (sInstance != null) { + throw new RuntimeException("禁止通过反射创建单例实例"); + } + mContext = context; + loadConfigs(); + } + + // 3. 公开静态方法(双重校验锁,获取唯一实例) + public static AppConfigsUtil getInstance(Context context) { + // 第一次校验:无锁,快速判断实例是否存在(提升性能) + if (sInstance == null) { + // 加锁:确保同一时间只有一个线程进入初始化逻辑 + synchronized (AppConfigsUtil.class) { + // 第二次校验:防止多线程并发时重复创建实例(线程安全) + if (sInstance == null) { + sInstance = new AppConfigsUtil(context); + } + } + } + return sInstance; + } + + public void loadConfigs() { + mAppConfigsModel = AppConfigsModel.loadBean(mContext, AppConfigsModel.class); + } + + public void saveConfigs() { + AppConfigsModel.saveBean(mContext, mAppConfigsModel); + } + + public boolean isEnableMainService(boolean isReloadConfigs) { + if (isReloadConfigs) { + loadConfigs(); + } + + return (mAppConfigsModel == null) ?false: mAppConfigsModel.isEnableMainService(); + } + + public void setIsEnableMainService(boolean isEnableMainService) { + if(mAppConfigsModel == null) { + mAppConfigsModel = new AppConfigsModel(); + } + mAppConfigsModel.setIsEnableMainService(isEnableMainService); + saveConfigs(); + } +} + diff --git a/positions/src/main/java/cc/winboll/studio/positions/utils/DensityUtils.java b/positions/src/main/java/cc/winboll/studio/positions/utils/DensityUtils.java new file mode 100644 index 0000000..828d376 --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/utils/DensityUtils.java @@ -0,0 +1,45 @@ +package cc.winboll.studio.positions.utils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/10/22 01:16 + * @Describe DensityUtils + */ + +import android.content.Context; +import android.util.DisplayMetrics; +import android.view.WindowManager; + +/** + * 屏幕密度工具类(dp/sp 转 px、获取屏幕尺寸) + */ +public class DensityUtils { + public static final String TAG = "DensityUtils"; + + /** + * dp 转 px(根据屏幕密度) + */ + public static int dp2px(Context context, float dpValue) { + final float scale = context.getResources().getDisplayMetrics().density; + return (int) (dpValue * scale + 0.5f); + } + + /** + * sp 转 px(根据文字缩放比例) + */ + public static int sp2px(Context context, float spValue) { + final float scale = context.getResources().getDisplayMetrics().scaledDensity; + return (int) (spValue * scale + 0.5f); + } + + /** + * 获取屏幕宽度(像素) + */ + public static int getScreenWidth(Context context) { + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + DisplayMetrics dm = new DisplayMetrics(); + wm.getDefaultDisplay().getMetrics(dm); + return dm.widthPixels; + } +} + diff --git a/positions/src/main/java/cc/winboll/studio/positions/utils/DistanceCalculatorUtil.java b/positions/src/main/java/cc/winboll/studio/positions/utils/DistanceCalculatorUtil.java new file mode 100644 index 0000000..25c65da --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/utils/DistanceCalculatorUtil.java @@ -0,0 +1,246 @@ +package cc.winboll.studio.positions.utils; +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.text.TextUtils; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.positions.models.PositionModel; +import cc.winboll.studio.positions.models.PositionTaskModel; +import cc.winboll.studio.positions.services.MainService; +import java.util.ArrayList; +import java.util.Iterator; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/10/27 18:40 + * @Describe 距离计算工具集(单例模式) + */ +public class DistanceCalculatorUtil { + + public static final String TAG = "DistanceCalculatorUtil"; + + // 1. 私有静态 volatile 实例:保证多线程下实例可见性,避免指令重排序导致的空指针 + private static volatile DistanceCalculatorUtil sInstance; + Context mContext; + ArrayList mPositionList; // 位置数据列表 + ArrayList mAllTasks;// 任务数据列表 + PositionModel mGpsPositionCalculated; + long mLastCalculatedTime = 0; + long mMinCalculatedTimeBettween = 30000; // GPS数据更新时,两次计算之间的最小时间间隔 + double mMinjumpDistance = 10.0f; // GPS数据更新时,能跳跃距离最小有效值,达到有效值时,两次计算之间的最小时间间隔阀值将被忽略。 + + // 2. 私有构造器:禁止外部通过 new 关键字创建实例,确保单例唯一性 + private DistanceCalculatorUtil(Context context) { + // 可选:初始化工具类依赖的资源(如配置参数、缓存等) + mContext = context; + + LogUtils.d(TAG, "DistanceCalculatorUtil 单例实例初始化"); + } + + // 3. 公开静态方法:双重校验锁获取单例,兼顾线程安全与性能 + public static DistanceCalculatorUtil getInstance(Context context) { + // 第一重校验:避免已创建实例时的频繁加锁,提升性能 + if (sInstance == null) { + // 加锁:确保多线程下仅一个线程进入实例创建逻辑 + synchronized (DistanceCalculatorUtil.class) { + // 第二重校验:防止多线程并发时重复创建实例 + if (sInstance == null) { + sInstance = new DistanceCalculatorUtil(context); + } + } + } + return sInstance; + } + + // ---------------------- 以下可补充距离计算相关工具方法 ---------------------- + /** + * 示例:Haversine 公式计算两点间距离(单位:米) + * @param lat1 第一点纬度 + * @param lon1 第一点经度 + * @param lat2 第二点纬度 + * @param lon2 第二点经度 + * @return 两点间直线距离(米),计算失败返回 -1 + */ + public static double calculateHaversineDistance(double lat1, double lon1, double lat2, double lon2) { + try { + final double EARTH_RADIUS = 6371000; // 地球半径(米) + // 角度转弧度 + double latDiff = Math.toRadians(lat2 - lat1); + double lonDiff = Math.toRadians(lon2 - lon1); + + // Haversine 核心公式 + double a = Math.sin(latDiff / 2) * Math.sin(latDiff / 2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) + * Math.sin(lonDiff / 2) * Math.sin(lonDiff / 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return EARTH_RADIUS * c; // 返回距离(米) + } catch (Exception e) { + LogUtils.d(TAG, "Haversine 距离计算失败:" + e.getMessage()); + return -1; // 标记计算失败 + } + } + + + /** + * 计算两点间距离(Haversine公式,纯Java 7 基础API,无数学工具类依赖) + * @param gpsLat GPS纬度 + * @param gpsLon GPS经度 + * @param posLat 目标位置纬度 + * @param posLon 目标位置经度 + * @return 两点间距离(单位:米) + */ + /*private double calculateHaversineDistance(double gpsLat, double gpsLon, double posLat, double posLon) { + final double EARTH_RADIUS = 6371000; // 地球半径(米) + double latDiff = Math.toRadians(posLat - gpsLat); + double lonDiff = Math.toRadians(posLon - gpsLon); + // Haversine公式核心计算(Java 7 基础数学方法) + double a = Math.sin(latDiff / 2) * Math.sin(latDiff / 2) + + Math.cos(Math.toRadians(gpsLat)) * Math.cos(Math.toRadians(posLat)) + * Math.sin(lonDiff / 2) * Math.sin(lonDiff / 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return EARTH_RADIUS * c; + }*/ + + /** + * 校验所有任务触发条件(距离达标则触发任务通知) + */ + public void checkAllTaskTriggerCondition(PositionModel currentGpsPosition) { + if (currentGpsPosition == null) { + LogUtils.d(TAG, "传入坐标参数为空,退出函数。"); + return; + } + + // 计算频率控制模块 + // + // 计算与最近一次GPS计算的时间间隔 + long nCalculatedTimeBettween = System.currentTimeMillis() - mLastCalculatedTime; + // 计算跳跃距离 + double gpsPositionCalculatedLatitude = mGpsPositionCalculated == null ?0.0f: mGpsPositionCalculated.getLatitude(); + double gpsPositionCalculatedLongitude = mGpsPositionCalculated == null ?0.0f: mGpsPositionCalculated.getLongitude(); + double jumpDistance = calculateHaversineDistance(gpsPositionCalculatedLatitude, gpsPositionCalculatedLongitude, currentGpsPosition.getLatitude(), currentGpsPosition.getLongitude()); + if (jumpDistance < mMinjumpDistance) { + LogUtils.d(TAG, String.format("checkAllTaskTriggerCondition:跳跃距离%f,小于50米。", jumpDistance)); + // 跳跃距离小于最小有效跳跃值 + if (nCalculatedTimeBettween < mMinCalculatedTimeBettween) { + //间隔小于最小时间间隔设定 + LogUtils.d(TAG, String.format("checkAllTaskTriggerCondition:与最近一次计算间隔时间%d,坐标变化忽略。", nCalculatedTimeBettween)); + return; + } + } + + if (mGpsPositionCalculated == null) { + mGpsPositionCalculated = currentGpsPosition; + LogUtils.d(TAG, "最后计算位置记录为空,现在使用新坐标为初始化。"); + } + + LogUtils.d(TAG, String.format("checkAllTaskTriggerCondition:跳跃距离%f,与上次计算间隔%d,现在启动任务数据计算。", jumpDistance, nCalculatedTimeBettween)); + + // 获取位置任务基础数据 + MainService mainService = MainService.getInstance(mContext); + mPositionList = mainService.getPositionList(); + mAllTasks = mainService.getAllTasks(); + + // 位置数据为空,跳过校验。 + if (mPositionList.isEmpty()) { + LogUtils.d(TAG, "checkAllTaskTriggerCondition:位置数据为空,跳过距离计算。"); + return; + } + + // 更新所有位置点的位置距离数据 + refreshRealPositionDistance(currentGpsPosition); + + // 任务数据为空,跳过校验。 + if (mAllTasks.isEmpty()) { + LogUtils.d(TAG, "checkAllTaskTriggerCondition:任务数据为空,跳过任务提醒检查计算。"); + return; + } + + // 迭代器遍历任务(Java 7 安全遍历,避免并发修改异常) + Iterator taskIter = mAllTasks.iterator(); + while (taskIter.hasNext()) { + PositionTaskModel task = taskIter.next(); + // 仅校验“已启用”且“绑定有效位置”的任务 + if (!task.isEnable() || TextUtils.isEmpty(task.getPositionId())) { + continue; + } + + // 查找任务绑定的位置(Java 7 迭代器遍历位置列表) + PositionModel bindPos = null; + Iterator posIter = mPositionList.iterator(); + while (posIter.hasNext()) { + PositionModel pos = posIter.next(); + if (task.getPositionId().equals(pos.getPositionId())) { + bindPos = pos; + break; + } + } + if (bindPos == null) { + LogUtils.w(TAG, "任务ID=" + task.getTaskId() + ":绑定位置不存在,跳过"); + task.setIsBingo(false); + continue; + } + + // 校验任务开始时间 + if (task.getStartTime() > System.currentTimeMillis()) { + continue; + } + + // 校验距离条件(判断是否满足任务触发阈值) + double currentDistance = bindPos.getRealPositionDistance(); + if (currentDistance < 0) { + LogUtils.w(TAG, "任务ID=" + task.getTaskId() + ":距离计算失败,跳过"); + task.setIsBingo(false); + continue; + } + + boolean isTriggered = false; + int taskDistance = task.getDiscussDistance(); + // 任务触发条件:大于/小于指定距离(Java 7 基础判断,无三元运算符嵌套) + if (task.isGreaterThan()) { + isTriggered = currentDistance > taskDistance; + } else if (task.isLessThan()) { + isTriggered = currentDistance < taskDistance; + } + + // 更新任务触发状态+发送通知(状态变化时才处理) + if (task.isBingo() != isTriggered) { + task.setIsBingo(isTriggered); + if (isTriggered) { + MainService.getInstance(mContext).sendTaskTriggerNotification(task, bindPos, currentDistance); + } + } + } + + MainService.getInstance(mContext).saveAllTasks(); // 持久化更新后的任务状态 + // 记录最后坐标更新点 + mGpsPositionCalculated = currentGpsPosition; + // 记录数据计算时间 + mLastCalculatedTime = System.currentTimeMillis(); + } + + + /** + * 强制刷新所有位置距离(GPS更新后调用,计算距离+校验任务触发条件) + */ + public void refreshRealPositionDistance(PositionModel currentGpsPosition) { + // 遍历所有位置计算距离(Java 7 增强for循环,无Stream) + for (PositionModel pos : mPositionList) { + if (pos.isEnableRealPositionDistance()) { + double distance = DistanceCalculatorUtil.calculateHaversineDistance( + currentGpsPosition.getLatitude(), + currentGpsPosition.getLongitude(), + pos.getLatitude(), + pos.getLongitude() + ); + pos.setRealPositionDistance(distance); + } else { + pos.setRealPositionDistance(-1); // 未启用距离计算,标记为无效 + } + } + + // 距离刷新后通知GPS监听者 + MainService.getInstance(mContext).notifyAllGpsListeners(currentGpsPosition); + } +} diff --git a/positions/src/main/java/cc/winboll/studio/positions/utils/NotificationUtil.java b/positions/src/main/java/cc/winboll/studio/positions/utils/NotificationUtil.java new file mode 100644 index 0000000..4cc459d --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/utils/NotificationUtil.java @@ -0,0 +1,193 @@ +package cc.winboll.studio.positions.utils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/09/30 16:09 + * @Describe NotificationUtils(适配API 30,修复系统默认铃声获取,任务通知循环响铃) + */ +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.media.RingtoneManager; // 导入RingtoneManager(关键:用于获取系统默认铃声) +import android.net.Uri; // 导入Uri(存储铃声路径) +import android.os.Build; +import androidx.core.app.NotificationCompat; +import cc.winboll.studio.positions.R; +import cc.winboll.studio.positions.activities.LocationActivity; + +/** + * 通知栏工具类: + * 1. 任务通知:铃声循环播放(适配API 30),修复系统默认铃声获取方式 + * 2. 前台服务通知:低打扰(无声无震动),符合API 30规范 + */ +public class NotificationUtil { + public static final String TAG = "NotificationUtils"; + // 任务通知常量(独立渠道,确保循环铃声配置不冲突) + private static final String TASK_NOTIFICATION_CHANNEL_ID = "task_notification_channel_01"; + private static final String TASK_NOTIFICATION_CHANNEL_NAME = "任务通知(循环铃声)"; + // 前台服务通知常量(独立渠道,低打扰) + private static final String FOREGROUND_SERVICE_CHANNEL_ID = "foreground_location_service_channel_02"; + private static final String FOREGROUND_SERVICE_CHANNEL_NAME = "位置服务"; + public static final int FOREGROUND_SERVICE_NOTIFICATION_ID = 10086; // 固定前台服务通知ID + + // ---------------------- 核心:任务通知(循环响铃+修复系统默认铃声) ---------------------- + private static int getNotificationId(String taskId) { + return taskId.hashCode() & 0xFFFFFF; // 确保通知ID唯一且非负 + } + + public static void show(Context context, String taskId, String positionId, String taskDescription) { + if (context == null || taskId == null || positionId == null) { + return; + } + + NotificationManager notificationManager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (notificationManager == null) { + return; + } + + // 1. 初始化通知渠道(配置循环铃声参数,使用修复后的铃声获取方式) + createNotificationChannel(notificationManager); + + // 2. 点击跳转Intent(携带任务/位置参数,适配API 30页面栈) + Intent jumpIntent = new Intent(context, LocationActivity.class); + jumpIntent.putExtra("EXTRA_POSITION_ID", positionId); + jumpIntent.putExtra("EXTRA_TASK_ID", taskId); + jumpIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + + // 3. PendingIntent(API 30强制加IMMUTABLE,避免安全异常) + PendingIntent pendingIntent = PendingIntent.getActivity( + context, + getNotificationId(taskId), + jumpIntent, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT + ); + + // 4. 构建通知(核心:循环响铃+修复的系统默认铃声) + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, TASK_NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) // API 30强制要求,否则通知不显示 + .setContentTitle("任务提醒") + .setContentText(taskDescription) + .setContentIntent(pendingIntent) + .setAutoCancel(true) // 点击后取消通知,停止循环响铃 + .setPriority(NotificationCompat.PRIORITY_DEFAULT) // 确保铃声能正常播放(API 30规则) + //.setVibrationPattern(new long[]{0, 300, 200, 300}) // 震动与铃声同步循环 + .setOnlyAlertOnce(false); // 重复通知也触发循环提醒 + + // 关键修复:用RingtoneManager获取系统默认通知铃声(替代废弃的NotificationManager.getDefaultUri) + Uri defaultNotificationRingtone = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION); + if (defaultNotificationRingtone != null) { + builder.setSound(defaultNotificationRingtone); // 设置系统默认铃声 + } else { + builder.setDefaults(NotificationCompat.DEFAULT_SOUND); // 极端情况:铃声Uri为空时,用默认提醒音兜底 + } + + // 5. 循环响铃核心:设置FLAG_INSISTENT(通知未取消则持续循环) + Notification notification = builder.build(); + notification.flags |= Notification.FLAG_INSISTENT; + + // 6. 显示通知(触发循环响铃) + notificationManager.notify(getNotificationId(taskId), notification); + } + + // ---------------------- 核心:创建通知渠道(修复铃声配置,适配API 30) ---------------------- + private static void createNotificationChannel(NotificationManager notificationManager) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // 1. 任务通知渠道(用修复后的方式配置系统默认铃声) + NotificationChannel taskChannel = new NotificationChannel( + TASK_NOTIFICATION_CHANNEL_ID, + TASK_NOTIFICATION_CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT // 重要性≥DEFAULT,否则铃声不响(API 30规则) + ); + taskChannel.setDescription("任务提醒通知,铃声循环播放至点击取消"); + taskChannel.enableVibration(true); + taskChannel.setVibrationPattern(new long[]{0, 300, 200, 300}); + taskChannel.setAllowBubbles(false); // 避免气泡打断循环铃声 + + // 关键修复:渠道铃声也用RingtoneManager获取(与通知Builder保持一致,确保铃声统一) + Uri channelDefaultRingtone = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION); + taskChannel.setSound(channelDefaultRingtone, null); // 绑定系统默认铃声到渠道 + + // 2. 前台服务渠道(低打扰,无声无震动) + NotificationChannel foregroundChannel = new NotificationChannel( + FOREGROUND_SERVICE_CHANNEL_ID, + FOREGROUND_SERVICE_CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW + ); + foregroundChannel.setDescription("位置服务运行中,无声音/震动提醒"); + foregroundChannel.enableVibration(false); + foregroundChannel.setSound(null, null); // 明确关闭铃声 + foregroundChannel.setShowBadge(false); + + // 注册渠道(API 30覆盖旧配置,确保修复后的铃声生效) + notificationManager.createNotificationChannel(taskChannel); + notificationManager.createNotificationChannel(foregroundChannel); + } + } + + // ---------------------- 原有功能:取消任务通知(停止循环响铃) ---------------------- + public static void cancel(Context context, String taskId) { + if (context == null || taskId == null) { + return; + } + NotificationManager notificationManager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (notificationManager != null) { + notificationManager.cancel(getNotificationId(taskId)); // 取消后循环铃声自动停止 + } + } + + // ---------------------- 前台服务通知(适配API 30,无声无震动) ---------------------- + public static Notification createForegroundServiceNotification(Context context, String serviceStatus) { + if (context == null) { + throw new IllegalArgumentException("Context cannot be null for foreground service notification"); + } + + // 确保前台服务渠道已创建(低打扰配置) + NotificationManager notificationManager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (notificationManager != null) { + createNotificationChannel(notificationManager); + } + + // 点击跳转Intent + Intent jumpIntent = new Intent(context, LocationActivity.class); + jumpIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + + // PendingIntent(API 30必加IMMUTABLE) + PendingIntent pendingIntent = PendingIntent.getActivity( + context, + FOREGROUND_SERVICE_NOTIFICATION_ID, + jumpIntent, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT + ); + + // 构建前台服务通知(无声无震动,符合低打扰) + return new NotificationCompat.Builder(context, FOREGROUND_SERVICE_CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle("位置服务运行中") + .setContentText(serviceStatus) + .setContentIntent(pendingIntent) + .setOngoing(true) // 不可手动清除(前台服务规范) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setDefaults(0) // 禁用所有默认提醒(无声无震动) + .build(); + } + + // ---------------------- 前台服务通知状态更新(适配API 30) ---------------------- + public static void updateForegroundServiceStatus(Context context, String newServiceStatus) { + if (context == null) { + return; + } + Notification updatedNotification = createForegroundServiceNotification(context, newServiceStatus); + NotificationManager notificationManager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (notificationManager != null) { + notificationManager.notify(FOREGROUND_SERVICE_NOTIFICATION_ID, updatedNotification); + } + } +} + diff --git a/positions/src/main/java/cc/winboll/studio/positions/utils/ServiceUtil.java b/positions/src/main/java/cc/winboll/studio/positions/utils/ServiceUtil.java new file mode 100644 index 0000000..a9fa86f --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/utils/ServiceUtil.java @@ -0,0 +1,69 @@ +package cc.winboll.studio.positions.utils; + +/** + * @Author ZhanGSKen + * @Date 2024/07/19 14:30:57 + * @Describe 应用服务组件工具类 + */ +import android.app.ActivityManager; +import android.content.Context; +import android.content.Intent; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.positions.services.AssistantService; +import cc.winboll.studio.positions.services.MainService; +import java.util.List; + +public class ServiceUtil { + public final static String TAG = "ServiceUtil"; + + public static boolean isServiceAlive(Context context, String szServiceName) { + // 获取Activity管理者对象 + ActivityManager manager = (ActivityManager) context + .getSystemService(Context.ACTIVITY_SERVICE); + // 获取正在运行的服务(此处设置最多取1000个) + List runningServices = manager + .getRunningServices(1000); + if (runningServices.size() <= 0) { + return false; + } + // 遍历,若存在名字和传入的serviceName的一致则说明存在 + for (ActivityManager.RunningServiceInfo runningServiceInfo : runningServices) { + if (runningServiceInfo.service.getClassName().equals(szServiceName)) { + return true; + } + } + + return false; + } + + public static void stopAutoService(Context context) { + AppConfigsUtil appConfigsUtil = AppConfigsUtil.getInstance(context); + appConfigsUtil.setIsEnableMainService(false); + appConfigsUtil.saveConfigs(); + // 关闭并设置主服务 + Intent intent1 = new Intent(context, MainService.class); + intent1.putExtra(MainService.EXTRA_IS_SETTING_TO_ENABLE, false); + context.stopService(intent1); // 先停止旧服务 + context.startService(intent1); // 传入新的启动标志位,返回给系统 + // 关闭并设置主服务守护进程 + Intent intent2 = new Intent(context, AssistantService.class); + intent2.putExtra(AssistantService.EXTRA_IS_SETTING_TO_ENABLE, false); + context.stopService(intent2); // 先停止旧服务 + context.startService(intent2); // 传入新的启动标志位,返回给系统 + // 再次关闭所有服务 + context.stopService(intent1); + context.stopService(intent2); + + LogUtils.d(TAG, "stopAutoService"); + } + + public static void startAutoService(Context context) { + AppConfigsUtil appConfigsUtil = AppConfigsUtil.getInstance(context); + appConfigsUtil.setIsEnableMainService(true); + appConfigsUtil.saveConfigs(); + Intent intent = new Intent(context, MainService.class); + intent.putExtra(MainService.EXTRA_IS_SETTING_TO_ENABLE, true); + context.startService(intent); + LogUtils.d(TAG, "startAutoService"); + } +} diff --git a/positions/src/main/java/cc/winboll/studio/positions/views/DateTimePickerPopup.java b/positions/src/main/java/cc/winboll/studio/positions/views/DateTimePickerPopup.java new file mode 100644 index 0000000..692bad8 --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/views/DateTimePickerPopup.java @@ -0,0 +1,254 @@ +package cc.winboll.studio.positions.views; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/10/22 02:15 + * @Describe DateTimePickerPopup + */ +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.NumberPicker; +import android.widget.PopupWindow; +import java.util.Calendar; +import cc.winboll.studio.positions.R; +import cc.winboll.studio.positions.utils.DensityUtils; + +/** + * 日期时间选择弹窗(竖直滚动行:年、月、日、时、分) + */ +public class DateTimePickerPopup extends PopupWindow { + public static final String TAG = "DateTimePickerPopup"; + + private Context mContext; + private NumberPicker mPickerYear; + private NumberPicker mPickerMonth; + private NumberPicker mPickerDay; + private NumberPicker mPickerHour; + private NumberPicker mPickerMinute; + private Button mBtnCancel; + private Button mBtnConfirm; + private OnDateTimeSelectedListener mListener; + + // 时间范围默认值 + private int mMinYear = 2000; + private int mMaxYear = Calendar.getInstance().get(Calendar.YEAR) + 10; + private int mMinMonth = 1; + private int mMaxMonth = 12; + private int mMinDay = 1; + private int mMaxDay = 31; + private int mMinHour = 0; + private int mMaxHour = 23; + private int mMinMinute = 0; + private int mMaxMinute = 59; + + // 默认选中时间 + private int mDefaultYear; + private int mDefaultMonth; + private int mDefaultDay; + private int mDefaultHour; + private int mDefaultMinute; + + /** + * 日期时间选择回调 + */ + public interface OnDateTimeSelectedListener { + void onDateTimeSelected(int year, int month, int day, int hour, int minute); + void onCancel(); + } + + /** + * Builder 模式 + */ + public static class Builder { + private Context mContext; + private DateTimePickerPopup mPopup; + + public Builder(Context context) { + this.mContext = context; + mPopup = new DateTimePickerPopup(context); + Calendar calendar = Calendar.getInstance(); + mPopup.mDefaultYear = calendar.get(Calendar.YEAR); + mPopup.mDefaultMonth = calendar.get(Calendar.MONTH) + 1; + mPopup.mDefaultDay = calendar.get(Calendar.DAY_OF_MONTH); + mPopup.mDefaultHour = calendar.get(Calendar.HOUR_OF_DAY); + mPopup.mDefaultMinute = calendar.get(Calendar.MINUTE); + } + + public Builder setDateTimeRange(int minYear, int maxYear, int minMonth, int maxMonth, + int minDay, int maxDay, int minHour, int maxHour, + int minMinute, int maxMinute) { + mPopup.mMinYear = minYear; + mPopup.mMaxYear = maxYear; + mPopup.mMinMonth = minMonth; + mPopup.mMaxMonth = maxMonth; + mPopup.mMinDay = minDay; + mPopup.mMaxDay = maxDay; + mPopup.mMinHour = minHour; + mPopup.mMaxHour = maxHour; + mPopup.mMinMinute = minMinute; + mPopup.mMaxMinute = maxMinute; + return this; + } + + public Builder setDefaultDateTime(int year, int month, int day, int hour, int minute) { + mPopup.mDefaultYear = year; + mPopup.mDefaultMonth = month; + mPopup.mDefaultDay = day; + mPopup.mDefaultHour = hour; + mPopup.mDefaultMinute = minute; + return this; + } + + public Builder setOnDateTimeSelectedListener(OnDateTimeSelectedListener listener) { + mPopup.mListener = listener; + return this; + } + + public DateTimePickerPopup build() { + mPopup.initView(); + mPopup.initPickers(); + mPopup.bindButtonClick(); + mPopup.setPopupStyle(); + return mPopup; + } + } + + private DateTimePickerPopup(Context context) { + super(context); + this.mContext = context; + } + + private void initView() { + LayoutInflater inflater = LayoutInflater.from(mContext); + View rootView = inflater.inflate(R.layout.dialog_date_time_picker, null, false); + setContentView(rootView); + + mPickerYear = (NumberPicker) rootView.findViewById(R.id.picker_year); + mPickerMonth = (NumberPicker) rootView.findViewById(R.id.picker_month); + mPickerDay = (NumberPicker) rootView.findViewById(R.id.picker_day); + mPickerHour = (NumberPicker) rootView.findViewById(R.id.picker_hour); + mPickerMinute = (NumberPicker) rootView.findViewById(R.id.picker_minute); + mBtnCancel = (Button) rootView.findViewById(R.id.btn_cancel); + mBtnConfirm = (Button) rootView.findViewById(R.id.btn_confirm); + } + + private void initPickers() { + // 初始化年选择器 + mPickerYear.setMinValue(mMinYear); + mPickerYear.setMaxValue(mMaxYear); + mPickerYear.setValue(mDefaultYear); + mPickerYear.setWrapSelectorWheel(false); + + // 初始化月选择器 + mPickerMonth.setMinValue(mMinMonth); + mPickerMonth.setMaxValue(mMaxMonth); + mPickerMonth.setValue(mDefaultMonth); + mPickerMonth.setWrapSelectorWheel(false); + + // 初始化日选择器(根据年月动态调整范围) + updateDayRange(mDefaultYear, mDefaultMonth); + mPickerDay.setValue(mDefaultDay); + mPickerDay.setWrapSelectorWheel(false); + + // 初始化时选择器 + mPickerHour.setMinValue(mMinHour); + mPickerHour.setMaxValue(mMaxHour); + mPickerHour.setValue(mDefaultHour); + mPickerHour.setWrapSelectorWheel(false); + + // 初始化分选择器 + mPickerMinute.setMinValue(mMinMinute); + mPickerMinute.setMaxValue(mMaxMinute); + mPickerMinute.setValue(mDefaultMinute); + mPickerMinute.setWrapSelectorWheel(false); + + // 年月变化时更新日范围(Java 7 匿名内部类) + mPickerYear.setOnValueChangedListener(new NumberPicker.OnValueChangeListener() { + @Override + public void onValueChange(NumberPicker picker, int oldVal, int newVal) { + updateDayRange(newVal, mPickerMonth.getValue()); + } + }); + + mPickerMonth.setOnValueChangedListener(new NumberPicker.OnValueChangeListener() { + @Override + public void onValueChange(NumberPicker picker, int oldVal, int newVal) { + updateDayRange(mPickerYear.getValue(), newVal); + } + }); + } + + private void updateDayRange(int year, int month) { + int maxDay; + switch (month) { + case 2: + if ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0) { + maxDay = 29; + } else { + maxDay = 28; + } + break; + case 4: + case 6: + case 9: + case 11: + maxDay = 30; + break; + default: + maxDay = 31; + } + mPickerDay.setMaxValue(maxDay); + if (mPickerDay.getValue() > maxDay) { + mPickerDay.setValue(maxDay); + } + } + + private void bindButtonClick() { + // 取消按钮(Java 7 匿名内部类) + mBtnCancel.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + dismiss(); + if (mListener != null) { + mListener.onCancel(); + } + } + }); + + // 确认按钮(Java 7 匿名内部类) + mBtnConfirm.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + int year = mPickerYear.getValue(); + int month = mPickerMonth.getValue(); + int day = mPickerDay.getValue(); + int hour = mPickerHour.getValue(); + int minute = mPickerMinute.getValue(); + + if (mListener != null) { + mListener.onDateTimeSelected(year, month, day, hour, minute); + } + dismiss(); + } + }); + } + + private void setPopupStyle() { + int width = (int) (DensityUtils.getScreenWidth(mContext) * 0.85f); + int height = ViewGroup.LayoutParams.WRAP_CONTENT; + + setWidth(width); + setHeight(height); + setFocusable(true); + setOutsideTouchable(true); + setBackgroundDrawable(mContext.getResources().getDrawable(R.drawable.bg_dialog_round)); + setAnimationStyle(R.style.PopupDateTimePickerAnim); + } + + public void showAsDropDown(View anchorView) { + super.showAsDropDown(anchorView, 0, DensityUtils.dp2px(mContext, 10)); + } +} diff --git a/positions/src/main/java/cc/winboll/studio/positions/views/PositionTaskListView.java b/positions/src/main/java/cc/winboll/studio/positions/views/PositionTaskListView.java new file mode 100644 index 0000000..e597ebe --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/views/PositionTaskListView.java @@ -0,0 +1,624 @@ +package cc.winboll.studio.positions.views; + +/** + * @Describe 位置任务列表视图(适配MainService唯一数据源+同步任务状态+支持简单/编辑模式) + * @Author 豆包&ZhanGSKen + * @CreateTime 2025-09-30 08:09:00 + * @EditTime 2026-03-31 19:00:00 + */ + +import android.content.Context; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.CompoundButton; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.RadioGroup; +import android.widget.TextView; +import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import cc.winboll.studio.libaes.dialogs.YesNoAlertDialog; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.positions.R; +import cc.winboll.studio.positions.models.PositionTaskModel; +import cc.winboll.studio.positions.services.MainService; +import com.jzxiang.pickerview.TimePickerDialog; +import com.jzxiang.pickerview.listener.OnDateSetListener; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +public class PositionTaskListView extends LinearLayout { + + // 视图模式常量 + public static final int VIEW_MODE_SIMPLE = 1; + public static final int VIEW_MODE_EDIT = 2; + + private static final String TAG = "PositionTaskListView"; + + // 核心成员变量 + private String mBindPositionId; + private MainService mMainService; + private int mCurrentViewMode; + private TaskListAdapter mTaskAdapter; + private RecyclerView mRvTasks; + + // 任务修改回调接口 + public interface OnTaskUpdatedListener { + void onTaskUpdated(String positionId, ArrayList updatedTasks); + } + + private OnTaskUpdatedListener mOnTaskUpdatedListener; + + // ---------------------- 构造函数 ---------------------- + public PositionTaskListView(Context context) { + super(context); + LogUtils.d(TAG, "PositionTaskListView 构造函数 1 参数: context=" + context); + initView(context); + } + + public PositionTaskListView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + LogUtils.d(TAG, "PositionTaskListView 构造函数 2 参数: context=" + context + ", attrs=" + attrs); + initView(context); + } + + public PositionTaskListView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + LogUtils.d(TAG, "PositionTaskListView 构造函数 3 参数: context=" + context + ", attrs=" + attrs + ", defStyleAttr=" + defStyleAttr); + initView(context); + } + + // ---------------------- 初始化视图 ---------------------- + private void initView(Context context) { + LogUtils.d(TAG, "initView 开始执行 参数: context=" + context); + setOrientation(VERTICAL); + LayoutInflater.from(context).inflate(R.layout.view_position_task_list, this, true); + + mRvTasks = (RecyclerView) findViewById(R.id.rv_position_tasks); + mRvTasks.setLayoutManager(new LinearLayoutManager(context)); + + mTaskAdapter = new TaskListAdapter(new ArrayList()); + mRvTasks.setAdapter(mTaskAdapter); + + mCurrentViewMode = VIEW_MODE_SIMPLE; + LogUtils.d(TAG, "initView 执行完成,视图初始化完毕"); + } + + // ---------------------- 对外API ---------------------- + public void init(MainService mainService, String positionId) { + LogUtils.d(TAG, "init 开始执行 参数: mainService=" + mainService + ", positionId=" + positionId); + if (mainService == null) { + LogUtils.e(TAG, "init 失败:MainService 为空"); + showToast("任务列表初始化失败:服务未就绪"); + return; + } + if (positionId == null || positionId.trim().isEmpty()) { + LogUtils.e(TAG, "init 失败:positionId 为空"); + showToast("任务列表初始化失败:未关联位置"); + return; + } + + mMainService = mainService; + mBindPositionId = positionId; + LogUtils.d(TAG, "init 绑定成功:positionId=" + positionId); + + syncTasksFromMainService(); + LogUtils.d(TAG, "init 执行完成"); + } + + public void syncTasksFromMainService() { + LogUtils.d(TAG, "syncTasksFromMainService 开始执行"); + if (mMainService == null || mBindPositionId == null || mBindPositionId.trim().isEmpty()) { + LogUtils.w(TAG, "syncTasksFromMainService 失败:服务或位置ID无效"); + return; + } + + try { + ArrayList allServiceTasks = mMainService.getAllTasks(); + LogUtils.d(TAG, "syncTasksFromMainService 从服务获取任务总数: " + (allServiceTasks == null ? 0 : allServiceTasks.size())); + + ArrayList currentPosTasks = new ArrayList<>(); + if (allServiceTasks != null && !allServiceTasks.isEmpty()) { + for (PositionTaskModel task : allServiceTasks) { + if (isTaskMatchedWithPosition(task)) { + currentPosTasks.add(task); + } + } + } + LogUtils.d(TAG, "syncTasksFromMainService 筛选后当前位置任务数: " + currentPosTasks.size()); + + mTaskAdapter.updateData(currentPosTasks); + LogUtils.d(TAG, "syncTasksFromMainService 执行完成,Adapter已刷新"); + + } catch (Exception e) { + LogUtils.e(TAG, "syncTasksFromMainService 异常: " + e.getMessage(), e); + showToast("任务同步失败,请重试"); + } + } + + public void setViewStatus(int viewMode) { + LogUtils.d(TAG, "setViewStatus 参数: viewMode=" + viewMode); + if (viewMode != VIEW_MODE_SIMPLE && viewMode != VIEW_MODE_EDIT) { + LogUtils.w(TAG, "setViewStatus 无效模式"); + return; + } + mCurrentViewMode = viewMode; + mTaskAdapter.notifyDataSetChanged(); + LogUtils.d(TAG, "setViewStatus 切换完成: " + (viewMode == VIEW_MODE_SIMPLE ? "简单模式" : "编辑模式")); + } + + public void setOnTaskUpdatedListener(OnTaskUpdatedListener listener) { + LogUtils.d(TAG, "setOnTaskUpdatedListener 参数: listener=" + listener); + mOnTaskUpdatedListener = listener; + } + + public ArrayList getCurrentPosTasks() { + LogUtils.d(TAG, "getCurrentPosTasks 被调用"); + return new ArrayList<>(mTaskAdapter.getAdapterData()); + } + + public void clearData() { + LogUtils.d(TAG, "clearData 开始执行"); + mTaskAdapter.updateData(new ArrayList()); + mMainService = null; + mBindPositionId = null; + mCurrentViewMode = VIEW_MODE_SIMPLE; + LogUtils.d(TAG, "clearData 执行完成,数据已清空"); + } + + public void triggerTaskSync() { + LogUtils.d(TAG, "triggerTaskSync 主动触发同步"); + syncTasksFromMainService(); + if (mOnTaskUpdatedListener != null && mBindPositionId != null) { + mOnTaskUpdatedListener.onTaskUpdated(mBindPositionId, getCurrentPosTasks()); + } + } + + // ---------------------- 内部工具 ---------------------- + private boolean isTaskMatchedWithPosition(PositionTaskModel task) { + if (task == null || mBindPositionId == null) { + return false; + } + return mBindPositionId.equals(task.getPositionId()); + } + + private void showToast(String content) { + if (getContext() == null) return; + Toast.makeText(getContext(), content, Toast.LENGTH_SHORT).show(); + } + + private String genSelectedTimeText(long timeMillis) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()); + return sdf.format(new Date(timeMillis)); + } + + // ---------------------- 外部新增任务 ---------------------- + public void addNewTask(PositionTaskModel newTask) { + LogUtils.d(TAG, "addNewTask 参数: newTask=" + newTask); + if (mMainService == null) { + LogUtils.e(TAG, "addNewTask 失败:MainService为空"); + showToast("新增任务失败:服务未就绪"); + return; + } + if (newTask == null) { + LogUtils.e(TAG, "addNewTask 失败:任务为空"); + showToast("新增任务失败:任务数据为空"); + return; + } + if (mBindPositionId == null || mBindPositionId.trim().isEmpty()) { + LogUtils.e(TAG, "addNewTask 失败:未绑定位置"); + showToast("新增任务失败:未关联位置"); + return; + } + + try { + newTask.setPositionId(mBindPositionId); + mMainService.addTask(newTask); + LogUtils.d(TAG, "addNewTask 服务添加成功 taskId=" + newTask.getTaskId()); + + syncTasksFromMainService(); + if (mOnTaskUpdatedListener != null) { + mOnTaskUpdatedListener.onTaskUpdated(mBindPositionId, getCurrentPosTasks()); + } + showToast("新增任务成功"); + + } catch (Exception e) { + LogUtils.e(TAG, "addNewTask 异常: " + e.getMessage(), e); + showToast("新增失败,请重试"); + syncTasksFromMainService(); + } + } + + // ---------------------- Adapter ---------------------- + private class TaskListAdapter extends RecyclerView.Adapter { + + private List mAdapterData; + + public TaskListAdapter(List data) { + LogUtils.d(TAG, "TaskListAdapter 构造 参数 data.size=" + data.size()); + mAdapterData = new ArrayList<>(data); + } + + public void updateData(List newData) { + LogUtils.d(TAG, "updateData 参数 newData.size=" + (newData == null ? 0 : newData.size())); + if (newData == null) { + mAdapterData.clear(); + } else { + mAdapterData = new ArrayList<>(newData); + } + notifyDataSetChanged(); + } + + public List getAdapterData() { + return new ArrayList<>(mAdapterData); + } + + @Override + public int getItemCount() { + return mAdapterData.isEmpty() ? 1 : mAdapterData.size(); + } + + @Override + public int getItemViewType(int position) { + if (mAdapterData.isEmpty()) { + return 0; + } else { + return mCurrentViewMode; + } + } + + @NonNull + @Override + public TaskViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + Context context = parent.getContext(); + LayoutInflater inflater = LayoutInflater.from(context); + + if (viewType == 0) { + View emptyView = inflater.inflate(R.layout.item_task_empty, parent, false); + return new EmptyViewHolder(emptyView); + } else if (viewType == VIEW_MODE_SIMPLE) { + View simpleView = inflater.inflate(R.layout.item_position_task_simple, parent, false); + return new SimpleTaskViewHolder(simpleView); + } else { + View editView = inflater.inflate(R.layout.item_task_content, parent, false); + return new TaskContentViewHolder(editView); + } + } + + @Override + public void onBindViewHolder(@NonNull TaskViewHolder holder, int position) { + if (holder instanceof EmptyViewHolder) { + EmptyViewHolder emptyHolder = (EmptyViewHolder) holder; + TextView tv = emptyHolder.itemView.findViewById(R.id.tv_task_empty_tip); + tv.setText(mCurrentViewMode == VIEW_MODE_EDIT + ? "暂无任务,点击\"添加新任务\"创建" + : "暂无启用的任务"); + return; + } + + if (position >= mAdapterData.size()) { + LogUtils.w(TAG, "onBindViewHolder 越界 position=" + position); + return; + } + + final PositionTaskModel task = mAdapterData.get(position); + if (task == null) { + LogUtils.w(TAG, "onBindViewHolder task为空 position=" + position); + return; + } + + if (holder instanceof SimpleTaskViewHolder) { + SimpleTaskViewHolder h = (SimpleTaskViewHolder) holder; + String desc = task.getTaskDescription() == null ? "未设置描述" : task.getTaskDescription(); + h.tvSimpleTaskDesc.setText(String.format("任务:%s", desc)); + h.tvStartTime.setText(genSelectedTimeText(task.getStartTime())); + String cond = task.isGreaterThan() ? "大于(◎)" : "小于(•)"; + h.tvSimpleDistanceCond.setText(String.format("条件:距离 %s %d 米", cond, task.getDiscussDistance())); + h.tvSimpleIsEnable.setText(task.isEnable() ? "状态:已启用" : "状态:已禁用"); + h.tvSimpleIsEnable.setTextColor(task.isEnable() + ? getContext().getResources().getColor(R.color.colorEnableGreen) + : getContext().getResources().getColor(R.color.colorGrayText)); + h.vBingoDot.setVisibility(task.isBingo() ? View.VISIBLE : View.GONE); + + } else if (holder instanceof TaskContentViewHolder) { + TaskContentViewHolder h = (TaskContentViewHolder) holder; + bindEditModeTask(h, task, position); + } + } + + private void bindEditModeTask(final TaskContentViewHolder holder, final PositionTaskModel task, final int position) { + LogUtils.d(TAG, "bindEditModeTask position=" + position + " taskId=" + task.getTaskId()); + + String desc = task.getTaskDescription() == null ? "未设置描述" : task.getTaskDescription(); + holder.tvTaskDesc.setText(String.format("任务:%s", desc)); + String cond = task.isGreaterThan() ? "大于(◎)" : "小于(•)"; + holder.tvTaskDistance.setText(String.format("条件:%s %d 米", cond, task.getDiscussDistance())); + holder.tvStartTime.setText(genSelectedTimeText(task.getStartTime())); + + holder.cbTaskEnable.setOnCheckedChangeListener(null); + holder.cbTaskEnable.setChecked(task.isEnable()); + holder.cbTaskEnable.setEnabled(mCurrentViewMode == VIEW_MODE_EDIT); + + holder.btnEditTask.setVisibility(View.VISIBLE); + holder.btnDeleteTask.setVisibility(View.VISIBLE); + + holder.btnDeleteTask.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + YesNoAlertDialog.show(getContext(), "删除任务提示", "是否删除此项位置任务?", new YesNoAlertDialog.OnDialogResultListener(){ + @Override + public void onNo() { + } + + @Override + public void onYes() { + LogUtils.d(TAG, "删除按钮点击 position=" + position + " taskId=" + task.getTaskId()); + if (mMainService == null) { + showToast("删除失败:服务未就绪"); + LogUtils.e(TAG, "删除失败:MainService为空"); + return; + } + try { + mMainService.deleteTask(task.getTaskId()); + LogUtils.d(TAG, "服务删除任务成功 taskId=" + task.getTaskId()); + notifyItemRemoved(position); + notifyItemRangeChanged(position, mAdapterData.size()); + + if (mOnTaskUpdatedListener != null && mBindPositionId != null) { + mOnTaskUpdatedListener.onTaskUpdated(mBindPositionId, new ArrayList<>(mAdapterData)); + } + showToast("任务已删除"); + + } catch (Exception e) { + LogUtils.e(TAG, "删除异常: " + e.getMessage(), e); + showToast("删除失败,请重试"); + syncTasksFromMainService(); + } + } + + }); + } + }); + + holder.btnEditTask.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "编辑按钮点击 position=" + position + " taskId=" + task.getTaskId()); + if (mMainService == null) { + showToast("编辑失败:服务未就绪"); + LogUtils.e(TAG, "编辑失败:MainService为空"); + return; + } + showEditTaskDialog(task, position); + } + }); + + holder.cbTaskEnable.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, final boolean isChecked) { + LogUtils.d(TAG, "开关状态变更 taskId=" + task.getTaskId() + " isChecked=" + isChecked); + if (mMainService == null) { + showToast("状态修改失败:服务未就绪"); + buttonView.setChecked(!isChecked); + return; + } + try { + task.setIsEnable(isChecked); + mMainService.updateTaskStatus(task); + mRvTasks.post(new Runnable() { + @Override + public void run() { + notifyItemChanged(position); + if (mOnTaskUpdatedListener != null && mBindPositionId != null) { + mOnTaskUpdatedListener.onTaskUpdated(mBindPositionId, new ArrayList<>(mAdapterData)); + } + } + }); + } catch (Exception e) { + LogUtils.e(TAG, "开关变更异常: " + e.getMessage(), e); + buttonView.setChecked(!isChecked); + task.setIsEnable(!isChecked); + syncTasksFromMainService(); + } + } + }); + } + + private void showEditTaskDialog(final PositionTaskModel task, final int position) { + LogUtils.d(TAG, "showEditTaskDialog position=" + position + " taskId=" + task.getTaskId()); + final Context context = getContext(); + if (context == null) { + LogUtils.w(TAG, "showEditTaskDialog 上下文为空"); + return; + } + + View dialogView = LayoutInflater.from(context).inflate(R.layout.dialog_edit_task, null); + final EditText etEditDesc = dialogView.findViewById(R.id.et_edit_task_desc); + final RadioGroup rgDistanceCondition = dialogView.findViewById(R.id.rg_distance_condition); + final EditText etEditDistance = dialogView.findViewById(R.id.et_edit_distance); + Button btnCancel = dialogView.findViewById(R.id.btn_dialog_cancel); + Button btnSave = dialogView.findViewById(R.id.btn_dialog_save); + final Button btnSelectTime = dialogView.findViewById(R.id.btn_select_time); + final TextView tvSelectedTime = dialogView.findViewById(R.id.tv_selected_time); + + tvSelectedTime.setText(genSelectedTimeText(task.getStartTime())); + + Calendar initCal = Calendar.getInstance(); + initCal.setTimeInMillis(task.getStartTime()); + int y = initCal.get(Calendar.YEAR); + int M = initCal.get(Calendar.MONTH) + 1; + int d = initCal.get(Calendar.DAY_OF_MONTH); + int h = initCal.get(Calendar.HOUR_OF_DAY); + int m = initCal.get(Calendar.MINUTE); + + final DateTimePickerPopup dateTimePopup = new DateTimePickerPopup.Builder(context) + .setDateTimeRange(2020, 2030, 1, 12, 1, 31, 0, 23, 0, 59) + .setDefaultDateTime(y, M, d, h, m) + .setOnDateTimeSelectedListener(new DateTimePickerPopup.OnDateTimeSelectedListener() { + @Override + public void onDateTimeSelected(int year, int month, int day, int hour, int minute) { + Calendar cal = Calendar.getInstance(); + cal.set(year, month - 1, day, hour, minute, 0); + cal.set(Calendar.MILLISECOND, 0); + long time = cal.getTimeInMillis(); + tvSelectedTime.setText(genSelectedTimeText(time)); + task.setStartTime(time); + } + + @Override + public void onCancel() {} + }) + .build(); + + btnSelectTime.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + dateTimePopup.showAsDropDown(btnSelectTime); + } + }); + + etEditDesc.setText(task.getTaskDescription() == null ? "" : task.getTaskDescription()); + etEditDesc.setSelection(etEditDesc.getText().length()); + + if (task.isGreaterThan()) { + rgDistanceCondition.check(R.id.rb_greater_than); + } else { + rgDistanceCondition.check(R.id.rb_less_than); + } + etEditDistance.setText(String.valueOf(task.getDiscussDistance())); + + final android.app.AlertDialog dialog = new android.app.AlertDialog.Builder(context) + .setView(dialogView) + .setCancelable(false) + .create(); + dialog.show(); + + final OnDateSetListener listener = new OnDateSetListener() { + @Override + public void onDateSet(TimePickerDialog timePickerDialog, long p) {} + }; + + btnCancel.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + dialog.dismiss(); + } + }); + + btnSave.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + String newDesc = etEditDesc.getText().toString().trim(); + String distStr = etEditDistance.getText().toString().trim(); + + if (distStr.isEmpty()) { + showToast("请输入有效距离"); + etEditDistance.requestFocus(); + return; + } + + int newDist; + try { + newDist = Integer.parseInt(distStr); + if (newDist < 1) { + showToast("距离不能小于1米"); + etEditDistance.requestFocus(); + return; + } + } catch (NumberFormatException e) { + showToast("距离请输入数字"); + etEditDistance.requestFocus(); + return; + } + + task.setTaskDescription(newDesc); + task.setDiscussDistance(newDist); + boolean isGreater = rgDistanceCondition.getCheckedRadioButtonId() == R.id.rb_greater_than; + task.setIsGreaterThan(isGreater); + task.setPositionId(mBindPositionId); + + try { + mMainService.updateTask(task); + LogUtils.d(TAG, "保存修改成功 taskId=" + task.getTaskId()); + mAdapterData.set(position, task); + mRvTasks.post(new Runnable() { + @Override + public void run() { + notifyItemChanged(position); + if (mOnTaskUpdatedListener != null && mBindPositionId != null) { + mOnTaskUpdatedListener.onTaskUpdated(mBindPositionId, new ArrayList<>(mAdapterData)); + } + } + }); + dialog.dismiss(); + showToast("任务已更新"); + } catch (Exception e) { + LogUtils.e(TAG, "保存异常: " + e.getMessage(), e); + showToast("保存失败,请重试"); + syncTasksFromMainService(); + } + } + }); + } + + // ---------------------- ViewHolder ---------------------- + public abstract class TaskViewHolder extends RecyclerView.ViewHolder { + public TaskViewHolder(@NonNull View itemView) { + super(itemView); + } + } + + public class EmptyViewHolder extends TaskViewHolder { + public EmptyViewHolder(@NonNull View itemView) { + super(itemView); + } + } + + public class SimpleTaskViewHolder extends TaskViewHolder { + TextView tvSimpleTaskDesc; + TextView tvSimpleDistanceCond; + TextView tvStartTime; + TextView tvSimpleIsEnable; + View vBingoDot; + + public SimpleTaskViewHolder(@NonNull View itemView) { + super(itemView); + tvSimpleTaskDesc = itemView.findViewById(R.id.tv_simple_task_desc); + tvSimpleDistanceCond = itemView.findViewById(R.id.tv_simple_distance_cond); + tvStartTime = itemView.findViewById(R.id.tv_starttime); + tvSimpleIsEnable = itemView.findViewById(R.id.tv_simple_is_enable); + vBingoDot = itemView.findViewById(R.id.v_bingo_dot); + } + } + + public class TaskContentViewHolder extends TaskViewHolder { + TextView tvTaskDesc; + TextView tvTaskDistance; + TextView tvStartTime; + CompoundButton cbTaskEnable; + Button btnEditTask; + Button btnDeleteTask; + + public TaskContentViewHolder(@NonNull View itemView) { + super(itemView); + tvTaskDesc = itemView.findViewById(R.id.tv_task_desc); + tvTaskDistance = itemView.findViewById(R.id.tv_task_distance); + tvStartTime = itemView.findViewById(R.id.tv_starttime); + cbTaskEnable = itemView.findViewById(R.id.cb_task_enable); + btnEditTask = itemView.findViewById(R.id.btn_edit_task); + btnDeleteTask = itemView.findViewById(R.id.btn_delete_task); + } + } + } +} + diff --git a/positions/src/main/res/anim/popup_date_time_picker_in.xml b/positions/src/main/res/anim/popup_date_time_picker_in.xml new file mode 100644 index 0000000..3998a38 --- /dev/null +++ b/positions/src/main/res/anim/popup_date_time_picker_in.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/positions/src/main/res/anim/popup_date_time_picker_out.xml b/positions/src/main/res/anim/popup_date_time_picker_out.xml new file mode 100644 index 0000000..0619581 --- /dev/null +++ b/positions/src/main/res/anim/popup_date_time_picker_out.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/positions/src/main/res/drawable-v24/ic_launcher_foreground.xml b/positions/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bd21d --- /dev/null +++ b/positions/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/positions/src/main/res/drawable/activity_background.png b/positions/src/main/res/drawable/activity_background.png new file mode 100644 index 0000000..9f40005 Binary files /dev/null and b/positions/src/main/res/drawable/activity_background.png differ diff --git a/positions/src/main/res/drawable/bg_bingo_dot.xml b/positions/src/main/res/drawable/bg_bingo_dot.xml new file mode 100644 index 0000000..248bd1b --- /dev/null +++ b/positions/src/main/res/drawable/bg_bingo_dot.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/positions/src/main/res/drawable/bg_dialog_round.xml b/positions/src/main/res/drawable/bg_dialog_round.xml new file mode 100644 index 0000000..1cacbaa --- /dev/null +++ b/positions/src/main/res/drawable/bg_dialog_round.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/positions/src/main/res/drawable/bg_task_item.xml b/positions/src/main/res/drawable/bg_task_item.xml new file mode 100644 index 0000000..d8a68b8 --- /dev/null +++ b/positions/src/main/res/drawable/bg_task_item.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/positions/src/main/res/drawable/btn_cancel_bg.xml b/positions/src/main/res/drawable/btn_cancel_bg.xml new file mode 100644 index 0000000..74ca496 --- /dev/null +++ b/positions/src/main/res/drawable/btn_cancel_bg.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/positions/src/main/res/drawable/btn_confirm_bg.xml b/positions/src/main/res/drawable/btn_confirm_bg.xml new file mode 100644 index 0000000..9524b33 --- /dev/null +++ b/positions/src/main/res/drawable/btn_confirm_bg.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/positions/src/main/res/drawable/btn_delete_bg.xml b/positions/src/main/res/drawable/btn_delete_bg.xml new file mode 100644 index 0000000..c7f8623 --- /dev/null +++ b/positions/src/main/res/drawable/btn_delete_bg.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/positions/src/main/res/drawable/btn_dialog_cancel.xml b/positions/src/main/res/drawable/btn_dialog_cancel.xml new file mode 100644 index 0000000..3c04c0f --- /dev/null +++ b/positions/src/main/res/drawable/btn_dialog_cancel.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/positions/src/main/res/drawable/btn_dialog_confirm.xml b/positions/src/main/res/drawable/btn_dialog_confirm.xml new file mode 100644 index 0000000..fa1e750 --- /dev/null +++ b/positions/src/main/res/drawable/btn_dialog_confirm.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/positions/src/main/res/drawable/btn_selector.xml b/positions/src/main/res/drawable/btn_selector.xml new file mode 100644 index 0000000..e1be0a5 --- /dev/null +++ b/positions/src/main/res/drawable/btn_selector.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/positions/src/main/res/drawable/btn_text_selector.xml b/positions/src/main/res/drawable/btn_text_selector.xml new file mode 100644 index 0000000..47d9836 --- /dev/null +++ b/positions/src/main/res/drawable/btn_text_selector.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/positions/src/main/res/drawable/circle_button_bg.xml b/positions/src/main/res/drawable/circle_button_bg.xml new file mode 100644 index 0000000..5ed6038 --- /dev/null +++ b/positions/src/main/res/drawable/circle_button_bg.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/positions/src/main/res/drawable/edittext_bg.xml b/positions/src/main/res/drawable/edittext_bg.xml new file mode 100644 index 0000000..85b3d28 --- /dev/null +++ b/positions/src/main/res/drawable/edittext_bg.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/positions/src/main/res/drawable/ic_launcher.xml b/positions/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..1fd5efd --- /dev/null +++ b/positions/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,10 @@ + + + + diff --git a/positions/src/main/res/drawable/ic_launcher_background.xml b/positions/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d5fccc5 --- /dev/null +++ b/positions/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/positions/src/main/res/drawable/ic_launcher_beta.xml b/positions/src/main/res/drawable/ic_launcher_beta.xml new file mode 100644 index 0000000..bc3dca6 --- /dev/null +++ b/positions/src/main/res/drawable/ic_launcher_beta.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/positions/src/main/res/drawable/ic_positions.png b/positions/src/main/res/drawable/ic_positions.png new file mode 100644 index 0000000..68ea75b Binary files /dev/null and b/positions/src/main/res/drawable/ic_positions.png differ diff --git a/positions/src/main/res/drawable/ic_positions_plus.png b/positions/src/main/res/drawable/ic_positions_plus.png new file mode 100644 index 0000000..4155d06 Binary files /dev/null and b/positions/src/main/res/drawable/ic_positions_plus.png differ diff --git a/positions/src/main/res/drawable/item_bg_edit.xml b/positions/src/main/res/drawable/item_bg_edit.xml new file mode 100644 index 0000000..589db77 --- /dev/null +++ b/positions/src/main/res/drawable/item_bg_edit.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/positions/src/main/res/drawable/item_bg_simple.xml b/positions/src/main/res/drawable/item_bg_simple.xml new file mode 100644 index 0000000..1617054 --- /dev/null +++ b/positions/src/main/res/drawable/item_bg_simple.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/positions/src/main/res/drawable/item_position_bg.xml b/positions/src/main/res/drawable/item_position_bg.xml new file mode 100644 index 0000000..a6dba44 --- /dev/null +++ b/positions/src/main/res/drawable/item_position_bg.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/positions/src/main/res/drawable/shape_2px_border.xml b/positions/src/main/res/drawable/shape_2px_border.xml new file mode 100644 index 0000000..1ca8e82 --- /dev/null +++ b/positions/src/main/res/drawable/shape_2px_border.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/positions/src/main/res/drawable/shape_log_border.xml b/positions/src/main/res/drawable/shape_log_border.xml new file mode 100644 index 0000000..6b965ca --- /dev/null +++ b/positions/src/main/res/drawable/shape_log_border.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/positions/src/main/res/layout/activity_about.xml b/positions/src/main/res/layout/activity_about.xml new file mode 100644 index 0000000..278c5be --- /dev/null +++ b/positions/src/main/res/layout/activity_about.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/positions/src/main/res/layout/activity_location.xml b/positions/src/main/res/layout/activity_location.xml new file mode 100644 index 0000000..bcfc91b --- /dev/null +++ b/positions/src/main/res/layout/activity_location.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +