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..02fe47b --- /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.12.13' + api 'cc.winboll.studio:libappbase:15.14.2' + + // 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..c4e8cbf --- /dev/null +++ b/positions/build.properties @@ -0,0 +1,8 @@ +#Created by .winboll/winboll_app_build.gradle +#Wed Jan 07 19:14:34 HKT 2026 +stageCount=9 +libraryProject= +baseVersion=15.12 +publishVersion=15.12.8 +buildCount=0 +baseBetaVersion=15.12.9 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..764c31b --- /dev/null +++ b/positions/src/main/AndroidManifest.xml @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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..b3016dd --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/App.java @@ -0,0 +1,348 @@ +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.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.util.Log; +import android.view.Gravity; +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.ToastUtils; +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; + +public class App extends GlobalApplication { + + private static Handler MAIN_HANDLER = new Handler(Looper.getMainLooper()); + + @Override + public void onCreate() { + super.onCreate(); + setIsDebugging(BuildConfig.DEBUG); + + WinBoLLActivityManager.init(this); + + // 初始化 Toast 框架 + ToastUtils.init(this); + // 设置 Toast 布局样式 + //ToastUtils.setView(R.layout.view_toast); + //ToastUtils.setStyle(new WhiteToastStyle()); + //ToastUtils.setGravity(Gravity.BOTTOM, 0, 200); + + //CrashHandler.getInstance().registerGlobal(this); + //CrashHandler.getInstance().registerPart(this); + } + + 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); + } + } + + 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); + } + } + + 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); + } + } + + 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); + } + + public void registerGlobal(Context context, String crashDir) { + Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionHandlerImpl(context.getApplicationContext(), crashDir)); + } + + public void unregister() { + Thread.setDefaultUncaughtExceptionHandler(DEFAULT_UNCAUGHT_EXCEPTION_HANDLER); + } + + public void registerPart(Context context) { + unregisterPart(context); + mPartCrashHandler = new PartCrashHandler(context.getApplicationContext()); + MAIN_HANDLER.postAtFrontOfQueue(mPartCrashHandler); + } + + public void unregisterPart(Context context) { + if (mPartCrashHandler != null) { + mPartCrashHandler.isRunning.set(false); + mPartCrashHandler = null; + } + } + + 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) { + e.printStackTrace(); + 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); + + try { + Intent intent = new Intent(mContext, CrashActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra(Intent.EXTRA_TEXT, log); + mContext.startActivity(intent); + } catch (Throwable e) { + e.printStackTrace(); + writeLog(e.toString()); + } + + throwable.printStackTrace(); + 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(Log.getStackTraceString(throwable)); + + 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")); + } catch (Throwable e) { + e.printStackTrace(); + } + } + + 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); + + 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); + } + + 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) { + switch (item.getItemId()) { + case android.R.id.copy: + ClipboardManager cm = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + cm.setPrimaryClip(ClipData.newPlainText(getPackageName(), mLog)); + 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..0ebf2bb --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/MainActivity.java @@ -0,0 +1,325 @@ +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.Switch; +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.R; +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.utils.AppConfigsUtil; +import cc.winboll.studio.positions.utils.ServiceUtil; +import android.widget.LinearLayout; + +/** + * 主页面:仅负责 + * 1. 位置服务启动/停止(通过 Switch 开关控制) + * 2. 跳转至“位置管理页(LocationActivity)”和“日志页(LogActivity)” + * 3. Java 7 语法适配:无 Lambda、显式接口实现、兼容低版本 + */ +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 Switch mServiceSwitch; + private Button mManagePositionsButton; + private Toolbar mToolbar; + // 服务相关:服务实例、绑定状态标记 + //private DistanceRefreshService mDistanceService; + private boolean isServiceBound = false; + ADsBannerView mADsBannerView; + + + @Override + public Activity getActivity() { + return this; + } + + @Override + public String getTag() { + return TAG; + } + + // ---------------------- 服务连接回调(仅用于获取服务状态,不依赖服务执行核心逻辑) ---------------------- +// private final ServiceConnection mServiceConn = new ServiceConnection() { +// /** +// * 服务绑定成功:获取服务实例,同步开关状态(以服务实际状态为准) +// */ +// @Override +// public void onServiceConnected(ComponentName name, IBinder service) { +// // Java 7 显式强转 Binder 实例(确保类型匹配,避免ClassCastException) +// DistanceRefreshService.DistanceBinder binder = (DistanceRefreshService.DistanceBinder) service; +// mDistanceService = binder.getService(); +// isServiceBound = true; +// } +// +// /** +// * 服务意外断开(如服务崩溃):重置服务实例和绑定状态 +// */ +// @Override +// public void onServiceDisconnected(ComponentName name) { +// mDistanceService = null; +// isServiceBound = false; +// } +// }; + + // ---------------------- Activity 生命周期(核心:初始化UI、申请权限、绑定服务、释放资源) ---------------------- + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); // 关联主页面布局 + + // 1. 初始化顶部 Toolbar(保留原逻辑,设置页面标题) + initToolbar(); + // 2. 初始化其他控件 + initViews(); + // 3. 检查并申请位置权限(含后台GPS权限,确保服务启动前权限就绪) + if (!checkLocationPermissions()) { + requestLocationPermissions(); + } + // 4. 绑定服务(仅用于获取服务实时状态,不影响服务独立运行) + //bindDistanceService(); + + mADsBannerView = findViewById(R.id.adsbanner); + + setLLMainBackgroundColor(); + } + + // 在 Activity 的 onCreate() 或需要获取颜色的方法中调用 + private void setLLMainBackgroundColor() { + // 1. 定义要解析的主题属性(这里是 colorAccent) + TypedArray a = getTheme().obtainStyledAttributes(new int[]{android.R.attr.colorAccent}); + // 2. 获取对应的颜色值(默认值可设为你需要的 fallback 颜色,如 Color.GRAY) + int colorAccent = a.getColor(0, Color.GRAY); + // 3. 必须回收,避免内存泄漏 + a.recycle(); + + LinearLayout llmain = findViewById(R.id.llmain); + llmain.setBackgroundColor(colorAccent); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (mADsBannerView != null) { + mADsBannerView.releaseAdResources(); + } + + // 页面销毁时解绑服务,避免Activity与服务相互引用导致内存泄漏 +// if (isServiceBound) { +// unbindService(mServiceConn); +// isServiceBound = false; +// mDistanceService = null; +// } + } + + @Override + protected void onResume() { + super.onResume(); + if (mADsBannerView != null) { + mADsBannerView.resumeADs(MainActivity.this); + } + } + + + + // ---------------------- 核心功能1:初始化UI组件(Toolbar + 服务开关) ---------------------- + /** + * 初始化顶部 Toolbar,设置页面标题 + */ + private void initToolbar() { + mToolbar = (Toolbar) findViewById(R.id.toolbar); // Java 7 显式 findViewById + 强转 + setSupportActionBar(mToolbar); + // 给ActionBar设置标题(先判断非空,避免空指针异常) + if (getSupportActionBar() != null) { + getSupportActionBar().setTitle(getString(R.string.app_name)); + } + } + + /** + * 初始化服务控制开关:读取SP状态、绑定点击事件(含权限检查) + */ + private void initViews() { + mServiceSwitch = (Switch) findViewById(R.id.switch_service_control); // 显式强转 + mServiceSwitch.setChecked(AppConfigsUtil.getInstance(this).isEnableMainService(true)); + + mManagePositionsButton = (Button) findViewById(R.id.btn_manage_positions); + mManagePositionsButton.setEnabled(mServiceSwitch.isChecked()); + + // Java 7 用匿名内部类实现 CompoundButton.OnCheckedChangeListener + mServiceSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + // 开关打开前先检查权限:无权限则终止操作、重置开关、引导申请 + if (isChecked && !checkLocationPermissions()) { + requestLocationPermissions(); + return; + } + + // 权限就绪:执行服务启停逻辑 + if (isChecked) { + LogUtils.d(TAG, "设置启动服务"); + ServiceUtil.startAutoService(MainActivity.this); + } else { + LogUtils.d(TAG, "设置关闭服务"); + + ServiceUtil.stopAutoService(MainActivity.this); + } + + mManagePositionsButton.setEnabled(isChecked); + } + }); + } + + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + // 主题菜单 + AESThemeUtil.inflateMenu(this, menu); + // 调试工具菜单 + if (App.isDebugging()) { + DevelopUtils.inflateMenu(this, menu); + } + // 应用其他菜单 + getMenuInflater().inflate(R.menu.toolbar_main, menu); + + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int menuItemId = item.getItemId(); + if (AESThemeUtil.onAppThemeItemSelected(this, item)) { + recreate(); + } if (DevelopUtils.onDevelopItemSelected(this, item)) { + LogUtils.d(TAG, String.format("onOptionsItemSelected item.getItemId() %d ", item.getItemId())); + } else if (menuItemId == R.id.item_settings) { + Intent intent = new Intent(); + intent.setClass(this, SettingsActivity.class); + startActivity(intent); + } else { + // 在switch语句中处理每个ID,并在处理完后返回true,未处理的情况返回false。 + return super.onOptionsItemSelected(item); + } + return true; + } + + + /** + * 绑定服务(仅用于获取服务状态,不启动服务) + */ +// private void bindDistanceService() { +// Intent serviceIntent = new Intent(this, MainService.class); +// // BIND_AUTO_CREATE:服务未启动则创建(仅为获取状态,启停由开关控制) +// bindService(serviceIntent, mServiceConn, Context.BIND_AUTO_CREATE); +// } + + // ---------------------- 核心功能3:页面跳转(位置管理页+日志页) ---------------------- + /** + * 跳转至“位置管理页(LocationActivity)”(按钮点击触发,需在布局中设置 android:onClick="onPositions") + * 服务未启动时提示,不允许跳转(避免LocationActivity无数据) + */ + public void onPositions(View view) { + //ToastUtils.show("onPositions"); + // 服务已启动:跳转到位置管理页 + startActivity(new Intent(MainActivity.this, LocationActivity.class)); + } + + // ---------------------- 新增:位置权限处理(适配Java7 + 后台GPS权限) ---------------------- + /** + * 检查是否拥有「前台+后台」位置权限(适配Android版本差异) + * Java7 特性:显式类型判断、无Lambda、兼容低版本API + */ + private boolean checkLocationPermissions() { + // 1. 检查前台精确定位权限(Android 6.0+ 必需,显式强转权限常量) + int foregroundPermResult = checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION); + boolean hasForegroundPerm = (foregroundPermResult == PackageManager.PERMISSION_GRANTED); + + // 2. 检查后台定位权限(仅Android 10+ 需要,Java7 显式用Build.VERSION判断版本) + boolean hasBackgroundPerm = true; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + int backgroundPermResult = checkSelfPermission(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION); + hasBackgroundPerm = (backgroundPermResult == PackageManager.PERMISSION_GRANTED); + } + + // 前台+后台权限均满足,才返回true + return hasForegroundPerm && hasBackgroundPerm; + } + + private void requestLocationPermissions() { + // 1. 先判断前台定位权限(ACCESS_FINE_LOCATION)是否已授予 + if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) + != PackageManager.PERMISSION_GRANTED) { + // 1.1 未授予前台权限:先申请前台权限(API 30+ 后台权限依赖前台权限) + String[] foregroundPermissions = new String[]{Manifest.permission.ACCESS_FINE_LOCATION}; + // 对API 23+(Android 6.0)动态申请,低版本会直接授予(清单已声明前提下) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + requestPermissions(foregroundPermissions, REQUEST_LOCATION_PERMISSIONS); + } + } else { + // 2. 已授予前台权限:判断是否需要申请后台权限(仅API 29+需要) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // 2.1 检查后台权限是否未授予 + if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION) + != PackageManager.PERMISSION_GRANTED) { + // 2.2 API 30+ 必须单独申请后台权限(不能和前台权限一起弹框) + requestPermissions( + new String[]{Manifest.permission.ACCESS_BACKGROUND_LOCATION}, + REQUEST_BACKGROUND_LOCATION_PERMISSION + ); + } + } + // 3. 前台权限已授予(+ 后台权限按需授予):此处可执行定位相关逻辑 + // doLocationRelatedLogic(); + } + } + +// 【必须补充】权限申请结果回调(处理用户同意/拒绝逻辑) + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + // 处理前台权限申请结果 + if (requestCode == REQUEST_LOCATION_PERMISSIONS) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // 前台权限同意:自动尝试申请后台权限(如果是API 29+) + requestLocationPermissions(); + } else { + // 前台权限拒绝:提示用户(可选:引导跳转到应用设置页) + Toast.makeText(this, "需要前台定位权限才能使用该功能", Toast.LENGTH_SHORT).show(); + } + } 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(); + } + } + } + +} + 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..2c40719 --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/activities/LocationActivity.java @@ -0,0 +1,488 @@ +package cc.winboll.studio.positions.activities; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/09/29 18:22 + * @Describe 位置列表页面(适配MainService GPS接口+规范服务交互+完善生命周期) + */ +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.interfaces.IWinBoLLActivity; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +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.MainService; +import java.util.ArrayList; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Java 7 语法适配: + * 1. 服务绑定用匿名内部类实现 ServiceConnection + * 2. Adapter 初始化传入 MainService 实例,确保数据来源唯一 + * 3. 所有位置/任务操作通过 MainService 接口执行 + */ +public class LocationActivity extends WinBoLLActivity implements IWinBoLLActivity { + public static final String TAG = "LocationActivity"; + + private Toolbar mToolbar; + + private RecyclerView mRvPosition; + private PositionAdapter mPositionAdapter; + + // MainService 引用+绑定状态(AtomicBoolean 确保多线程状态可见性) + private MainService mMainService; + private final AtomicBoolean isServiceBound = new AtomicBoolean(false); + // 标记 Adapter 是否已初始化(避免重复初始化/销毁后初始化) + private final AtomicBoolean isAdapterInited = new AtomicBoolean(false); + + // ---------------------- 新增:GPS监听核心变量 ---------------------- + private MainService.GpsUpdateListener mGpsUpdateListener; // GPS监听实例 + private PositionModel mCurrentGpsPos; // 缓存当前GPS位置(供页面使用) + // 本地位置缓存(解决服务数据未同步时Adapter空数据问题) + private final ArrayList mLocalPosCache = new ArrayList(); + + + // 服务连接(Java 7 匿名内部类实现,强化状态同步+数据预加载) + private ServiceConnection mServiceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + // 1. 安全获取服务实例(避免强转失败+服务未就绪) + if (!(service instanceof MainService.LocalBinder)) { + LogUtils.e(TAG, "服务绑定失败:Binder类型不匹配(非MainService.LocalBinder)"); + isServiceBound.set(false); + return; + } + + try { + MainService.LocalBinder binder = (MainService.LocalBinder) service; + mMainService = binder.getService(); + // 2. 标记服务绑定成功(原子操作,确保多线程可见) + isServiceBound.set(true); + LogUtils.d(TAG, "MainService绑定成功,开始同步数据+初始化Adapter"); + + // 3. 同步服务数据到本地缓存(核心:先同步数据,再初始化Adapter) + syncDataFromMainService(); + // 4. 注册GPS监听(确保监听在Adapter前初始化,数据不丢失) + registerGpsListener(); + // 5. 初始化Adapter(传入本地缓存+服务实例,数据非空) + initPositionAdapter(); + + } catch (Exception e) { + LogUtils.d(TAG, "服务绑定后初始化失败:" + e.getMessage()); + isServiceBound.set(false); + mMainService = null; + showToast("服务初始化失败,无法加载数据"); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + LogUtils.w(TAG, "MainService断开连接,清空引用+标记状态"); + // 1. 清空服务引用+标记绑定状态 + mMainService = null; + isServiceBound.set(false); + // 2. 标记Adapter未初始化(下次绑定需重新初始化) + isAdapterInited.set(false); + } + }; + + @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_location); + + 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, "【导航栏】点击返回"); + startActivity(new Intent(LocationActivity.this, MainActivity.class)); + finish(); + } + }); + + // 1. 初始化视图(优先执行,避免Adapter初始化时视图为空) + initView(); + // 2. 初始化GPS监听(提前创建,避免绑定服务后空指针) + initGpsUpdateListener(); + // 3. 绑定MainService(最后执行,确保视图/监听已就绪) + bindMainService(); + } + + /** + * 初始化视图(RecyclerView)- 确保视图先于Adapter初始化 + */ + private void initView() { + mRvPosition = (RecyclerView) findViewById(R.id.rv_position_list); + // 1. 显式设置布局管理器(避免Adapter设置时无布局管理器崩溃) + LinearLayoutManager layoutManager = new LinearLayoutManager(this); + layoutManager.setOrientation(LinearLayoutManager.VERTICAL); + mRvPosition.setLayoutManager(layoutManager); + // 2. 初始化本地缓存(避免首次加载时缓存为空) + mLocalPosCache.clear(); + LogUtils.d(TAG, "视图初始化完成(布局管理器+本地缓存已就绪)"); + } + + /** + * 绑定MainService(Java 7 显式Intent,强化绑定安全性) + */ + private void bindMainService() { + // 1. 避免重复绑定(快速重建Activity时防止多绑定) + if (isServiceBound.get()) { + LogUtils.w(TAG, "无需重复绑定:MainService已绑定"); + return; + } + + Intent serviceIntent = new Intent(this, MainService.class); + // 2. 绑定服务(BIND_AUTO_CREATE:服务不存在时自动创建,增加绑定成功率) + boolean bindSuccess = bindService(serviceIntent, mServiceConnection, BIND_AUTO_CREATE); + if (!bindSuccess) { + LogUtils.e(TAG, "发起MainService绑定请求失败(服务未找到/系统限制)"); + showToast("服务绑定失败,无法加载位置数据"); + } else { + LogUtils.d(TAG, "MainService绑定请求已发起"); + } + } + + /** + * 从MainService同步数据到本地缓存(核心解决Adapter空数据问题) + * 作用:1. 服务数据优先同步到本地,Adapter基于本地缓存初始化 + * 2. 避免服务数据更新时直接操作Adapter,通过缓存中转 + */ + private void syncDataFromMainService() { + // 1. 安全校验(服务未绑定/服务空,用本地缓存兜底) + if (!isServiceBound.get() || mMainService == null) { + LogUtils.w(TAG, "同步数据:服务未就绪,使用本地缓存(当前缓存量=" + mLocalPosCache.size() + ")"); + return; + } + + try { + // 2. 从服务获取最新位置数据(同步操作,确保数据拿到后再返回) + ArrayList servicePosList = mMainService.getPositionList(); + // 3. 同步到本地缓存(清空旧数据+添加新数据,避免重复) + synchronized (mLocalPosCache) { // 加锁避免多线程操作缓存冲突 + mLocalPosCache.clear(); + if (servicePosList != null && !servicePosList.isEmpty()) { + mLocalPosCache.addAll(servicePosList); + } + } + LogUtils.d(TAG, "数据同步完成:服务位置数=" + (servicePosList == null ? 0 : servicePosList.size()) + + ",本地缓存数=" + mLocalPosCache.size()); + + } catch (Exception e) { + LogUtils.d(TAG, "同步服务数据失败:" + e.getMessage()); + // 异常时保留本地缓存,避免Adapter无数据 + LogUtils.w(TAG, "同步失败,使用本地缓存兜底(缓存量=" + mLocalPosCache.size() + ")"); + } + } + + /** + * 初始化PositionAdapter(核心优化:基于本地缓存初始化,避免空数据) + */ + private void initPositionAdapter() { + // 1. 多重安全校验(避免销毁后初始化/重复初始化/依赖未就绪) + if (isAdapterInited.get() || !isServiceBound.get() || mMainService == null || mRvPosition == null) { + LogUtils.w(TAG, "Adapter初始化跳过:" + + "已初始化=" + isAdapterInited.get() + + ",服务绑定=" + isServiceBound.get() + + ",视图就绪=" + (mRvPosition != null)); + return; + } + + try { + // 2. 基于本地缓存初始化Adapter(缓存已同步服务数据,非空) + mPositionAdapter = new PositionAdapter(this, mLocalPosCache, mMainService); + + // 3. 设置删除回调(删除时同步服务+本地缓存+Adapter) + mPositionAdapter.setOnDeleteClickListener(new PositionAdapter.OnDeleteClickListener() { + @Override + public void onDeleteClick(int position) { + // 安全校验(索引有效+服务绑定+缓存非空) + if (position < 0 || position >= mLocalPosCache.size() || !isServiceBound.get() || mMainService == null) { + LogUtils.w(TAG, "删除位置失败:索引无效/服务未就绪(索引=" + position + ",缓存量=" + mLocalPosCache.size() + ")"); + return; + } + + PositionModel deletePos = mLocalPosCache.get(position); + if (deletePos != null && !deletePos.getPositionId().isEmpty()) { + // 步骤1:调用服务删除(确保服务数据一致性) + mMainService.removePosition(deletePos.getPositionId()); + // 步骤2:删除本地缓存(确保缓存与服务同步) + synchronized (mLocalPosCache) { + mLocalPosCache.remove(position); + } + // 步骤3:通知Adapter刷新(基于缓存操作,避免空数据) + mPositionAdapter.notifyItemRemoved(position); + showToast("删除位置成功:" + deletePos.getMemo()); + LogUtils.d(TAG, "删除位置完成:ID=" + deletePos.getPositionId() + "(服务+缓存已同步)"); + } + } + }); + + // 4. 设置保存回调(保存时同步服务+本地缓存+Adapter) + mPositionAdapter.setOnSavePositionClickListener(new PositionAdapter.OnSavePositionClickListener() { + @Override + public void onSavePositionClick(int position, PositionModel updatedPos) { + // 安全校验(索引有效+服务绑定+数据非空) + if (!isServiceBound.get() || mMainService == null + || position < 0 || position >= mLocalPosCache.size() || updatedPos == null) { + LogUtils.w(TAG, "保存位置失败:服务未就绪/索引无效/数据空"); + showToast("服务未就绪,保存失败"); + return; + } + + // 步骤1:调用服务更新(确保服务数据一致性) + mMainService.updatePosition(updatedPos); + // 步骤2:更新本地缓存(确保缓存与服务同步) + synchronized (mLocalPosCache) { + mLocalPosCache.set(position, updatedPos); + } + // 步骤3:通知Adapter刷新(基于缓存操作,避免空数据) + mPositionAdapter.notifyItemChanged(position); + showToast("保存位置成功:" + updatedPos.getMemo()); + LogUtils.d(TAG, "保存位置完成:ID=" + updatedPos.getPositionId() + "(服务+缓存已同步)"); + } + }); + + // 5. 设置Adapter到RecyclerView(最后一步,确保Adapter已配置完成) + mRvPosition.setAdapter(mPositionAdapter); + // 6. 标记Adapter已初始化(避免重复初始化) + isAdapterInited.set(true); + LogUtils.d(TAG, "PositionAdapter初始化完成(基于本地缓存,数据量=" + mLocalPosCache.size() + ")"); + + } catch (Exception e) { + LogUtils.d(TAG, "Adapter初始化失败:" + e.getMessage()); + isAdapterInited.set(false); + mPositionAdapter = null; + showToast("位置列表初始化失败,请重试"); + } + } + + /** + * 显示Toast(Java 7 显式Toast.makeText,避免空Context) + */ + private void showToast(String content) { + if (isFinishing() || isDestroyed()) { // 避免Activity销毁后弹Toast崩溃 + LogUtils.w(TAG, "Activity已销毁,跳过Toast:" + content); + return; + } + Toast.makeText(this, content, Toast.LENGTH_SHORT).show(); + } + + // ---------------------- 页面交互(新增位置逻辑保留,适配GPS数据) ---------------------- + /** + * 新增位置(调用服务addPosition(),可选:用当前GPS位置初始化新位置) + */ + public void addNewPosition(View view) { + // 1. 隐藏软键盘(避免软键盘遮挡操作) + InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null && getCurrentFocus() != null) { + imm.hideSoftInputFromWindow(getCurrentFocus().getWindowToken(), 0); + } + + // 2. 安全校验(服务未绑定,不允许新增) + if (!isServiceBound.get() || mMainService == null) { + LogUtils.w(TAG, "新增位置失败:MainService未绑定"); + showToast("服务未就绪,无法新增位置"); + return; + } + + // 3. 创建新位置模型(优化:优先用当前GPS位置初始化,无则用默认值) + PositionModel newPos = new PositionModel(); + newPos.setPositionId(PositionModel.genPositionId()); // 生成唯一ID(需PositionModel实现) + 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); // 启用距离计算(依赖GPS) + + // 4. 调用服务新增+同步本地缓存(确保缓存与服务一致) + mMainService.addPosition(newPos); + synchronized (mLocalPosCache) { + mLocalPosCache.add(newPos); + } + LogUtils.d(TAG, "通过服务新增位置:ID=" + newPos.getPositionId() + ",纬度=" + newPos.getLatitude() + "(缓存已同步)"); + + // 5. 刷新Adapter(基于缓存操作,确保数据立即显示) + if (isAdapterInited.get() && mPositionAdapter != null) { + mPositionAdapter.notifyItemInserted(mLocalPosCache.size() - 1); + } + showToast("新增位置成功(已启用GPS距离计算)"); + } + + // ---------------------- 新增:GPS监听初始化+注册/反注册(核心适配逻辑) ---------------------- + /** + * 初始化GPS监听:实现MainService.GpsUpdateListener,接收实时GPS数据 + */ + private void initGpsUpdateListener() { + LogUtils.d(TAG, "initGpsUpdateListener()"); + mGpsUpdateListener = new MainService.GpsUpdateListener() { + @Override + public void onGpsPositionUpdated(PositionModel currentGpsPos) { + if (currentGpsPos == null || isFinishing() || isDestroyed()) { + LogUtils.w(TAG, "GPS位置更新:数据为空或Activity已销毁"); + return; + } + // 缓存当前GPS位置(供页面其他逻辑使用) + mCurrentGpsPos = currentGpsPos; + LogUtils.d(TAG, String.format("收到GPS更新:纬度=%.4f,经度=%.4f" + , currentGpsPos.getLatitude(), currentGpsPos.getLongitude())); + // 安全更新UI(避免Activity销毁后操作视图崩溃) + ((TextView)findViewById(R.id.tv_latitude)).setText(String.format("当前纬度:%f", currentGpsPos.getLatitude())); + ((TextView)findViewById(R.id.tv_longitude)).setText(String.format("当前经度:%f", currentGpsPos.getLongitude())); + } + + @Override + public void onGpsStatusChanged(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监听:调用MainService的PUBLIC方法,绑定监听 + */ + private void registerGpsListener() { + // 安全校验(避免Activity销毁/服务未绑定/监听为空时注册) + if (isFinishing() || isDestroyed() || !isServiceBound.get() || mMainService == null || mGpsUpdateListener == null) { + LogUtils.w(TAG, "GPS监听注册跳过:Activity状态异常/依赖未就绪"); + return; + } + try { + mMainService.registerGpsUpdateListener(mGpsUpdateListener); + LogUtils.d(TAG, "GPS监听已注册"); + } catch (Exception e) { + LogUtils.d(TAG, "GPS监听注册失败:" + e.getMessage()); + } + } + + /** + * 反注册GPS监听:调用MainService的PUBLIC方法,解绑监听(核心防内存泄漏+数据异常) + */ + private void unregisterGpsListener() { + // 避免Activity销毁后调用服务方法(防止空指针/服务已解绑) + if (mMainService == null || mGpsUpdateListener == null) { + LogUtils.w(TAG, "GPS监听反注册跳过:服务/监听未初始化"); + return; + } + try { + mMainService.unregisterGpsUpdateListener(mGpsUpdateListener); + LogUtils.d(TAG, "GPS监听已反注册"); + } catch (Exception e) { + LogUtils.d(TAG, "GPS监听反注册失败:" + e.getMessage()); + } + } + + /** + * 页面可见时同步数据(解决快速切回时数据未更新问题) + * 场景:快速关闭再打开Activity,服务已绑定但数据未重新同步 + */ + @Override + protected void onResume() { + super.onResume(); + // 1. 服务已绑定但Adapter未初始化:重新同步数据+初始化Adapter + if (isServiceBound.get() && mMainService != null && !isAdapterInited.get()) { + LogUtils.d(TAG, "onResume:服务已绑定但Adapter未初始化,重新同步数据"); + syncDataFromMainService(); + initPositionAdapter(); + } else if (isServiceBound.get() && mMainService != null && isAdapterInited.get() && mPositionAdapter != null) { + syncDataFromMainService(); + mPositionAdapter.notifyDataSetChanged(); + LogUtils.d(TAG, "onResume:刷新位置数据(与服务同步)"); + } + } + + /** + * 页面不可见时暂停操作(避免后台操作导致数据异常) + */ + @Override + protected void onPause() { + super.onPause(); + // 避免后台时仍执行UI刷新(如GPS更新触发的视图操作) + LogUtils.d(TAG, "onPause:页面不可见,暂停UI相关操作"); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + LogUtils.d(TAG, "onDestroy:开始释放资源"); + + // 1. 反注册GPS监听(优先执行,避免服务持有Activity引用导致内存泄漏) + unregisterGpsListener(); + + // 2. 释放Adapter资源(反注册可能的监听,避免内存泄漏) + if (mPositionAdapter != null) { + mPositionAdapter.release(); + mPositionAdapter = null; // 清空引用,帮助GC回收 + LogUtils.d(TAG, "Adapter资源已释放"); + } + + // 3. 解绑MainService(最后执行,确保其他资源已释放) + if (isServiceBound.get()) { + try { + unbindService(mServiceConnection); + LogUtils.d(TAG, "MainService解绑完成"); + } catch (IllegalArgumentException e) { + // 捕获“服务未绑定”异常(快速开关时可能出现,避免崩溃) + LogUtils.d(TAG, "解绑MainService失败:服务未绑定(可能已提前解绑)"); + } + // 重置绑定状态+服务引用 + isServiceBound.set(false); + mMainService = null; + } + + // 4. 清空本地缓存+GPS引用(帮助GC回收) + synchronized (mLocalPosCache) { + mLocalPosCache.clear(); + } + mCurrentGpsPos = null; + mGpsUpdateListener = null; + isAdapterInited.set(false); + LogUtils.d(TAG, "所有资源释放完成(onDestroy执行结束)"); + } + + // ---------------------- 移除重复定义:LocalBinder 统一在 MainService 中定义 ---------------------- + // 说明:原LocationActivity中的LocalBinder是重复定义(MainService已实现),会导致类型强转失败 + // 此处删除该类,确保Activity绑定服务时强转的是MainService中的LocalBinder +} + + 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..d324e87 --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/adapters/PositionAdapter.java @@ -0,0 +1,809 @@ +package cc.winboll.studio.positions.adapters; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/09/29 20:25 + * @Describe 位置数据适配器(修复视图复用资源加载,支持滚动后重新绑定数据,Java 7语法适配) + */ + + +/** + * Java 7 语法严格适配 + 视图复用资源加载修复: + * 1. 保留无Lambda/弱引用/线程安全集合等原有适配 + * 2. 修复核心问题:移除 onViewDetachedFromWindow 中关键资源释放,改为 onBind 时重新绑定 + * 3. 强化资源复用:任务视图/距离控件在复用后自动从服务同步最新数据,确保滚动后数据不丢失 + * 4. 优化缓存逻辑:仅清理脱离屏幕且无效的控件缓存,有效控件保留引用供局部更新(如距离) + */ +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 = "实时距离:计算失败"; + + // 核心依赖(弱引用+线程安全集合,适配Java 7) + private final Context mContext; + private final ArrayList mCachedPositionList; // 位置缓存(与MainService同步) + private final WeakReference mMainServiceRef; // 弱引用MainService,防内存泄漏 + // 控件缓存:位置ID → 对应任务列表视图(分别缓存简单/编辑模式,支持复用后快速同步) + private final ConcurrentHashMap mSimpleTaskViewMap; + private final ConcurrentHashMap mEditTaskViewMap; + // 距离控件缓存(用于局部更新距离UI,保留有效引用避免复用后更新失效) + private final ConcurrentHashMap mPosDistanceViewMap; + + // 回调接口(仅处理位置逻辑,任务逻辑通过PositionTaskListView+MainService完成) + 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) { + this.mContext = context; + // 容错处理:避免传入null导致空指针 + this.mCachedPositionList = (cachedPositionList != null) ? cachedPositionList : new ArrayList(); + // 弱引用MainService:防止Adapter持有服务导致内存泄漏(Java 7 弱引用语法) + this.mMainServiceRef = new WeakReference(mainService); + // 初始化控件缓存(线程安全集合,适配多线程更新场景) + this.mSimpleTaskViewMap = new ConcurrentHashMap(); + this.mEditTaskViewMap = new ConcurrentHashMap(); + this.mPosDistanceViewMap = new ConcurrentHashMap(); + + // 注册MainService任务监听:服务任务变化时同步刷新任务列表视图 + if (mainService != null) { + mainService.registerTaskUpdateListener(this); + LogUtils.d(TAG, "已注册MainService任务监听,确保任务数据与服务同步"); + } else { + LogUtils.w(TAG, "构造函数:MainService为空,PositionTaskListView无法初始化"); + } + + LogUtils.d(TAG, "Adapter初始化完成:位置数量=" + 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) { + 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) { + 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, "instanceof SimpleViewHolder(复用/新创建):位置ID=" + posId); + SimpleViewHolder simpleHolder = (SimpleViewHolder) holder; + // 1. 重新绑定位置基础数据(经纬度/备注/距离,确保复用后显示最新数据) + bindSimplePositionData(simpleHolder, posModel); + // 2. 重新初始化+绑定简单模式任务视图(复用后从服务同步最新任务,避免数据丢失) + initAndBindSimpleTaskView(simpleHolder.ptlvSimpleTasks, posId, mainService); + // 3. 缓存/更新简单模式任务视图+距离控件(覆盖旧缓存,确保引用最新控件) + mSimpleTaskViewMap.put(posId, simpleHolder.ptlvSimpleTasks); + mPosDistanceViewMap.put(posId, simpleHolder.tvSimpleDistance); + + } else if (holder instanceof EditViewHolder) { + LogUtils.d(TAG, "instanceof EditViewHolder(复用/新创建):位置ID=" + posId); + EditViewHolder editHolder = (EditViewHolder) holder; + // 1. 重新绑定位置基础数据(经纬度/备注/距离/距离开关,复用后显示最新状态) + bindEditPositionData(editHolder, posModel, position); + // 2. 重新初始化+绑定编辑模式任务视图(复用后同步服务最新任务,支持增删改) + initAndBindEditTaskView(editHolder.ptlvEditTasks, posId, mainService, editHolder.btnAddTask); + // 3. 缓存/更新编辑模式任务视图+距离控件(覆盖旧缓存,确保引用最新控件) + 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(); + + // 1. 清理「已脱离视图树/无效」的距离控件缓存(保留有效控件供局部更新) + if (mPosDistanceViewMap.containsKey(posId)) { + TextView distanceView = mPosDistanceViewMap.get(posId); + if (distanceView == null || !distanceView.isAttachedToWindow()) { + mPosDistanceViewMap.remove(posId); + LogUtils.d(TAG, "视图脱离屏幕:移除无效距离控件缓存(位置ID=" + posId + ")"); + } + } + + // 2. 清理「已脱离视图树/无效」的简单模式任务视图缓存(不解绑监听/不清空数据,复用后重新同步) + if (holder instanceof SimpleViewHolder && mSimpleTaskViewMap.containsKey(posId)) { + PositionTaskListView taskView = mSimpleTaskViewMap.get(posId); + if (taskView == null || !taskView.isAttachedToWindow()) { + mSimpleTaskViewMap.remove(posId); + LogUtils.d(TAG, "简单模式视图脱离屏幕:移除无效任务视图缓存(位置ID=" + posId + ")"); + } + } + + // 3. 清理「已脱离视图树/无效」的编辑模式任务视图缓存(同上,保留数据供复用) + if (holder instanceof EditViewHolder && mEditTaskViewMap.containsKey(posId)) { + PositionTaskListView taskView = mEditTaskViewMap.get(posId); + if (taskView == null || !taskView.isAttachedToWindow()) { + mEditTaskViewMap.remove(posId); + LogUtils.d(TAG, "编辑模式视图脱离屏幕:移除无效任务视图缓存(位置ID=" + posId + ")"); + } + } + } + + @Override + public int getItemCount() { + return mCachedPositionList.size(); + } + + // ========================================================================= + // 位置数据绑定(保留原有逻辑,确保复用后数据最新) + // ========================================================================= + /** + * 绑定简单模式位置数据(仅显示,无编辑操作)—— 复用后重新执行,显示最新数据 + */ + private void bindSimplePositionData(SimpleViewHolder holder, final PositionModel posModel) { + // 1. 经纬度显示(保留6位小数,格式统一) + holder.tvSimpleLon.setText(String.format("经度:%.6f", posModel.getLongitude())); + holder.tvSimpleLat.setText(String.format("纬度:%.6f", posModel.getLatitude())); + + // 2. 备注显示(无备注时显示默认文本) + String memo = posModel.getMemo(); + holder.tvSimpleMemo.setText("备注:" + (TextUtils.isEmpty(memo) ? DEFAULT_MEMO : memo)); + + // 3. 实时距离显示(按状态区分文本+颜色——复用后重新计算显示最新距离) + updateDistanceDisplay(holder.tvSimpleDistance, posModel); + + // 4. 点击切换到编辑模式 + holder.itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + posModel.setIsSimpleView(false); + // 精准刷新当前项(避免全量刷新) + notifyItemChanged(getPositionIndexById(posModel.getPositionId())); + LogUtils.d(TAG, "简单视图点击:位置ID=" + posModel.getPositionId() + ",切换到编辑视图"); + } + }); + } + + /** + * 绑定编辑模式位置数据(支持备注修改/距离开关/删除/保存)—— 复用后重新执行,恢复编辑状态 + */ + private void bindEditPositionData(final EditViewHolder holder, final PositionModel posModel, final int position) { + final String posId = posModel.getPositionId(); + + // 1. 经纬度显示(不可编辑,仅展示) + holder.tvEditLon.setText(String.format("经度:%.6f", posModel.getLongitude())); + holder.tvEditLat.setText(String.format("纬度:%.6f", posModel.getLatitude())); + + // 2. 备注编辑(填充现有备注,光标定位到末尾——复用后恢复输入状态) + String memo = posModel.getMemo(); + if (!TextUtils.isEmpty(memo)) { + holder.etEditMemo.setText(memo); + holder.etEditMemo.setSelection(memo.length()); + } else { + holder.etEditMemo.setText(""); + } + + // 3. 实时距离显示(与简单模式逻辑一致——复用后显示最新距离) + updateDistanceDisplay(holder.tvEditDistance, posModel); + + // 4. 距离开关状态(匹配位置模型中的启用状态——复用后恢复开关状态) + holder.rgDistanceSwitch.check(posModel.isEnableRealPositionDistance() + ? R.id.rb_distance_enable + : R.id.rb_distance_disable); + + // 5. 取消编辑:切换回简单模式+隐藏软键盘 + holder.btnCancel.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + posModel.setIsSimpleView(true); + notifyItemChanged(position); + hideSoftKeyboard(v); + LogUtils.d(TAG, "取消编辑:位置ID=" + posId + ",切换回简单视图"); + } + }); + + // 6. 删除位置:回调Activity处理(Adapter不直接操作数据) + holder.btnDelete.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (mOnDeleteListener != null) { + mOnDeleteListener.onDeleteClick(position); + } + hideSoftKeyboard(v); + LogUtils.d(TAG, "触发删除:通知Activity处理位置ID=" + posId + "的删除逻辑"); + } + }); + + // 7. 保存位置:收集参数→更新模型→回调Activity→切换视图 + 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); + + // 回调Activity保存(由Activity同步MainService) + if (mOnSavePosListener != null) { + mOnSavePosListener.onSavePositionClick(position, updatedPos); + } + + // 本地同步状态(避免刷新延迟) + posModel.setMemo(newMemo); + posModel.setIsEnableRealPositionDistance(isDistanceEnable); + posModel.setIsSimpleView(true); + notifyItemChanged(position); + hideSoftKeyboard(v); + LogUtils.d(TAG, "保存位置:ID=" + posId + ",新备注=" + newMemo + ",距离启用=" + isDistanceEnable); + } + }); + } + + // ========================================================================= + // PositionTaskListView 集成(强化复用逻辑:复用后重新同步服务数据,确保数据不丢失) + // ========================================================================= + /** + * 初始化+绑定简单模式任务列表视图——复用后重新执行,从服务同步最新任务 + */ + private void initAndBindSimpleTaskView(PositionTaskListView taskView, final String posId, MainService mainService) { + if (taskView == null || TextUtils.isEmpty(posId) || mainService == null) { + LogUtils.w(TAG, "初始化简单模式任务视图失败:参数无效(posId=" + posId + ",service=" + mainService + ")"); + return; + } + + // 1. 初始化任务视图(绑定MainService+当前位置ID——复用后重新绑定,确保服务引用有效) + taskView.init(mainService, posId); + // 2. 设置为简单模式(仅展示,隐藏编辑按钮——复用后恢复视图模式) + taskView.setViewStatus(PositionTaskListView.VIEW_MODE_SIMPLE); + // 3. 关键:复用后强制从服务同步最新任务(避免显示旧数据,核心修复点) + taskView.syncTasksFromMainService(); + + // 4. 任务更新回调(服务任务变化时重新同步——复用后重新绑定回调,避免监听失效) + taskView.setOnTaskUpdatedListener(new PositionTaskListView.OnTaskUpdatedListener() { + @Override + public void onTaskUpdated(String positionId, ArrayList updatedTasks) { + LogUtils.d(TAG, "简单模式任务更新:位置ID=" + positionId + ",已启用任务数=" + updatedTasks.size()); + } + }); + + LogUtils.d(TAG, "初始化/复用简单模式任务视图完成:位置ID=" + posId); + } + + /** + - 初始化+绑定编辑模式任务列表视图——复用后重新执行,从服务同步最新任务+恢复编辑功能 + */ + private void initAndBindEditTaskView(final PositionTaskListView taskView, final String posId, + MainService mainService, Button btnAddTask) { + if (taskView == null || TextUtils.isEmpty(posId) || mainService == null || btnAddTask == null) { + LogUtils.w(TAG, "初始化编辑模式任务视图失败:参数无效(posId=" + posId + ",btnAddTask=" + btnAddTask + ")"); + return; + } + // 1. 初始化任务视图(绑定MainService+当前位置ID——复用后重新绑定,确保服务引用有效) + taskView.init(mainService, posId); + // 2. 设置为编辑模式(显示编辑/删除按钮——复用后恢复视图模式) + taskView.setViewStatus(PositionTaskListView.VIEW_MODE_EDIT); + // 3. 关键:复用后强制从服务同步最新任务(避免显示旧数据,核心修复点) + taskView.syncTasksFromMainService();// 4. 绑定“新增任务”按钮逻辑(复用后重新绑定点击事件,避免按钮点击失效) + btnAddTask.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + // 构建默认任务模型(关联当前位置ID,使用默认配置) + PositionTaskModel newTask = new PositionTaskModel(); + newTask.setTaskId(PositionTaskModel.genTaskId()); // 生成唯一任务ID(需模型类实现) + newTask.setPositionId(posId); // 绑定当前位置 + newTask.setTaskDescription(DEFAULT_TASK_DESC); // 默认描述 + newTask.setDiscussDistance(DEFAULT_TASK_DISTANCE); // 默认触发距离(50米) + newTask.setIsEnable(true); // 默认启用 + newTask.setIsBingo(false); // 初始未触发// 调用任务视图的新增方法(假设视图已实现addNewTask,内部同步MainService) + taskView.addNewTask(newTask); + hideSoftKeyboard(v); // 隐藏软键盘,提升体验 + LogUtils.d(TAG, "编辑模式新增任务:位置ID=" + posId + ",任务ID=" + newTask.getTaskId()); + } + });// 5. 任务更新回调(通知外部任务变化——复用后重新绑定回调,确保交互正常) + taskView.setOnTaskUpdatedListener(new PositionTaskListView.OnTaskUpdatedListener() { + @Override + public void onTaskUpdated(String positionId, ArrayList updatedTasks) { + LogUtils.d(TAG, "编辑模式任务更新:位置ID=" + positionId + ",当前任务数=" + updatedTasks.size()); + } + }); + LogUtils.d(TAG, "初始化/复用编辑模式任务视图完成:位置ID=" + posId); + } + + // ========================================================================= + // 工具方法(保留原有逻辑,适配资源复用场景) + // ========================================================================= + /** + + - 更新距离显示(复用后重新执行,确保显示最新距离+颜色——适配资源复用) + */ + private void updateDistanceDisplay(TextView distanceView, PositionModel posModel) { + if (distanceView == null || posModel == null) { + LogUtils.w(TAG, "更新距离显示失败:参数为空(控件/位置模型)"); + return; + }// 场景1:距离未启用(灰色文本) + if (!posModel.isEnableRealPositionDistance()) { + distanceView.setText(DISTANCE_DISABLED); + distanceView.setTextColor(mContext.getResources().getColor(R.color.gray)); + return; + }// 场景2:距离计算失败(红色文本,用-1标记失败状态) + double distance = posModel.getRealPositionDistance(); + if (distance < 0) { + distanceView.setText(DISTANCE_ERROR); + distanceView.setTextColor(mContext.getResources().getColor(R.color.red)); + return; + }// 场景3:正常显示距离(按距离范围设置颜色——复用后重新计算颜色) + distanceView.setText(String.format(DISTANCE_FORMAT, distance)); + if (distance <= 100) { + distanceView.setTextColor(mContext.getResources().getColor(R.color.green)); // 近距离(≤100米) + } else if (distance <= 500) { + distanceView.setTextColor(mContext.getResources().getColor(R.color.yellow));// 中距离(≤500米) + } else { + distanceView.setTextColor(mContext.getResources().getColor(R.color.red)); // 远距离(>500米) + } + } + + /** + + - 根据索引获取位置模型(容错处理,避免越界/空指针——适配复用后索引变化场景) + */ + private PositionModel getPositionByIndex(int index) { + if (mCachedPositionList == null || index < 0 || index >= mCachedPositionList.size()) { + LogUtils.w(TAG, "获取位置模型失败:无效索引(" + index + ")或缓存为空"); + return null; + } + return mCachedPositionList.get(index); + } + + /** + + - 根据位置ID获取列表索引(用于精准刷新视图——适配复用后视图位置变化) + */ + private int getPositionIndexById(String positionId) { + if (TextUtils.isEmpty(positionId) || mCachedPositionList == null || mCachedPositionList.isEmpty()) { + LogUtils.w(TAG, "获取位置索引失败:参数无效(ID/缓存为空)"); + return -1; + }// Java 7 增强for循环遍历(替代Lambda,适配语法) + 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; + } + + /** + + - 局部更新距离UI(仅更新指定位置的距离,避免全量刷新卡顿——适配复用后控件缓存有效场景) + */ + public void updateSinglePositionDistance(String positionId) { + if (TextUtils.isEmpty(positionId) || mPosDistanceViewMap.isEmpty()) { + LogUtils.w(TAG, "局部更新距离失败:ID无效或无控件缓存(ID=" + positionId + ")"); + return; + } + // 1. 获取服务端最新位置数据(带重试,避免服务临时回收) + 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()) { + // Java 7 迭代器遍历,避免ConcurrentModificationException + Iterator iter = servicePosList.iterator(); + while (iter.hasNext()) { + PositionModel pos = (PositionModel)iter.next(); + if (positionId.equals(pos.getPositionId())) { + latestPos = pos; + break; + } + } + } + } catch (Exception e) { + LogUtils.d(TAG, "获取最新位置数据失败(ID=" + positionId + ")" + e); + return; + }if (latestPos == null) { + LogUtils.w(TAG, "局部更新距离失败:未找到位置ID=" + positionId); + return; + } + // 2. 更新距离控件(确保主线程操作,避免跨线程异常——适配复用后控件已重新绑定) + 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); + } + }); + LogUtils.d(TAG, "局部更新距离完成:位置ID=" + positionId + ",距离=" + latestPos.getRealPositionDistance() + "米"); + } else { + mPosDistanceViewMap.remove(positionId); // 移除无效控件缓存(如视图已被销毁) + LogUtils.w(TAG, "局部更新距离失败:控件已回收/脱离视图树(ID=" + positionId + ")"); + } + } + + /** + - 全量更新位置数据(从服务同步最新数据,过滤无效/重复项——适配复用后全量刷新场景) + */ + public void updateAllPositionData(ArrayList newPosList) { + if (newPosList == null) { + LogUtils.w(TAG, "全量更新位置数据失败:新列表为空"); + return; + } + // 1. 过滤无效位置(校验核心字段:ID/经纬度合法) + 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) { + LogUtils.w(TAG, "过滤无效位置:ID=" + pos.getPositionId() + "(经纬度/ID非法)"); + continue; + } + validPosList.add(pos); + } + // 2. 去重(按位置ID去重,保留服务端最新数据) + ConcurrentHashMap uniquePosMap = new ConcurrentHashMap(); + for (PositionModel pos : validPosList) { + uniquePosMap.put(pos.getPositionId(), pos); // 相同ID覆盖,保留最新 + } + ArrayList uniquePosList = new ArrayList(uniquePosMap.values());// 3. 同步到本地缓存+刷新UI(刷新后触发onBind,自动重新绑定资源) + this.mCachedPositionList.clear(); + this.mCachedPositionList.addAll(uniquePosList); + // 清空旧控件缓存(避免引用失效数据,刷新后重新缓存新控件) + mPosDistanceViewMap.clear(); + mSimpleTaskViewMap.clear(); + mEditTaskViewMap.clear(); + notifyDataSetChanged(); + LogUtils.d(TAG, "全量更新位置数据完成:原数量=" + newPosList.size() + ",过滤后=" + uniquePosList.size()); + } + + /** + - 隐藏软键盘(编辑完成后调用,提升用户体验——适配复用后软键盘残留问题) + */ + private void hideSoftKeyboard(View view) { + if (mContext == null || view == null) { + LogUtils.w(TAG, "隐藏软键盘失败:参数为空(上下文/视图)"); + return; + }InputMethodManager imm = (InputMethodManager) mContext.getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.hideSoftInputFromWindow(view.getWindowToken(), 0); // 强制隐藏 + } + } + + /** + - 带重试的服务获取(解决弱引用临时回收问题,最多重试2次——适配复用后服务引用失效场景) + */ + private MainService getMainServiceWithRetry(int retryCount) { + MainService mainService = mMainServiceRef.get(); + if (mainService != null) { + return mainService; + } + // 重试逻辑:每次间隔100ms,避免频繁重试 + 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.d(TAG, "重试获取服务时线程被中断" + e); + Thread.currentThread().interrupt(); // 恢复中断状态 + break; + } + }LogUtils.e(TAG, "重试" + retryCount + "次后仍未获取到MainService"); + return null; + } + + // ========================================================================= + // 实现 MainService.TaskUpdateListener 接口(服务任务变化时回调——适配复用后任务同步) + // ========================================================================= + @Override + public void onTaskUpdated() { + LogUtils.d(TAG, "收到MainService任务更新通知,同步所有任务列表视图(含复用视图)"); + + // 1. 同步简单模式任务视图(仅显示已启用任务——适配复用后视图已重新缓存) + 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(); // 移除无效视图缓存(如视图已脱离屏幕且未复用) + } + } + } + + // 2. 同步编辑模式任务视图(支持编辑的全量任务——适配复用后视图已重新缓存) + 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(); // 移除无效视图缓存 + } + } + } + } + + // ========================================================================= + // 回调设置方法(供Activity绑定交互逻辑——适配复用后回调不失效) + // ========================================================================= + public void setOnDeleteClickListener(OnDeleteClickListener listener) { + this.mOnDeleteListener = listener; + } + + public void setOnSavePositionClickListener(OnSavePositionClickListener listener) { + this.mOnSavePosListener = listener; + } + + // ========================================================================= + // 资源释放(Activity销毁时调用,彻底释放引用,避免内存泄漏——保留原有安全逻辑) + // ========================================================================= + public void release() { + // 1. 反注册MainService任务监听(解除服务绑定) + MainService mainService = mMainServiceRef.get(); + if (mainService != null) { + mainService.unregisterTaskUpdateListener(this); + LogUtils.d(TAG, "已反注册MainService任务监听"); + } + + // 2. 释放简单模式任务视图资源(清空数据+解绑监听——仅在Activity销毁时执行,不复用场景) + 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(); + } + } + + // 3. 释放编辑模式任务视图资源(清空数据+解绑监听——仅在Activity销毁时执行) + 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(); + } + } + + // 4. 清空其他缓存+置空引用(彻底释放,避免内存泄漏) + mPosDistanceViewMap.clear(); + if (mCachedPositionList != null) { + mCachedPositionList.clear(); + } + mOnDeleteListener = null; + mOnSavePosListener = null; + + LogUtils.d(TAG, "Adapter资源已完全释放(任务视图资源释放+缓存清空+引用置空)"); + } + + // ========================================================================= + // 静态内部类:ViewHolder(与布局严格对应,避免外部引用导致内存泄漏) + // ========================================================================= + /** + - 简单模式ViewHolder(对应 item_position_simple.xml,含简单模式任务列表视图) + */ + public static class SimpleViewHolder extends RecyclerView.ViewHolder { + TextView tvSimpleLon; // 经度显示 + TextView tvSimpleLat; // 纬度显示 + TextView tvSimpleMemo; // 备注显示 + TextView tvSimpleDistance; // 距离显示 + PositionTaskListView ptlvSimpleTasks; // 简单模式任务列表视图 + public SimpleViewHolder(View itemView) { + super(itemView); + // 绑定布局控件(与XML中ID严格一致,避免运行时空指针) + 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); + } + } + + /** + - 编辑模式ViewHolder(对应 item_position_edit.xml,含编辑模式任务列表视图) + */ + 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); + // 绑定布局控件(与XML中ID严格一致,避免运行时空指针) + 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); + } + } + + // ========================================================================= + // 补充:PositionTaskListView 必要方法适配(确保视图类已实现以下基础方法) + // (若视图类未实现,需在 PositionTaskListView 中添加对应逻辑,否则复用后功能异常) + // ========================================================================= + public static class PositionTaskListViewRequiredMethods { + /** + 1. - init 方法:初始化任务视图(绑定服务+位置ID——复用后重新绑定,确保服务引用有效) + - 需在 PositionTaskListView 中添加: + - public void init(MainService mainService, String positionId) { + - this.mMainServiceRef = new WeakReference(mainService); // 弱引用服务,防泄漏 + - this.mPositionId = positionId; // 绑定当前位置ID(复用后更新为当前位置ID) + - } + */ + + /** + 2. - setViewStatus 方法:设置视图模式(简单/编辑——复用后恢复对应模式UI) + - 需在 PositionTaskListView 中添加: + - public static final int VIEW_MODE_SIMPLE = 0; // 仅显示(隐藏编辑/删除按钮) + - public static final int VIEW_MODE_EDIT = 1; // 可编辑(显示编辑/删除按钮) + - private int mViewMode; + - public void setViewStatus(int viewMode) { + - this.mViewMode = viewMode; + - // 根据模式控制按钮显示:简单模式隐藏编辑按钮,编辑模式显示 + - if (mEditBtn != null) mEditBtn.setVisibility(viewMode == VIEW_MODE_EDIT ? View.VISIBLE : View.GONE); + - if (mDeleteBtn != null) mDeleteBtn.setVisibility(viewMode == VIEW_MODE_EDIT ? View.VISIBLE : View.GONE); + - } + */ + + /** + 3. - syncTasksFromMainService 方法:从服务同步任务数据(复用后核心方法,避免旧数据) + - 需在 PositionTaskListView 中添加: + - public void syncTasksFromMainService() { + - MainService mainService = mMainServiceRef.get(); + - if (mainService == null || TextUtils.isEmpty(mPositionId)) return; + - // 根据视图模式拉取对应任务:简单模式仅拉已启用,编辑模式拉全部 + - ArrayList tasks = (mViewMode == VIEW_MODE_SIMPLE) + - ? mainService.getEnabledTasksByPositionId(mPositionId) + - : mainService.getTasksByPositionId(mPositionId); + - // 更新列表数据(刷新UI,确保复用后显示最新任务) + - if (mTaskAdapter != null) { + - mTaskAdapter.setTaskList(tasks); + - mTaskAdapter.notifyDataSetChanged(); + - } + - // 通知外部任务更新(如Adapter需要联动) + - if (mTaskUpdateListener != null) mTaskUpdateListener.onTaskUpdated(mPositionId, tasks); + - } + */ + + /** + 4. - addNewTask 方法:新增任务(同步服务+刷新列表——复用后功能正常) + - 需在 PositionTaskListView 中添加: + - public void addNewTask(PositionTaskModel newTask) { + - if (newTask == null || TextUtils.isEmpty(newTask.getPositionId())) return; + - MainService mainService = mMainServiceRef.get(); + - if (mainService != null) { + - mainService.addTask(newTask); // 同步到服务(确保数据持久化) + - syncTasksFromMainService(); // 新增后立即同步,刷新列表 + - Toast.makeText(getContext(), "任务新增成功", Toast.LENGTH_SHORT).show(); + - } else { + - Toast.makeText(getContext(), "服务未就绪,新增失败", Toast.LENGTH_SHORT).show(); + - } + - } + */ + + /** + 5. - clearData 方法:清空任务数据(仅在Activity销毁时调用,不复用场景) + - 需在 PositionTaskListView 中添加: + - public void clearData() { + - if (mTaskList != null) mTaskList.clear(); // 清空本地任务列表 + - if (mTaskAdapter != null) mTaskAdapter.notifyDataSetChanged(); // 刷新空UI + - } + */ + + /** + 6. - setOnTaskUpdatedListener 方法:设置任务更新回调(复用后重新绑定,避免回调失效) + - 需在 PositionTaskListView 中添加: + - public interface OnTaskUpdatedListener { + - void onTaskUpdated(String positionId, ArrayList updatedTasks); + - } + - private OnTaskUpdatedListener mTaskUpdateListener; + - public void setOnTaskUpdatedListener(OnTaskUpdatedListener listener) { + - this.mTaskUpdateListener = listener; + - } + */ + } +} 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/MainService.java b/positions/src/main/java/cc/winboll/studio/positions/services/MainService.java new file mode 100644 index 0000000..59b4bc0 --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/services/MainService.java @@ -0,0 +1,1066 @@ +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.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); + } + + // 任务更新监听接口(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定位数据 + + // 服务相关变量(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(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)) { + run(); // 启动服务核心逻辑 + } + } + + /** + * 服务核心逻辑(启动前台服务、初始化GPS、加载数据等) + * 【关键修改】新增定时器初始化,每1分钟调用任务校验 + */ + public void run() { + if (mAppConfigsUtil.isEnableMainService(true)) { + if (!_mIsServiceRunning) { + _mIsServiceRunning = true; + wakeupAndBindAssistant(); // 唤醒并绑定辅助服务 + + // 启动前台服务(Java 7 显式调用,无方法引用) + String initialStatus = "[ Positions ] is in Service."; + 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; + } + // 格式化通知内容(Java 7 String.format,无String.join等Java 8+方法) + final String gpsStatus = String.format( + "GPS位置:北纬%.4f° 东经%.4f° | 可见位置:%d个", + _mCurrentGpsPosition.getLatitude(), + _mCurrentGpsPosition.getLongitude(), + mVisiblePositionIds.size() + ); + // 主线程判断+切换(Java 7 匿名内部类) + if (Looper.myLooper() == Looper.getMainLooper()) { + NotificationUtil.updateForegroundServiceStatus(this, gpsStatus); + } else { + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + NotificationUtil.updateForegroundServiceStatus(MainService.this, gpsStatus); + } + }); + } + } + + + + + // ========================================================================= + // 服务生命周期+辅助服务相关(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) { + 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位置"); + + // 同步GPS位置+刷新距离+日志(原逻辑保留) + syncCurrentGpsPosition(gpsPos); + DistanceCalculatorUtil.getInstance(MainService.this).checkAllTaskTriggerCondition(gpsPos); + LogUtils.d(TAG, "GPS位置更新:纬度=" + location.getLatitude() + ",经度=" + location.getLongitude()); + } + } + + @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; + } + LogUtils.d(TAG, statusDesc); + notifyAllGpsStatusListeners(statusDesc); + updateNotificationGpsStatus(statusDesc); + } + } + + @Override + public void onProviderEnabled(String provider) { + // GPS启用时更新状态+通知+重启定位(Java 7 基础逻辑) + if (provider.equals(LocationManager.GPS_PROVIDER)) { + isGpsEnabled = true; + String statusDesc = "GPS已开启(用户手动打开)"; + LogUtils.d(TAG, statusDesc); + notifyAllGpsStatusListeners(statusDesc); + updateNotificationGpsStatus("GPS已开启,正在获取位置..."); + startGpsLocation(); + } + } + + @Override + public void onProviderDisabled(String provider) { + // GPS禁用时清空状态+通知+提示(Java 7 基础逻辑) + if (provider.equals(LocationManager.GPS_PROVIDER)) { + isGpsEnabled = false; + _mCurrentGpsPosition = null; + String statusDesc = "GPS已关闭(用户手动关闭)"; + LogUtils.w(TAG, statusDesc); + notifyAllGpsStatusListeners(statusDesc); + updateNotificationGpsStatus("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) + */ + private void startGpsLocation() { + 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"); + syncCurrentGpsPosition(lastGpsPos); + LogUtils.d(TAG, "已获取缓存GPS位置:纬度=" + lastKnownLocation.getLatitude()); + } else { + String tip = "无缓存GPS位置,等待实时定位..."; + LogUtils.d(TAG, tip); + notifyAllGpsStatusListeners(tip); + updateNotificationGpsStatus("GPS搜索中(请移至开阔地带)"); + } + + } catch (SecurityException e) { + // 定位权限异常(Java 7 显式捕获,无Lambda异常处理) + String error = "启动GPS失败(权限异常):" + e.getMessage(); + LogUtils.e(TAG, error); + notifyAllGpsStatusListeners(error); + isGpsPermissionGranted = false; + updateNotificationGpsStatus("定位权限异常,无法获取GPS"); + } catch (Exception e) { + // 其他异常(如LocationManager为空、系统服务异常等) + String error = "启动GPS失败:" + e.getMessage(); + LogUtils.e(TAG, error); + notifyAllGpsStatusListeners(error); + updateNotificationGpsStatus("GPS启动失败,尝试重试..."); + } + } + + /** + * 停止GPS定位(Java 7 异常处理,移除监听器避免内存泄漏) + */ + private void stopGpsLocation() { + // 校验参数:避免空指针+权限未授予时调用 + if (mLocationManager != null && mGpsLocationListener != null && isGpsPermissionGranted) { + try { + mLocationManager.removeUpdates(mGpsLocationListener); + String tip = "GPS定位已停止(移除监听器)"; + LogUtils.d(TAG, tip); + notifyAllGpsStatusListeners(tip); + } catch (Exception e) { + String error = "停止GPS失败:" + e.getMessage(); + LogUtils.e(TAG, error); + notifyAllGpsStatusListeners(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) { + // 判断当前线程是否为主线程,避免UI操作在子线程 + if (Looper.myLooper() == Looper.getMainLooper()) { + NotificationUtil.updateForegroundServiceStatus(this, statusText); + } else { + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + NotificationUtil.updateForegroundServiceStatus(MainService.this, statusText); + } + }); + } + } + } + + + // ========================================================================= + // 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..b9661b3 --- /dev/null +++ b/positions/src/main/java/cc/winboll/studio/positions/views/PositionTaskListView.java @@ -0,0 +1,752 @@ +package cc.winboll.studio.positions.views; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/09/30 08:09 + * @Describe 位置任务列表视图(适配MainService唯一数据源+同步任务状态+支持简单/编辑模式) + */ +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.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; + + // 核心成员变量(新增MainService引用,作为唯一数据源) + private String mBindPositionId; + private MainService mMainService; // 持有MainService实例,所有任务数据从服务获取 + private int mCurrentViewMode; + private TaskListAdapter mTaskAdapter; + private RecyclerView mRvTasks; + + // 任务修改回调接口(保留,用于通知外部同步UI) + public interface OnTaskUpdatedListener { + void onTaskUpdated(String positionId, ArrayList updatedTasks); + } + + private OnTaskUpdatedListener mOnTaskUpdatedListener; + + // ---------------------- 构造函数(不变,新增MainService空校验) ---------------------- + public PositionTaskListView(Context context) { + super(context); + initView(context); + } + + public PositionTaskListView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + initView(context); + } + + public PositionTaskListView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initView(context); + } + + // 初始化视图(绑定控件+设置布局,Adapter初始化为空数据) + private void 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)); + + // 初始化为空列表(数据后续从MainService同步) + mTaskAdapter = new TaskListAdapter(new ArrayList()); + mRvTasks.setAdapter(mTaskAdapter); + + mCurrentViewMode = VIEW_MODE_SIMPLE; + LogUtils.d(TAG, "视图初始化完成(等待绑定MainService和位置ID)"); + } + + // ---------------------- 对外API(核心调整:绑定MainService+从服务同步数据) ---------------------- + /** + * 初始化:绑定MainService+关联位置ID(必须先调用此方法,否则无数据) + * @param mainService MainService实例(从Activity传入,确保唯一数据源) + * @param positionId 关联的位置ID(只加载该位置下的任务) + */ + public void init(MainService mainService, String positionId) { + if (mainService == null) { + LogUtils.e(TAG, "init失败:MainService实例为空(需从Activity传入有效服务实例)"); + showToast("任务列表初始化失败:服务未就绪"); + return; + } + if (positionId == null || positionId.trim().isEmpty()) { + LogUtils.e(TAG, "init失败:位置ID为空(需关联有效位置)"); + showToast("任务列表初始化失败:未关联位置"); + return; + } + + // 绑定服务实例+位置ID + this.mMainService = mainService; + this.mBindPositionId = positionId; + LogUtils.d(TAG, "已绑定MainService和位置ID:" + positionId); + + // 从MainService同步当前位置的任务(核心:数据来源改为服务) + syncTasksFromMainService(); + } + + /** + * 从MainService同步当前位置的任务(核心方法,所有数据加载入口) + * 作用:1. 清空本地缓存→2. 从服务获取全量任务→3. 筛选当前位置任务→4. 刷新Adapter + */ + public void syncTasksFromMainService() { + // 安全校验(服务未绑定/位置ID为空,不执行同步) + if (mMainService == null || mBindPositionId == null || mBindPositionId.trim().isEmpty()) { + LogUtils.w(TAG, "同步任务失败:MainService未绑定或位置ID无效"); + return; + } + + try { + // 1. 从MainService获取全量任务(服务是唯一数据源,避免本地缓存不一致) + ArrayList allServiceTasks = mMainService.getAllTasks(); + LogUtils.d(TAG, "从MainService获取全量任务数:" + (allServiceTasks == null ? 0 : allServiceTasks.size())); + + // 2. 筛选当前位置关联的任务(只保留与mBindPositionId匹配的任务) + ArrayList currentPosTasks = new ArrayList(); + if (allServiceTasks != null && !allServiceTasks.isEmpty()) { + for (PositionTaskModel task : allServiceTasks) { + if (isTaskMatchedWithPosition(task)) { + currentPosTasks.add(task); + } + } + } + LogUtils.d(TAG, "筛选后当前位置任务数:" + currentPosTasks.size()); + + // 3. 更新Adapter数据(直接替换数据源,避免本地缓存) + mTaskAdapter.updateData(currentPosTasks); + LogUtils.d(TAG, "从MainService同步任务完成(Adapter已刷新)"); + + } catch (Exception e) { + LogUtils.d(TAG, "同步任务失败(MainService调用异常):" + e.getMessage()); + showToast("任务同步失败,请重试"); + } + } + + // 视图模式切换(不变,刷新Adapter触发视图类型变更) + public void setViewStatus(int viewMode) { + if (viewMode != VIEW_MODE_SIMPLE && viewMode != VIEW_MODE_EDIT) { + LogUtils.w(TAG, "设置视图模式失败:无效模式(仅支持简单/编辑模式)"); + return; + } + mCurrentViewMode = viewMode; + mTaskAdapter.notifyDataSetChanged(); + LogUtils.d(TAG, "已切换视图模式:" + (viewMode == VIEW_MODE_SIMPLE ? "简单模式" : "编辑模式")); + } + + // 保留回调接口(用于通知外部UI刷新,如Activity更新列表) + public void setOnTaskUpdatedListener(OnTaskUpdatedListener listener) { + this.mOnTaskUpdatedListener = listener; + LogUtils.d(TAG, "已设置任务更新回调监听"); + } + + /** + * 获取当前位置的任务(从Adapter数据源获取,而非本地缓存) + * @return 当前位置任务列表(新集合,避免外部修改数据源) + */ + public ArrayList getCurrentPosTasks() { + return new ArrayList(mTaskAdapter.getAdapterData()); + } + + /** + * 清空数据(解绑服务+清空位置ID+重置Adapter) + * 场景:视图销毁/切换位置时调用,避免数据残留 + */ + public void clearData() { + // 1. 清空Adapter数据源 + mTaskAdapter.updateData(new ArrayList()); + // 2. 解绑服务+位置ID(避免下次使用时引用旧数据) + this.mMainService = null; + this.mBindPositionId = null; + // 3. 重置视图模式 + mCurrentViewMode = VIEW_MODE_SIMPLE; + LogUtils.d(TAG, "数据已清空(解绑服务+重置视图)"); + } + + /** + * 主动触发任务同步(强制从服务拉取最新数据,刷新视图) + * 场景:外部操作后(如新增任务),调用此方法更新列表 + */ + public void triggerTaskSync() { + LogUtils.d(TAG, "主动触发任务同步(从MainService拉取最新数据)"); + syncTasksFromMainService(); + + // 通知外部(如Activity)任务已更新(可选,根据业务需求) + if (mOnTaskUpdatedListener != null && mBindPositionId != null) { + mOnTaskUpdatedListener.onTaskUpdated(mBindPositionId, getCurrentPosTasks()); + } + } + + // ---------------------- 内部工具方法(新增服务空校验) ---------------------- + private static final String TAG = "PositionTaskListView"; + + /** + * 校验任务是否与当前绑定的位置匹配 + * @param task 待校验的任务 + * @return true=匹配(任务位置ID=当前绑定位置ID),false=不匹配 + */ + private boolean isTaskMatchedWithPosition(PositionTaskModel task) { + if (task == null || mBindPositionId == null || mBindPositionId.trim().isEmpty()) { + return false; + } + // 严格匹配任务的位置ID(确保只加载当前位置的任务) + return mBindPositionId.equals(task.getPositionId()); + } + + /** + * 显示Toast(简化调用,避免重复代码) + */ + private void showToast(String content) { + if (getContext() == null) return; + Toast.makeText(getContext(), content, Toast.LENGTH_SHORT).show(); + } + + // ---------------------- 内部Adapter:适配MainService数据源(核心调整) ---------------------- + private class TaskListAdapter extends RecyclerView.Adapter { + // Adapter数据源(仅保留一份,直接从MainService同步,无本地冗余) + private List mAdapterData; + + // 初始化Adapter(空数据源) + public TaskListAdapter(List data) { + this.mAdapterData = new ArrayList(data); // 防御性拷贝,避免外部修改 + } + + /** + * 更新Adapter数据源(核心:从MainService同步后调用,替换数据源并刷新) + * @param newData 从MainService筛选后的当前位置任务列表 + */ + public void updateData(List newData) { + if (newData == null) { + this.mAdapterData.clear(); + } else { + this.mAdapterData = new ArrayList(newData); // 防御性拷贝 + } + notifyDataSetChanged(); // 刷新列表(数据源已替换,确保显示最新数据) + } + + /** + * 获取Adapter当前数据源(对外提供,避免直接操作mAdapterData) + * @return 当前数据源(新集合,避免外部修改) + */ + public List getAdapterData() { + return new ArrayList(mAdapterData); + } + + // getItemCount:空列表显示1个“空提示”项,非空显示任务数 + @Override + public int getItemCount() { + return mAdapterData.isEmpty() ? 1 : mAdapterData.size(); + } + + // getItemViewType:按“空列表/简单模式/编辑模式”区分视图类型(不变) + @Override + public int getItemViewType(int position) { + if (mAdapterData.isEmpty()) { + return 0; // 0=空提示视图 + } else { + return mCurrentViewMode; // 1=简单模式视图,2=编辑模式视图 + } + } + + // onCreateViewHolder:按视图类型加载对应布局(不变,确保布局与模式匹配) + @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) { + // 简单模式布局(带isBingo红点,仅展示) + View simpleTaskView = inflater.inflate(R.layout.item_position_task_simple, parent, false); + return new SimpleTaskViewHolder(simpleTaskView); + } else { + // 编辑模式布局(带编辑/删除按钮+启用开关,支持修改) + View editTaskView = inflater.inflate(R.layout.item_task_content, parent, false); + return new TaskContentViewHolder(editTaskView); + } + } + + // onBindViewHolder:按视图类型绑定数据(核心调整:操作后同步MainService) + @Override + public void onBindViewHolder(@NonNull TaskViewHolder holder, int position) { + // 1. 空提示视图绑定(根据模式显示不同提示文案) + if (holder instanceof EmptyViewHolder) { + EmptyViewHolder emptyHolder = (EmptyViewHolder) holder; + TextView tvEmptyTip = emptyHolder.itemView.findViewById(R.id.tv_task_empty_tip); + tvEmptyTip.setText(mCurrentViewMode == VIEW_MODE_EDIT + ? "暂无任务,点击\"添加新任务\"创建" + : "暂无启用的任务"); + return; + } + + // 2. 任务项有效性校验(避免越界/空数据) + if (position >= mAdapterData.size()) { + LogUtils.w(TAG, "绑定任务数据失败:位置索引越界(position=" + position + ",数据量=" + mAdapterData.size() + ")"); + return; + } + final PositionTaskModel task = mAdapterData.get(position); + if (task == null) { + LogUtils.w(TAG, "绑定任务数据失败:第" + position + "项任务为空"); + return; + } + + // 3. 简单模式绑定(仅展示,无修改操作,不变) + if (holder instanceof SimpleTaskViewHolder) { + SimpleTaskViewHolder simpleHolder = (SimpleTaskViewHolder) holder; + // 任务描述 + String taskDesc = task.getTaskDescription() == null ? "未设置描述" : task.getTaskDescription(); + simpleHolder.tvSimpleTaskDesc.setText(String.format("任务:%s", taskDesc)); + simpleHolder.tvStartTime.setText(genSelectedTimeText(task.getStartTime())); + // 距离条件(大于/小于+距离值) + String distanceCond = task.isGreaterThan() ? "大于" : "小于"; + simpleHolder.tvSimpleDistanceCond.setText(String.format("条件:距离 %s %d 米", distanceCond, task.getDiscussDistance())); + // 启用状态 + simpleHolder.tvSimpleIsEnable.setText(task.isEnable() ? "状态:已启用" : "状态:已禁用"); + // isBingo红点(任务触发时显示,未触发时隐藏) + simpleHolder.vBingoDot.setVisibility(task.isBingo() ? View.VISIBLE : View.GONE); + } else if (holder instanceof TaskContentViewHolder) { + TaskContentViewHolder contentHolder = (TaskContentViewHolder) holder; + bindEditModeTask(contentHolder, task, position); + } + } + + /** + * 编辑模式任务绑定(核心:修改操作→更新MainService→刷新Adapter) + */ + private void bindEditModeTask(final TaskContentViewHolder holder, final PositionTaskModel task, final int position) { + // 4.1 绑定基础数据(描述+距离条件) + String taskDesc = task.getTaskDescription() == null ? "未设置描述" : task.getTaskDescription(); + holder.tvTaskDesc.setText(String.format("任务:%s", taskDesc)); + String distanceCond = task.isGreaterThan() ? "大于" : "小于"; + holder.tvTaskDistance.setText(String.format("条件:%s %d 米", distanceCond, task.getDiscussDistance())); + holder.tvStartTime.setText(genSelectedTimeText(task.getStartTime())); + + // 4.2 绑定“启用开关”(修复:先解绑监听→设值→再绑定监听,避免设值触发回调) + holder.cbTaskEnable.setOnCheckedChangeListener(null); + holder.cbTaskEnable.setChecked(task.isEnable()); + holder.cbTaskEnable.setEnabled(mCurrentViewMode == VIEW_MODE_EDIT); // 编辑模式才允许操作 + + // 4.3 编辑模式特有:显示编辑/删除按钮(仅编辑模式可见) + holder.btnEditTask.setVisibility(View.VISIBLE); + holder.btnDeleteTask.setVisibility(View.VISIBLE); + + // 4.4 删除按钮逻辑(核心:删除→同步MainService→刷新Adapter) + holder.btnDeleteTask.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (mMainService == null) { + showToast("删除失败:服务未就绪"); + LogUtils.e(TAG, "删除任务失败:MainService实例为空"); + return; + } + + try { + // 步骤1:调用MainService删除任务(服务是唯一数据源,确保数据一致性) + mMainService.deleteTask(task.getTaskId()); // 需在MainService中实现deleteTask()方法(删除服务内全量任务列表中的对应项) + LogUtils.d(TAG, "调用MainService删除任务:ID=" + task.getTaskId() + "(位置ID=" + mBindPositionId + ")"); + + // 步骤2:从Adapter数据源移除任务(避免等待同步,立即反馈UI) + //mAdapterData.remove(position); + // 步骤3:刷新Adapter(局部刷新+范围通知,避免列表错乱) + notifyItemRemoved(position); + notifyItemRangeChanged(position, mAdapterData.size()); + + LogUtils.d(TAG, "Adapter已移除任务,刷新列表(位置索引=" + position + ")"); + + // 步骤4:通知外部(如Activity)任务已更新 + if (mOnTaskUpdatedListener != null && mBindPositionId != null) { + mOnTaskUpdatedListener.onTaskUpdated(mBindPositionId, new ArrayList(mAdapterData)); + } + showToast("任务已删除(已同步至服务)"); + + } catch (Exception e) { + LogUtils.d(TAG, "删除任务失败(服务调用/Adapter刷新异常):" + e.getMessage()); + showToast("删除失败,请重试"); + // 异常时重新同步数据(确保Adapter与服务一致) + syncTasksFromMainService(); + } + } + }); + + // 4.5 编辑按钮逻辑(核心:修改后同步MainService→刷新Adapter) + holder.btnEditTask.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (mMainService == null) { + showToast("编辑失败:服务未就绪"); + LogUtils.e(TAG, "编辑任务失败:MainService实例为空"); + return; + } + // 弹出编辑弹窗(修改后同步服务) + showEditTaskDialog(task, position); + } + }); + + // 4.6 启用开关逻辑(核心:状态变更→同步MainService→刷新Adapter) + holder.cbTaskEnable.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, final boolean isChecked) { + if (mMainService == null) { + showToast("状态修改失败:服务未就绪"); + LogUtils.e(TAG, "修改任务启用状态失败:MainService实例为空"); + // 回滚开关状态(避免UI与服务不一致) + buttonView.setChecked(!isChecked); + return; + } + + try { + // 步骤1:更新任务状态(先改内存数据,确保UI反馈及时) + task.setIsEnable(isChecked); + LogUtils.d(TAG, "更新任务启用状态:ID=" + task.getTaskId() + ",新状态=" + (isChecked ? "启用" : "禁用")); + + // 步骤2:调用MainService同步状态(服务是唯一数据源,持久化变更) + mMainService.updateTaskStatus(task); // 需在MainService中实现updateTaskStatus()方法(更新服务内任务的isEnable字段) + LogUtils.d(TAG, "调用MainService同步任务状态(已" + (isChecked ? "启用" : "禁用") + ")"); + + // 步骤3:延迟刷新Adapter(避免列表滚动/布局计算时异常) + 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.d(TAG, "修改任务启用状态失败:" + e.getMessage()); + showToast("状态修改失败,请重试"); + // 回滚状态(UI与服务保持一致) + buttonView.setChecked(!isChecked); + task.setIsEnable(!isChecked); + // 重新同步数据(修复可能的不一致) + syncTasksFromMainService(); + } + } + }); + } + + private String genSelectedTimeText(long timeMillis) { + // 2. 格式化时间字符串(Java 7 用 SimpleDateFormat,需处理 ParseException) + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()); + String formattedDateTime = sdf.format(new Date(timeMillis)); // Date 需导入 java.util.Date + + return formattedDateTime; + } + + /** + * 编辑任务弹窗(核心:保存修改→同步MainService→刷新Adapter) + */ + private void showEditTaskDialog(final PositionTaskModel task, final int position) { + final Context context = getContext(); + if (context == null) { + LogUtils.w(TAG, "编辑弹窗无法显示:上下文为空"); + 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 = (Button) dialogView.findViewById(R.id.btn_select_time); + final TextView tv_SelectedTime = (TextView) dialogView.findViewById(R.id.tv_selected_time); + + tv_SelectedTime.setText(genSelectedTimeText(task.getStartTime())); + + // 核心:从 long 时间戳解析年月日时分,用于初始化弹窗 + // -------------------------- + Calendar initCalendar = Calendar.getInstance(); + initCalendar.setTimeInMillis(task.getStartTime()); // 将 long 时间戳传入 Calendar + + // 从 Calendar 中解析出年月日时分(对应弹窗需要的参数格式) + int initYear = initCalendar.get(Calendar.YEAR); + int initMonth = initCalendar.get(Calendar.MONTH) + 1; // 关键:Calendar 月份0-11,转成1-12 + int initDay = initCalendar.get(Calendar.DAY_OF_MONTH); + int initHour = initCalendar.get(Calendar.HOUR_OF_DAY); // 24小时制 + int initMinute = initCalendar.get(Calendar.MINUTE); + + // 初始化弹窗,用解析出的年月日时分设置默认选中时间 + final DateTimePickerPopup dateTimePopup = new DateTimePickerPopup.Builder(context) + .setDateTimeRange(2020, 2030, 1, 12, 1, 31, 0, 23, 0, 59) + // 用 long 时间戳解析出的参数设置初始时间 + .setDefaultDateTime(initYear, initMonth, initDay, initHour, initMinute) + .setOnDateTimeSelectedListener(new DateTimePickerPopup.OnDateTimeSelectedListener() { + @Override + public void onDateTimeSelected(int year, int month, int day, int hour, int minute) { + // 处理选择的日期时间 + // 1. 创建 Calendar 实例(用于组装日期时间) + Calendar calendar = Calendar.getInstance(); + // 2. 设置 Calendar 的年、月、日、时、分(注意:Calendar 月份从 0 开始,需减 1) + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, month - 1); // 关键:传入的 month 是 1-12,需转为 0-11 + calendar.set(Calendar.DAY_OF_MONTH, day); + calendar.set(Calendar.HOUR_OF_DAY, hour); // 24小时制,对应参数中的 hour(0-23) + calendar.set(Calendar.MINUTE, minute); + calendar.set(Calendar.SECOND, 0); // 秒默认设为 0,避免随机值 + calendar.set(Calendar.MILLISECOND, 0); // 毫秒默认设为 0,确保时间戳精确 + + // 3. 转为 long 类型时间戳(单位:毫秒,从 1970-01-01 00:00:00 开始计算) + long timeMillis = calendar.getTimeInMillis(); + + // 4. 后续使用(示例:打印时间戳或传递给其他逻辑) + tv_SelectedTime.setText(genSelectedTimeText(timeMillis)); + task.setStartTime(timeMillis); + } + + @Override + public void onCancel() { + // 处理取消操作 + } + }) + .build(); + + // 3. “选择时间”按钮点击事件(弹出 TimePickerPopup) + btnSelectTime.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final 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 onSateSetListener = new OnDateSetListener(){ + @Override + public void onDateSet(TimePickerDialog timePickerDialog, long p) { + } + }; + + // 取消按钮:关闭弹窗(不做操作) + btnCancel.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + dialog.dismiss(); + } + }); + + // 保存按钮:校验输入→更新任务→同步服务→刷新UI + btnSave.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + // 1. 输入校验(避免无效数据) + String newDesc = etEditDesc.getText().toString().trim(); + String distanceStr = etEditDistance.getText().toString().trim(); + + if (distanceStr.isEmpty()) { + showToast("请输入有效距离(1米及以上)"); + etEditDistance.requestFocus(); + return; + } + + int newDistance; + try { + newDistance = Integer.parseInt(distanceStr); + if (newDistance < 1) { + showToast("距离不能小于1米"); + etEditDistance.requestFocus(); + return; + } + } catch (NumberFormatException e) { + showToast("距离请输入数字"); + etEditDistance.requestFocus(); + return; + } + + // 2. 收集新数据(更新任务对象) + task.setTaskDescription(newDesc); // 新描述 + task.setDiscussDistance(newDistance); // 新距离 + boolean isGreater = rgDistanceCondition.getCheckedRadioButtonId() == R.id.rb_greater_than; + task.setIsGreaterThan(isGreater); // 新距离条件(大于/小于) + task.setPositionId(mBindPositionId); // 确保位置ID不变(防止错位) + + try { + // 3. 调用MainService同步修改(服务是唯一数据源,持久化变更) + mMainService.updateTask(task); // 需在MainService中实现updateTask()方法(更新服务内任务的字段) + LogUtils.d(TAG, "调用MainService更新任务:ID=" + task.getTaskId() + "(描述=" + newDesc + ",距离=" + newDistance + "米)"); + + // 4. 更新Adapter数据源(立即反馈UI) + mAdapterData.set(position, task); + // 5. 延迟刷新Adapter(避免弹窗未关闭时布局异常) + 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.d(TAG, "保存任务修改失败:" + e.getMessage()); + showToast("保存失败,请重试"); + // 重新同步数据(修复可能的不一致) + syncTasksFromMainService(); + } + } + }); + } + + // ---------------------- ViewHolder 定义(完全适配布局,无修改) ---------------------- + // 基础抽象ViewHolder(统一父类,适配多视图类型) + public abstract class TaskViewHolder extends RecyclerView.ViewHolder { + public TaskViewHolder(@NonNull View itemView) { + super(itemView); + } + } + + // 空提示ViewHolder(对应 item_task_empty.xml) + public class EmptyViewHolder extends TaskViewHolder { + public EmptyViewHolder(@NonNull View itemView) { + super(itemView); + } + } + + // 简单模式ViewHolder(对应 item_position_task_simple.xml,带isBingo红点) + public class SimpleTaskViewHolder extends TaskViewHolder { + TextView tvSimpleTaskDesc; // 任务描述 + TextView tvSimpleDistanceCond;// 距离条件(大于/小于+距离) + TextView tvStartTime; + TextView tvSimpleIsEnable; // 启用状态(已启用/已禁用) + View vBingoDot; // isBingo红点(任务触发时显示) + + public SimpleTaskViewHolder(@NonNull View itemView) { + super(itemView); + // 绑定简单模式布局控件(与XML控件ID严格对应) + 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); + } + } + + // 编辑模式ViewHolder(对应 item_task_content.xml,带编辑/删除/开关) + public class TaskContentViewHolder extends TaskViewHolder { + TextView tvTaskDesc; // 任务描述 + TextView tvTaskDistance; // 距离条件 + TextView tvStartTime; + CompoundButton cbTaskEnable; // 启用开关 + Button btnEditTask; // 编辑按钮 + Button btnDeleteTask; // 删除按钮 + + public TaskContentViewHolder(@NonNull View itemView) { + super(itemView); + // 绑定编辑模式布局控件(与XML控件ID严格对应) + 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); + } + } + } + + // ---------------------- 新增:外部调用“新增任务”方法(适配MainService) ---------------------- + /** + * 新增任务(对外提供,如Activity调用添加任务) + * @param newTask 待新增的任务(需关联当前位置ID) + */ + public void addNewTask(PositionTaskModel newTask) { + if (mMainService == null) { + showToast("新增任务失败:服务未就绪"); + LogUtils.e(TAG, "新增任务失败:MainService实例为空"); + return; + } + if (newTask == null) { + showToast("新增任务失败:任务数据为空"); + LogUtils.e(TAG, "新增任务失败:待新增任务对象为空"); + return; + } + if (mBindPositionId == null || mBindPositionId.trim().isEmpty()) { + showToast("新增任务失败:未关联位置"); + LogUtils.e(TAG, "新增任务失败:未绑定位置ID(需先调用init()方法)"); + return; + } + + try { + // 1. 关联任务到当前位置(确保任务属于当前位置) + newTask.setPositionId(mBindPositionId); + // 2. 调用MainService新增任务(服务是唯一数据源,持久化数据) + mMainService.addTask(newTask); // 需在MainService中实现addTask()方法(添加到服务内全量任务列表) + LogUtils.d(TAG, "调用MainService新增任务:ID=" + newTask.getTaskId() + "(位置ID=" + mBindPositionId + ")"); + + // 3. 重新同步数据(从服务拉取最新列表,避免本地计算错误) + syncTasksFromMainService(); + // 4. 通知外部任务已更新 + if (mOnTaskUpdatedListener != null) { + mOnTaskUpdatedListener.onTaskUpdated(mBindPositionId, getCurrentPosTasks()); + } + showToast("新增任务成功(已同步至服务)"); + + } catch (Exception e) { + LogUtils.d(TAG, "新增任务失败:" + e.getMessage()); + showToast("新增失败,请重试"); + // 重新同步数据(修复可能的不一致) + syncTasksFromMainService(); + } + } +} + 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/layout/activity_location.xml b/positions/src/main/res/layout/activity_location.xml new file mode 100644 index 0000000..e7842f6 --- /dev/null +++ b/positions/src/main/res/layout/activity_location.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + +