diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-action.yml similarity index 78% rename from .github/workflows/build-check.yml rename to .github/workflows/build-action.yml index bae4dd7f..0fe9d3de 100644 --- a/.github/workflows/build-check.yml +++ b/.github/workflows/build-action.yml @@ -1,5 +1,5 @@ # name 字段用于指定 Workflow 的名字 -name: Build Check +name: build and get apk # 在 on 字段下列举触发条件(事件),可以有多种 on: @@ -26,7 +26,7 @@ jobs: build: # 该 job 运行的系统环境,支持 ubuntu 、windows、macOS runs-on: ubuntu-latest - + # 该 job 的一系列步骤。每个以“-”开头 steps: # 检出我们的源代码 @@ -39,6 +39,7 @@ jobs: java-version: '17' distribution: 'adopt' cache: gradle + # 创建 local.properties 文件 - name: Create local.properties run: | @@ -46,10 +47,22 @@ jobs: MAPS_API_KEY=${{ secrets.MAPS_API_KEY }} MAPS_SAFE_CODE=${{ secrets.MAPS_SAFE_CODE }} EOF + # 给 gradlew 增加执行权限 - name: Grant execute permission for gradlew run: chmod +x gradlew - - # 启动构建 + + # 启动构建(检查项目是否能编译通过) - name: Build with Gradle run: ./gradlew build + + # 构建 Debug APK(用于下载测试) + - name: Build Debug APK + run: ./gradlew assembleDebug + + # 上传 Debug APK 作为可下载的工件 + - name: Upload Debug APK + uses: actions/upload-artifact@v4 + with: + name: app-debug + path: app/build/outputs/apk/debug/*.apk \ No newline at end of file diff --git a/.gitignore b/.gitignore index a6b86156..7177b8f7 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,18 @@ captures/ # Google Services (e.g. APIs or Firebase) google-services.json + +# Eclipse / Buildship (project uses Gradle; these are IDE-generated) +.project +.classpath +.settings/ + +# VS Code +.vscode/ + +# Java heap dumps +*.hprof + +# OS files +.DS_Store +Thumbs.db diff --git a/.project b/.project deleted file mode 100644 index fdcd02d7..00000000 --- a/.project +++ /dev/null @@ -1,28 +0,0 @@ - - - GoGoGo - Project GoGoGo created by Buildship. - - - - - org.eclipse.buildship.core.gradleprojectbuilder - - - - - - org.eclipse.buildship.core.gradleprojectnature - - - - 1604809825384 - - 30 - - org.eclipse.core.resources.regexFilterMatcher - node_modules|.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ - - - - diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 9c7f9645..00000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,128 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the - overall community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or - advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email - address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -zcsexp@gmail.com. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series -of actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. - -Community Impact Guidelines were inspired by [Mozilla's code of conduct -enforcement ladder](https://github.com/mozilla/diversity). - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. diff --git a/README.en.md b/README.en.md deleted file mode 100644 index 94e79486..00000000 --- a/README.en.md +++ /dev/null @@ -1,29 +0,0 @@ -

- -

- -
- -[![GitHub stars](https://img.shields.io/github/stars/ZCShou/GoGoGo?logo=github)](https://github.com/ZCShou/GoGoGo/stargazers) -[![GitHub forks](https://img.shields.io/github/forks/ZCShou/GoGoGo?logo=github)](https://github.com/ZCShou/GoGoGo/network) -[![license](https://img.shields.io/github/license/ZCShou/GoGoGo)](https://github.com/ZCShou/GoGoGo/blob/master/LICENSE) -[![GitHub Release](https://img.shields.io/github/v/release/ZCShou/GoGoGo?label=Release)](https://github.com/ZCShou/GoGoGo/releases) -[![standard-readme compliant](https://img.shields.io/badge/readme%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/RichardLitt/standard-readme) -
-
- -[![Build Check](https://github.com/ZCShou/GoGoGo/actions/workflows/build-check.yml/badge.svg)](https://github.com/ZCShou/GoGoGo/actions/workflows/build-check.yml) -[![CodeQL](https://github.com/ZCShou/GoGoGo/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/ZCShou/GoGoGo/actions/workflows/codeql-analysis.yml) -
- -
-GoGoGo - A mock location app without root on android 8.0+. -
- -## - -[Refrence Chinese, please](./README.md) - -## License -GPL-3.0-only © ZCShou - diff --git a/README.md b/README.md index e054bb9a..1dc8b1f5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,3 @@ -

- -

@@ -20,31 +17,18 @@ 影梭 - 用于 Android 8.0+ 的无需 ROOT 权限的虚拟定位 APP
-## 简介 -  影梭是一个基于 Android 调试 API + 百度地图及定位 SDK 实现的安卓定位修改工具,并且同时实现了一个可以自由控制移动的摇杆。使用影梭,不需要 ROOT 权限就可以随意修改自己的当前位置以及模拟移动。 - -1. 源码仓库:[Github](https://github.com/ZCShou/GoGoGo)(推荐)、[Gitee](https://gitee.com/itexp/gogogo)(镜像) -2. 下载地址:[Github](https://github.com/ZCShou/GoGoGo/releases)(推荐)、[Gitee](https://gitee.com/itexp/gogogo/releases)(镜像) +## 特别鸣谢ZCShou -## 警告一 -  **最近,有网友直接白嫖影梭后改名为标枪定位,然后添加广告(除了加广告,功能没有任何改变),但是,没有按照 GPLv3 协议的要求进行开源(我已经联系过该网友进了提醒,但并没有收到回复),在此提醒:** -1. **开源 ≠ 白嫖,请遵循开源协议** -2. **GPL 的法律效力在国内相关诉讼案例很多,请自行搜索,权衡利弊。影梭保留追究相关侵权人员法律责任的所有权利!** -3. **开源不易,且行且珍惜** +## 警告 有任何问题请直接提交issue 或是删除 或是修改 禁止商用行为 -## 警告二 -  **最近,有很多人将影梭用在校园运动类 APP(包括但不限于闪动校园、TakeTwo、运动世界校园等)中作弊,开发者也收到了很多人提问为何影梭定位并不起作用或者寻求对影梭的改进,在此提醒:** -1. **影梭不支持任何校园运动类 APP 的作弊行为** -2. **影梭开发者也不赞同采用任何形式在校园运动中作弊** +## 简介 +  影梭是一个基于 Android 调试 API实现的安卓定位修改工具,并且同时实现了一个可以自由控制移动的摇杆。使用影梭,不需要 ROOT 权限就可以随意修改自己的当前位置以及模拟移动。 -## 背景 -  之前在玩一款 VR 游戏:一起来捉妖。为了省事,就想有没有可以更改位置的 APP。经过一番摸索发现确实有不少可以修改位置的 APP。但是,绝大多数这种 APP 都是收费的,而且贼贵! -  我比较感兴趣的是这样的技术是如何实现的,因此,决定研究研究自己写一个!现在游戏已经弃坑了,但是技术不能丢。因此,将研究结果开源出来方便大家一起学习!但是请注意(重要的事情说三遍!否则后果自负): +## 个人Fork原因及背景 +  将影梭用在校园运动跑步类app(步道乐跑 为本人学校使用) -1. 该 APP 仅仅是为了学习 Android + 百度地图的实现方法,请勿用于游戏作弊! -2. 该 APP 仅仅是为了学习 Android + 百度地图的实现方法,请勿用于游戏作弊! -3. 该 APP 仅仅是为了学习 Android + 百度地图的实现方法,请勿用于游戏作弊! +  原本的那个没有脚本 用起来好累 我就自己借用工具进行了修改 ## 功能 1. 定位修改 @@ -52,21 +36,32 @@ 3. 历史记录 4. 位置搜索 5. 直接输入坐标 +6. 脚本模式(按脚本预设自动移动) + +## 脚本模式 +  脚本模式可以先把一串“带移动状态的位置”写成脚本(走 / 跑 / 骑),启动后模拟位置就会按脚本自动移动,不需要手动摇杆。 -## 截图 -![joystick.jpg](./docs/images/joystick.jpg) -![search_history.jpg](./docs/images/search_history.jpg) -![map.jpg](./docs/images/map.jpg) +**脚本格式**:一行一个路点,`状态 经度 纬度 [速度] [停留秒数]`,`#` 或 `//` 开头为注释行。 + +``` +# 先步行,再跑步,最后骑车绕一圈 +walk 116.397 39.908 # 不写速度时使用“设置”里的步行速度 +run 116.401 39.910 4.5 # 跑步,4.5 米/秒 +bike 116.405 39.912 20km/h # 骑行,也可以写 km/h +walk 116.410 39.910 1.2 10 # 到达后原地停留 10 秒 +``` + +1. 状态支持 `步行/跑步/骑行`(`walk/run/bike`),也可以写 `速度4.5`、`停留10` 这类标签形式; +2. 坐标系可选 `GPS(WGS-84)` 与 `BD09`,两套坐标的值域重叠、无法自动识别,所以需要自己选对; +3. 每个脚本可以单独设置“单次跑完停止”或者“循环”; +4. 编辑页支持直接粘贴经纬度,也可以点地图选点(按当前选中的状态插入到光标处)。 ## 用法 1. 下载 APK 直接安装 2. 启动影梭,赋予相关权限 3. 单击地图位置,然后点击启动按钮 -## 文档 -  由于本人并不是做移动开发的,很多功能代码写的都比较差。我也第一次写 Android APP,目前还处在学习中。。。此外,就一个简单的 APP,应该也不需要啥文档,开发过程中遇到的一些问题,我一般都会记录在个人博客中,具体参见:https://blog.csdn.net/zcshoucsdn/category_10559121.html -  如果有疑问可以直接搜索 ISSUE 或者 在上面直接提交问题。 ## 参考   由于本人也是个新手,纯属业余瞎搞,因此,在写影梭的过程中,参考了很多网友分享的技术文章、示例代码等。包括但不限于以下列出的几个: @@ -85,9 +80,6 @@ Q:为何定位不是很稳定,偶尔会飘回真实位置? A:这是是由于实现原理导致的,Android 调试 API 固有的问题。确切的说,应该是由于手机本身还开启了其他定位方式(例如,基站定位、wifi定位等)导致的 -Q:是否支持鸿蒙系统? - -A:经过测试,影梭可以在鸿蒙系统上正常运行。 Q:为何在微信等腾讯系应用上定位不起作用? diff --git a/app/.classpath b/app/.classpath deleted file mode 100644 index 2c15594d..00000000 --- a/app/.classpath +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/.project b/app/.project deleted file mode 100644 index 478f7593..00000000 --- a/app/.project +++ /dev/null @@ -1,34 +0,0 @@ - - - app - Project app created by Buildship. - - - - - org.eclipse.jdt.core.javabuilder - - - - - org.eclipse.buildship.core.gradleprojectbuilder - - - - - - org.eclipse.jdt.core.javanature - org.eclipse.buildship.core.gradleprojectnature - - - - 1604809825395 - - 30 - - org.eclipse.core.resources.regexFilterMatcher - node_modules|.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ - - - - diff --git a/app/.settings/org.eclipse.buildship.core.prefs b/app/.settings/org.eclipse.buildship.core.prefs deleted file mode 100644 index a7b84d92..00000000 --- a/app/.settings/org.eclipse.buildship.core.prefs +++ /dev/null @@ -1,2 +0,0 @@ -connection.project.dir=.. -eclipse.preferences.version=1 diff --git a/app/build.gradle b/app/build.gradle index d1886148..525e729c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,132 +1,105 @@ -plugins { - id "com.android.application" - // 谷歌官方推荐的用于隐藏地图 KEY 的插件 https://developers.google.com/maps/documentation/places/android-sdk/secrets-gradle-plugin?hl=zh-cn - id "com.google.android.libraries.mapsplatform.secrets-gradle-plugin" -} - -android { - ndkVersion = '26.2.11394342' - compileSdk = 32 - buildToolsVersion = '36.0.0' - namespace = 'com.zcshou.gogogo' - - signingConfigs { - debug { - storeFile file('..\\keystore\\GoGoGo.jks') - storePassword 'GoGoGo' - keyAlias 'GoGoKey' - keyPassword 'GoGoGo' - } - release { - storeFile file('..\\keystore\\GoGoGo.jks') - storePassword 'GoGoGo' - keyAlias 'GoGoKey' - keyPassword 'GoGoGo' - } - } - - defaultConfig { - applicationId "com.zcshou.gogogo" - minSdkVersion 27 - //noinspection ExpiredTargetSdkVersion - targetSdkVersion 32 - versionCode 1123 - versionName '1.12.3' // https://semver.org/lang/zh-CN/ - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - resourceConfigurations += ['zh', 'zh-rCN', 'en', 'en-rUS'] - ndk { - //noinspection ChromeOsAbiSupport - abiFilters "arm64-v8a" - } - signingConfig = signingConfigs.release - } - - buildTypes { - release { - // Enables code shrinking, obfuscation, and optimization for only - // your project's release build type. - minifyEnabled = true - - // Enables resource shrinking, which is performed by the - // Android Gradle plugin. - shrinkResources = true - - // Includes the default ProGuard rules files that are packaged with - // the Android Gradle plugin. To learn more, go to the section about - // R8 configuration files. - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - - signingConfig = signingConfigs.release - multiDexEnabled = true - } - debug { - signingConfig = signingConfigs.debug - } - } - - // 要存取 secret value 必須要使用到一個 BuildScript 的類別,默认会从 local.properties 中获取 - buildFeatures { - buildConfig = true - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 - } - - packagingOptions { - jniLibs { - excludes += ['lib/armeabi/**'] - } - resources { - excludes += ['lib/armeabi/**'] - } - } - - sourceSets { - main { - jniLibs.srcDirs = ['libs'] - } - } - - dependenciesInfo { - includeInApk = false - includeInBundle = false - } - - // 指定输出的可执行程序的名字 - applicationVariants.configureEach { variant -> - variant.outputs.configureEach { output -> - def outputFile = output.outputFile - if (outputFile != null && outputFile.name.endsWith('.apk')) { - def fileName - if (variant.buildType.name == 'release') { - fileName = "Go_${defaultConfig.versionName}_arm64-v8a_release.apk" - } else { - fileName = "Go_${defaultConfig.versionName}_arm64-v8a_debug.apk" - } - outputFileName=fileName - } - } - } -} - -dependencies { - implementation fileTree(include: ['*.jar'], dir: 'libs') - // AppCompat 1.5.1 开始显式依赖于 Lifecycle 2.5.1 和 SavedState 1.2.0 - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' - implementation 'androidx.appcompat:appcompat:1.5.1' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation 'androidx.preference:preference:1.2.1' - implementation 'com.google.android.material:material:1.7.0' - implementation 'com.squareup.okhttp3:okhttp:4.12.0' - implementation 'com.elvishew:xlog:1.11.1' - implementation 'io.noties.markwon:core:4.6.2' - implementation files('libs/BaiduLBS_Android.jar') - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.3.0' - androidTestImplementation 'androidx.test:runner:1.7.0' - androidTestImplementation('androidx.test.espresso:espresso-core:3.7.0', { - exclude group: 'com.android.support', module: 'support-annotations' - }) -} +plugins { + id "com.android.application" +} + +android { + compileSdk = 32 + buildToolsVersion = '36.0.0' + namespace = 'com.zcshou.gogogo' + + signingConfigs { + debug { + storeFile file('../keystore/GoGoGo.jks') + storePassword 'GoGoGo' + keyAlias 'GoGoKey' + keyPassword 'GoGoGo' + } + release { + storeFile file('../keystore/GoGoGo.jks') + storePassword 'GoGoGo' + keyAlias 'GoGoKey' + keyPassword 'GoGoGo' + } + } + + defaultConfig { + applicationId "com.private.yingsuo" + minSdkVersion 27 + //noinspection ExpiredTargetSdkVersion + targetSdkVersion 32 + versionCode 1123 + versionName '1.12.3' // https://semver.org/lang/zh-CN/ + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + resourceConfigurations += ['zh', 'zh-rCN', 'en', 'en-rUS'] + signingConfig = signingConfigs.release + } + + buildTypes { + release { + // Enables code shrinking, obfuscation, and optimization for only + // the release build type. + minifyEnabled = true + + // Enables resource shrinking, which is performed by the + // Android Gradle plugin. + shrinkResources = true + + // Includes the default ProGuard rules files that are packaged with + // the Android Gradle plugin. To learn more, go to the section about + // R8 configuration files. + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + + signingConfig = signingConfigs.release + multiDexEnabled = true + } + debug { + signingConfig = signingConfigs.debug + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + dependenciesInfo { + includeInApk = false + includeInBundle = false + } + + // 指定输出的可执行程序的名字 + applicationVariants.configureEach { variant -> + variant.outputs.configureEach { output -> + def outputFile = output.outputFile + if (outputFile != null && outputFile.name.endsWith('.apk')) { + def fileName + if (variant.buildType.name == 'release') { + fileName = "Go_${defaultConfig.versionName}_arm64-v8a_release.apk" + } else { + fileName = "Go_${defaultConfig.versionName}_arm64-v8a_debug.apk" + } + outputFileName=fileName + } + } + } +} + +dependencies { + // AppCompat 1.5.1 开始显式依赖于 Lifecycle 2.5.1 和 SavedState 1.2.0 + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' + implementation 'androidx.appcompat:appcompat:1.5.1' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.preference:preference:1.2.1' + implementation 'com.google.android.material:material:1.7.0' + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation 'com.elvishew:xlog:1.11.1' + implementation 'io.noties.markwon:core:4.6.2' + // 瓦片地图引擎,替代百度地图 Android SDK + implementation 'org.osmdroid:osmdroid-android:6.1.20' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.3.0' + androidTestImplementation 'androidx.test:runner:1.7.0' + androidTestImplementation('androidx.test.espresso:espresso-core:3.7.0', { + exclude group: 'com.android.support', module: 'support-annotations' + }) +} diff --git a/app/libs/BaiduLBS_Android.jar b/app/libs/BaiduLBS_Android.jar deleted file mode 100644 index c39347eb..00000000 Binary files a/app/libs/BaiduLBS_Android.jar and /dev/null differ diff --git a/app/libs/arm64-v8a/.DS_Store b/app/libs/arm64-v8a/.DS_Store deleted file mode 100644 index f61bc5d8..00000000 Binary files a/app/libs/arm64-v8a/.DS_Store and /dev/null differ diff --git a/app/libs/arm64-v8a/libBaiduMapSDK_base_v7_6_5.so b/app/libs/arm64-v8a/libBaiduMapSDK_base_v7_6_5.so deleted file mode 100644 index 3d5fbb99..00000000 Binary files a/app/libs/arm64-v8a/libBaiduMapSDK_base_v7_6_5.so and /dev/null differ diff --git a/app/libs/arm64-v8a/libBaiduMapSDK_map_v7_6_5.so b/app/libs/arm64-v8a/libBaiduMapSDK_map_v7_6_5.so deleted file mode 100644 index 486e289c..00000000 Binary files a/app/libs/arm64-v8a/libBaiduMapSDK_map_v7_6_5.so and /dev/null differ diff --git a/app/libs/arm64-v8a/libc++_shared.so b/app/libs/arm64-v8a/libc++_shared.so deleted file mode 100644 index 94cd6445..00000000 Binary files a/app/libs/arm64-v8a/libc++_shared.so and /dev/null differ diff --git a/app/libs/arm64-v8a/libindoor.so b/app/libs/arm64-v8a/libindoor.so deleted file mode 100644 index 8b55e4c9..00000000 Binary files a/app/libs/arm64-v8a/libindoor.so and /dev/null differ diff --git a/app/libs/arm64-v8a/liblocSDK8b.so b/app/libs/arm64-v8a/liblocSDK8b.so deleted file mode 100644 index dd458900..00000000 Binary files a/app/libs/arm64-v8a/liblocSDK8b.so and /dev/null differ diff --git a/app/libs/arm64-v8a/libtiny_magic.so b/app/libs/arm64-v8a/libtiny_magic.so deleted file mode 100644 index 28fab013..00000000 Binary files a/app/libs/arm64-v8a/libtiny_magic.so and /dev/null differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 7b5310cb..117779e6 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,31 +1,34 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile --keep class com.baidu.** {*;} --keep class vi.com.** {*;} --keep class com.baidu.vi.** {*;} --dontwarn com.baidu.** - -# OkHttp platform used only on JVM and when Conscrypt and other security providers are available. --dontwarn okhttp3.internal.platform.** --dontwarn org.conscrypt.** --dontwarn org.bouncycastle.** --dontwarn org.openjsse.** \ No newline at end of file +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +# 保留文件名与行号信息:release 包闪退时日志里的堆栈才是可读的真实行号 +-keepattributes SourceFile,LineNumberTable + +# osmdroid 瓦片地图引擎 +-dontwarn org.osmdroid.** +-keep class org.osmdroid.tileprovider.modules.** { *; } + +# OkHttp platform used only on JVM and when Conscrypt and other security providers are available. +-dontwarn okhttp3.internal.platform.** +-dontwarn org.conscrypt.** +-dontwarn org.bouncycastle.** +-dontwarn org.openjsse.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 09bf560d..2a83a8b9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,114 +1,111 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png deleted file mode 100644 index d59f2226..00000000 Binary files a/app/src/main/ic_launcher-playstore.png and /dev/null differ diff --git a/app/src/main/java/com/zcshou/gogogo/FragmentSettings.java b/app/src/main/java/com/zcshou/gogogo/FragmentSettings.java index 1e98d042..75be5289 100644 --- a/app/src/main/java/com/zcshou/gogogo/FragmentSettings.java +++ b/app/src/main/java/com/zcshou/gogogo/FragmentSettings.java @@ -9,7 +9,6 @@ import androidx.preference.PreferenceFragmentCompat; import androidx.preference.SwitchPreferenceCompat; -import com.baidu.mapapi.SDKInitializer; import com.elvishew.xlog.XLog; import com.zcshou.utils.GoUtils; diff --git a/app/src/main/java/com/zcshou/gogogo/GoApplication.java b/app/src/main/java/com/zcshou/gogogo/GoApplication.java index 3b807152..baaa1de1 100644 --- a/app/src/main/java/com/zcshou/gogogo/GoApplication.java +++ b/app/src/main/java/com/zcshou/gogogo/GoApplication.java @@ -1,10 +1,11 @@ package com.zcshou.gogogo; import android.app.Application; +import android.os.Build; +import android.util.Log; -import com.baidu.location.LocationClient; -import com.baidu.mapapi.CoordType; -import com.baidu.mapapi.SDKInitializer; +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; import com.elvishew.xlog.LogConfiguration; import com.elvishew.xlog.LogLevel; @@ -15,29 +16,115 @@ import com.elvishew.xlog.printer.file.backup.NeverBackupStrategy; import com.elvishew.xlog.printer.file.clean.FileLastModifiedCleanStrategy; import com.elvishew.xlog.printer.file.naming.ChangelessFileNameGenerator; +import com.zcshou.utils.GoUtils; + +import org.osmdroid.config.Configuration; +import org.osmdroid.config.IConfigurationProvider; import java.io.File; +import java.io.FileWriter; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; public class GoApplication extends Application { public static final String APP_NAME = "GoGoGo"; public static final String LOG_FILE_NAME = APP_NAME + ".log"; + /** 崩溃日志单独再存一份,避免 xlog 的缓冲导致崩溃信息丢失 */ + public static final String CRASH_FILE_NAME = APP_NAME + ".crash.log"; private static final long MAX_TIME = 1000 * 60 * 60 * 24 * 3; // 3 days @Override public void onCreate() { super.onCreate(); + initCrashHandler(); + initXlog(); - // 百度地图 7.5 开始,要求必须同意隐私政策,默认为false - SDKInitializer.setAgreePrivacy(this, true); - // 百度定位 7.5 开始,要求必须同意隐私政策,默认为false(官方说可以统一为以上接口,但实际测试并不行,定位还是需要单独设置) - LocationClient.setAgreePrivacy(true); - SDKInitializer.setApiKey(BuildConfig.MAPS_API_KEY); - // 在使用 SDK 各组间之前初始化 context 信息,传入 ApplicationContext - SDKInitializer.initialize(this); + initOsmdroid(); + } + + /** + * 注册全局未捕获异常处理器。 + * + *

之前闪退时日志里没有任何堆栈:xlog 只记录主动打印的内容,进程被系统杀掉时 + * 什么线索都不会留下,所以这里把崩溃堆栈直接写进日志文件。

+ */ + private void initCrashHandler() { + final Thread.UncaughtExceptionHandler defaultHandler = Thread.getDefaultUncaughtExceptionHandler(); + + Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { + try { + writeCrashInfo(thread, throwable); + } catch (Throwable ignored) { + // 记录崩溃日志本身不允许再抛异常 + } + + if (defaultHandler != null) { + defaultHandler.uncaughtException(thread, throwable); + } + }); + } + + private void writeCrashInfo(@NonNull Thread thread, @NonNull Throwable throwable) { + StringBuilder builder = new StringBuilder(); + builder.append("\n==================== CRASH ====================\n"); + builder.append("time : ") + .append(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()).format(new Date())) + .append('\n'); + builder.append("thread : ").append(thread.getName()).append('\n'); + builder.append("device : ").append(Build.MANUFACTURER).append(' ').append(Build.MODEL) + .append(" / Android ").append(Build.VERSION.RELEASE) + .append(" (API ").append(Build.VERSION.SDK_INT).append(")\n"); + try { + builder.append("targetSdk: ").append(getApplicationInfo().targetSdkVersion).append('\n'); + } catch (Throwable ignored) { + } + try { + builder.append("version : ").append(GoUtils.getVersionName(this)).append('\n'); + } catch (Throwable ignored) { + } + builder.append(Log.getStackTraceString(throwable)); + builder.append("===============================================\n"); + + // 1) 交给 xlog:保证“问题反馈”里分享的 GoGoGo.log 也包含崩溃信息 + try { + XLog.e("CRASH: uncaught exception", throwable); + } catch (Throwable ignored) { + } + + // 2) 直接落盘并 flush:即使进程马上被杀也不会丢 + File logPath = getExternalFilesDir("Logs"); + if (logPath == null) { + return; + } + + appendCrashLog(new File(logPath, LOG_FILE_NAME), builder.toString()); + appendCrashLog(new File(logPath, CRASH_FILE_NAME), builder.toString()); + } + + private void appendCrashLog(File file, String content) { + try (FileWriter writer = new FileWriter(file, true)) { + writer.write(content); + writer.flush(); + } catch (Throwable ignored) { + } + } - SDKInitializer.setCoordType(CoordType.BD09LL); + /** + * 初始化 osmdroid(瓦片地图引擎) + */ + private void initOsmdroid() { + IConfigurationProvider osmConfig = Configuration.getInstance(); + // 读取 osmdroid 自身的配置项(瓦片缓存大小、过期时间等) + osmConfig.load(this, PreferenceManager.getDefaultSharedPreferences(this)); + // OSM / Esri 的瓦片服务器要求带上有意义的 User-Agent,否则会直接返回 403 + osmConfig.setUserAgentValue(getPackageName()); + // 瓦片缓存放到应用私有目录,避免申请存储权限 + File basePath = new File(getCacheDir(), "osmdroid"); + osmConfig.setOsmdroidBasePath(basePath); + osmConfig.setOsmdroidTileCache(new File(basePath, "tiles")); } /** @@ -64,4 +151,4 @@ private void initXlog() { XLog.init(config, consolePrinter, filePrinter); } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zcshou/gogogo/HistoryActivity.java b/app/src/main/java/com/zcshou/gogogo/HistoryActivity.java index 72d353db..d55d4bcc 100644 --- a/app/src/main/java/com/zcshou/gogogo/HistoryActivity.java +++ b/app/src/main/java/com/zcshou/gogogo/HistoryActivity.java @@ -9,6 +9,8 @@ import androidx.appcompat.app.AlertDialog; import androidx.preference.PreferenceManager; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + import android.text.InputType; import android.text.TextUtils; import android.util.Log; @@ -79,7 +81,9 @@ protected void onCreate(Bundle savedInstanceState) { @Override protected void onDestroy() { - mHistoryLocationDB.close(); + if (mHistoryLocationDB != null) { + mHistoryLocationDB.close(); + } super.onDestroy(); } @@ -98,7 +102,7 @@ public boolean onOptionsItemSelected(MenuItem item) { this.finish(); // back button return true; } else if (id == R.id.action_delete) { - new AlertDialog.Builder(HistoryActivity.this) + new MaterialAlertDialogBuilder(HistoryActivity.this) .setTitle("警告")//这里是表头的内容 .setMessage("确定要删除全部历史记录吗?")//这里是中间显示的具体信息 .setPositiveButton("确定", @@ -262,7 +266,7 @@ public boolean onQueryTextChange(String newText) {// 当搜索内容改变时触 } private void showDeleteDialog(String locID) { - AlertDialog.Builder builder = new AlertDialog.Builder(this); + AlertDialog.Builder builder = new MaterialAlertDialogBuilder(this); builder.setTitle("警告"); builder.setMessage("确定要删除该项历史记录吗?"); builder.setPositiveButton("确定", (dialog, whichButton) -> { @@ -282,7 +286,7 @@ private void showInputDialog(String locID, String name) { input.setInputType(InputType.TYPE_CLASS_TEXT); input.setText(name); - AlertDialog.Builder builder = new AlertDialog.Builder(this); + AlertDialog.Builder builder = new MaterialAlertDialogBuilder(this); builder.setTitle("名称"); builder.setView(input); builder.setPositiveButton("确认", (dialog, whichButton) -> { diff --git a/app/src/main/java/com/zcshou/gogogo/MainActivity.java b/app/src/main/java/com/zcshou/gogogo/MainActivity.java index 07641103..da98aa10 100644 --- a/app/src/main/java/com/zcshou/gogogo/MainActivity.java +++ b/app/src/main/java/com/zcshou/gogogo/MainActivity.java @@ -1,43 +1,47 @@ package com.zcshou.gogogo; -import android.app.DownloadManager; -import android.content.BroadcastReceiver; +import android.Manifest; import android.content.ClipData; import android.content.ClipboardManager; import android.content.ComponentName; import android.content.ContentValues; import android.content.Context; import android.content.Intent; -import android.content.IntentFilter; import android.content.ServiceConnection; import android.content.SharedPreferences; +import android.content.pm.PackageManager; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; +import android.graphics.DashPathEffect; +import android.graphics.drawable.Drawable; import android.hardware.Sensor; import android.hardware.SensorEvent; import android.hardware.SensorEventListener; import android.hardware.SensorManager; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; import android.net.Uri; import android.os.Bundle; import android.os.IBinder; +import android.os.Looper; import android.provider.Settings; import android.text.TextUtils; -import android.view.Gravity; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.View; -import android.view.Window; -import android.view.WindowManager; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; import android.widget.Button; import android.widget.EditText; import android.widget.ImageButton; import android.widget.ImageView; -import android.widget.LinearLayout; import android.widget.ListView; import android.widget.RadioButton; import android.widget.RadioGroup; import android.widget.SimpleAdapter; +import android.widget.Spinner; import android.widget.TextView; import androidx.annotation.NonNull; @@ -45,47 +49,29 @@ import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.SearchView; import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; import androidx.core.view.GravityCompat; import androidx.drawerlayout.widget.DrawerLayout; import androidx.preference.PreferenceManager; -import com.baidu.location.BDAbstractLocationListener; -import com.baidu.location.BDLocation; -import com.baidu.location.LocationClient; -import com.baidu.location.LocationClientOption; -import com.baidu.mapapi.SDKInitializer; -import com.baidu.mapapi.map.BaiduMap; -import com.baidu.mapapi.map.BitmapDescriptor; -import com.baidu.mapapi.map.BitmapDescriptorFactory; -import com.baidu.mapapi.map.InfoWindow; -import com.baidu.mapapi.map.MapPoi; -import com.baidu.mapapi.map.MapStatus; -import com.baidu.mapapi.map.MapStatusUpdate; -import com.baidu.mapapi.map.MapStatusUpdateFactory; -import com.baidu.mapapi.map.MapView; -import com.baidu.mapapi.map.MarkerOptions; -import com.baidu.mapapi.map.MyLocationConfiguration; -import com.baidu.mapapi.map.MyLocationData; -import com.baidu.mapapi.model.LatLng; -import com.baidu.mapapi.search.core.SearchResult; -import com.baidu.mapapi.search.geocode.GeoCodeResult; -import com.baidu.mapapi.search.geocode.GeoCoder; -import com.baidu.mapapi.search.geocode.OnGetGeoCoderResultListener; -import com.baidu.mapapi.search.geocode.ReverseGeoCodeOption; -import com.baidu.mapapi.search.geocode.ReverseGeoCodeResult; -import com.baidu.mapapi.search.sug.SuggestionResult; -import com.baidu.mapapi.search.sug.SuggestionSearch; -import com.baidu.mapapi.search.sug.SuggestionSearchOption; +import org.osmdroid.events.MapEventsReceiver; +import org.osmdroid.util.GeoPoint; +import org.osmdroid.views.MapView; +import org.osmdroid.views.overlay.infowindow.InfoWindow; +import org.osmdroid.views.overlay.MapEventsOverlay; +import org.osmdroid.views.overlay.Marker; +import org.osmdroid.views.overlay.Overlay; +import org.osmdroid.views.overlay.Polyline; +import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider; +import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.android.material.navigation.NavigationView; import com.google.android.material.snackbar.Snackbar; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - import java.io.File; -import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -94,18 +80,20 @@ import com.zcshou.service.ServiceGo; import com.zcshou.database.DataBaseHistoryLocation; import com.zcshou.database.DataBaseHistorySearch; +import com.zcshou.script.ScriptParser; +import com.zcshou.script.ScriptPlayer; +import com.zcshou.script.ScriptRoute; +import com.zcshou.script.ScriptStore; +import com.zcshou.script.ScriptWaypoint; import com.zcshou.utils.ShareUtils; import com.zcshou.utils.GoUtils; import com.zcshou.utils.MapUtils; +import com.zcshou.utils.OsmGeocoder; +import com.zcshou.utils.TileSourceUtils; import com.elvishew.xlog.XLog; -import io.noties.markwon.Markwon; -import okhttp3.Call; -import okhttp3.Callback; import okhttp3.OkHttpClient; -import okhttp3.Response; -import okhttp3.ResponseBody; public class MainActivity extends BaseActivity implements SensorEventListener { /* 对外 */ @@ -118,18 +106,34 @@ public class MainActivity extends BaseActivity implements SensorEventListener { public static final String POI_LONGITUDE = "POI_LONGITUDE"; public static final String POI_LATITUDE = "POI_LATITUDE"; + /** 启动脚本模式时传给 ServiceGo 的脚本 id */ + public static final String SCRIPT_ROUTE_ID = "SCRIPT_ROUTE_ID"; + private OkHttpClient mOkHttpClient; private SharedPreferences sharedPreferences; /*============================== 主界面地图 相关 ==============================*/ /************** 地图 *****************/ - public final static BitmapDescriptor mMapIndicator = BitmapDescriptorFactory.fromResource(R.drawable.icon_gcoding); - public static String mCurrentCity = null; - private MapView mMapView; - private static BaiduMap mBaiduMap = null; - private static LatLng mMarkLatLngMap = new LatLng(36.547743718042415, 117.07018449827267); // 当前标记的地图点 + /** + * 选点标记的图标资源。osmdroid 的每个 Marker 需要各自持有 Drawable, + * 所以这里只暴露资源 id,由使用方自行创建。 + */ + public final static int MAP_INDICATOR_RES = R.drawable.icon_gcoding; + /** + * 当前标记的地图点。 + * 注意:这里存放的始终是 BD-09 坐标(与原实现保持一致), + * 只有在绘制到地图、以及地图回调返回坐标时,才通过 {@link MapUtils} 做 BD-09 / WGS-84 转换。 + */ + private static GeoPoint mMarkLatLngMap = new GeoPoint(36.547743718042415, 117.07018449827267); private static String mMarkName = null; - private GeoCoder mGeoCoder; + /** 主界面地图(static 是为了让 HistoryActivity 能通过静态方法 showLocation 回填选点) */ + private static MapView mMapView; + private static Marker mMarkMarker = null; + private static Drawable sMapIndicatorDrawable = null; + private OsmGeocoder mGeoCoder; + private MyLocationNewOverlay mMyLocationOverlay; + private LocationManager mSysLocManager; + private LocationListener mSysLocListener; private SensorManager mSensorManager; private Sensor mSensorAccelerometer; private Sensor mSensorMagnetic; @@ -138,32 +142,113 @@ public class MainActivity extends BaseActivity implements SensorEventListener { private final float[] mR = new float[9];//旋转矩阵,用来保存磁场和加速度的数据 private final float[] mDirectionValues = new float[3];//模拟方向传感器的数据(原始数据为弧度) /************** 定位 *****************/ - private LocationClient mLocClient = null; - private double mCurrentLat = 0.0; // 当前位置的百度纬度 - private double mCurrentLon = 0.0; // 当前位置的百度经度 + private double mCurrentLat = 0.0; // 当前位置的纬度(WGS-84) + private double mCurrentLon = 0.0; // 当前位置的经度(WGS-84) private float mCurrentDirection = 0.0f; private boolean isFirstLoc = true; // 是否首次定位 private boolean isMockServStart = false; private ServiceGo.ServiceGoBinder mServiceBinder; + /** 是否已经绑定过 ServiceGo(与 isMockServStart 分开记,退出时要保证解绑) */ + private boolean mBoundToService = false; private ServiceConnection mConnection; private FloatingActionButton mButtonStart; + /** 脚本模式入口按钮 */ + private FloatingActionButton mButtonScript; + /** 当前正在播放的脚本 id,null 表示没有在播放脚本(由定位线程回调更新) */ + private volatile String mRunningScriptId; + /** + * 已经发出“开始移动”、但服务连接还没回来时记下的脚本 id。 + * + *

连上服务后用它兜底补一次启动:以前“点开始”只依赖 onStartCommand 异步送达, + * 一旦没送到(或服务端状态过期),界面显示在播放、位置却不动,点停止也没反应。

+ */ + private String mPendingScriptId; + /** 已经发出“停止移动”、但服务连接还没回来时的挂起标记,连上后再补一次停止 */ + private boolean mPendingStop; + /** + * 脚本是否处于暂停中(由服务端状态回调 / 每次同步更新)。 + * + *

暂停与播放共用“正在播放”的本地缓存 {@link #mRunningScriptId}, + * 单独记一个标记才能把“暂停 / 继续”按钮切到正确一侧。

+ */ + private volatile boolean mScriptPaused; + /** 已经点过“暂停 / 继续”、但服务连接还没回来时记下的目标状态,连上后补执行一次 */ + private Boolean mPendingPauseRequest; + /** 左下角开关的状态:true = 点击移动,false = 摇杆移动(默认由“移动类型”设置决定) */ + private boolean mClickMoveEnabled; + /** 左下角“点击移动 ⇄ 摇杆移动”开关,模拟位置运行期间才显示 */ + private Button mClickMoveSwitch; + /** 上一次刷新时脚本是否在播(用于检测“没在播 → 在播”并开启地图跟随) */ + private boolean mWasScriptRunning; + /** 定位图层自动重试是否已挂起(同一时刻只保留一条重试链) */ + private boolean mMyLocationRetryPending; + /*============================== 主界面连续选点(连贯移动) ==============================*/ + /** + * 主界面选点生成的路线在脚本存储里的固定 id。 + * 每次重新选点都覆盖同一条,避免脚本列表里堆满“地图路线”。 + */ + private static final String MAP_ROUTE_ID = "map_route_from_main_map"; + /** 记录最近一次“主界面选点”生成的路线 id(被编辑过会换成新 id) */ + private static final String KEY_MAIN_MAP_ROUTE_ID = "main_map_route_id"; + /** 路线选点模式开关:开启时点地图是“往路线末尾加一个路点”,而不是原来的“选一个传送点” */ + private boolean mRoutePicking = false; + /** 已选路点(WGS-84,与 ServiceGo / ScriptPlayer 使用的坐标语义一致) */ + private final List mRoutePoints = new ArrayList<>(); + /** 新路点使用的状态(步行 / 跑步 / 骑行),速度取“设置”里对应的值 */ + private ScriptWaypoint.Mode mRouteMode = ScriptWaypoint.Mode.WALK; + private FloatingActionButton mButtonRoute; + // route_bar / click_move_bar 的根节点是 MaterialCardView(卡片化), + // 字段声明成 View 而不是 LinearLayout:findViewById 会插入 checkcast, + // 类型对不上会直接 ClassCastException。两者实际只调用 setVisibility。 + private View mRouteBar; + private TextView mRouteHint; + private Spinner mRouteModeSpinner; + private MaterialButton mRouteGoButton; + /** 路线操作条上的“暂停 / 继续”按钮(路线没有在跑时隐藏) */ + private MaterialButton mRoutePauseButton; + /** 路线连线:点地图加路点时实时重画 */ + private Polyline mRouteLine; + private final List mRouteMarkers = new ArrayList<>(); + /** 地图点击派发器:始终保持在图层最上面,保证点地图不会被路线图钉 / 连线吃掉 */ + private MapEventsOverlay mMapEventsOverlay; + /*============================== 点击移动(多目标 + 行进轨迹) ==============================*/ + /** + * 点击移动的待走目标队列(WGS-84)。 + * + *

队列里只有还没走到的点:走到一个就从队头删掉(图钉与连线随之消失), + * 所以“下一个要下发的目标”永远是 {@code get(0)},不需要额外的下标。 + * 队头即当前正在走向的目标,队尾是最后追加的。

+ */ + private final List mClickMovePoints = new ArrayList<>(); + /** 从上一个目标到当前位置的连线(只有起点时才显示,形成完整的行进轨迹) */ + private Polyline mClickMoveLine; + private final List mClickMoveMarkers = new ArrayList<>(); + /** 点击移动的操作条:撤回 / 清空 / 开始行走(根节点同为 MaterialCardView,见 mRouteBar) */ + private View mClickMoveBar; + private TextView mClickMoveHint; + /** 主操作按钮,用 MaterialButton 才能随状态切换图标(开始 ↔ 停止) */ + private MaterialButton mClickMoveGoButton; + /** 点击移动是否正在行进(由服务端到达回调驱动) */ + private volatile boolean mClickMoveRunning; + /** 点击移动是否暂停中(服务端状态为准,本界面切换后回读同步) */ + private volatile boolean mClickMovePaused; + /** 点击移动操作条上的“暂停 / 继续”按钮(没在行走时隐藏) */ + private MaterialButton mClickMovePauseButton; + /** 已经点过“暂停 / 继续”、但服务连接还没回来时记下的目标状态,连上后补执行一次 */ + private Boolean mPendingClickPauseRequest; /*============================== 历史记录 相关 ==============================*/ private SQLiteDatabase mLocationHistoryDB; private SQLiteDatabase mSearchHistoryDB; /*============================== SearchView 相关 ==============================*/ private SearchView searchView; private ListView mSearchList; - private LinearLayout mSearchLayout; + // 这两个下拉层在 main_content.xml 里也做成了 MaterialCardView; + // 注意 activity_history.xml 里还有一个同名的 search_linear 且仍是 LinearLayout, + // 那是 HistoryActivity 自己的布局,两者按各自 layout 解析,互不影响。 + private View mSearchLayout; private ListView mSearchHistoryList; - private LinearLayout mHistoryLayout; + private View mHistoryLayout; private MenuItem searchItem; - private SuggestionSearch mSuggestionSearch; - /*============================== 更新 相关 ==============================*/ - private DownloadManager mDownloadManager = null; - private long mDownloadId; - private BroadcastReceiver mDownloadBdRcv; - private String mUpdateFilename; - @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -193,41 +278,166 @@ protected void onCreate(Bundle savedInstanceState) { initGoBtn(); + initRoutePicking(); + + initClickMoveBar(); + + initClickMoveSwitch(); + + // 恢复旋屏前还没开始移动的选点 + if (savedInstanceState != null) { + mRoutePoints.clear(); + mRoutePoints.addAll(ScriptStore.decodePoints(savedInstanceState.getString(STATE_ROUTE_POINTS, ""))); + ScriptWaypoint.Mode[] modes = ScriptWaypoint.Mode.values(); + int modeOrdinal = savedInstanceState.getInt(STATE_ROUTE_MODE, 0); + if (modeOrdinal >= 0 && modeOrdinal < modes.length) { + mRouteMode = modes[modeOrdinal]; + // 让下拉跟上恢复出来的状态,避免回显成默认的“步行” + if (mRouteModeSpinner != null) { + mRouteModeSpinner.setSelection(modeOrdinal); + } + } + // 恢复“正在选点”的界面状态,但不弹提示(旋屏不该再提示一次) + if (savedInstanceState.getBoolean(STATE_ROUTE_PICKING, false)) { + mRoutePicking = true; + if (mRouteBar != null) { + mRouteBar.setVisibility(View.VISIBLE); + } + if (mButtonRoute != null) { + mButtonRoute.setImageResource(R.drawable.ic_close); + } + } + redrawRoute(); + + // “单点模拟中”是实例字段,必须跨重建活下来:否则重建后 FAB 图标退回未启动态、 + // “终止模拟”入口失效,而且重建后的实例退出时不再 stopService(服务泄漏) + isMockServStart = savedInstanceState.getBoolean(STATE_MOCK_SERVING, false); + if (mButtonStart != null) { + mButtonStart.setImageResource(isMockServStart ? R.drawable.ic_fly : R.drawable.ic_position); + } + refreshClickMoveSwitch(); + } + mConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder service) { mServiceBinder = (ServiceGo.ServiceGoBinder)service; + // 脚本播放状态变化时刷新入口按钮(脚本播放可能在脚本模式页面里启动) + mServiceBinder.setScriptListener(mScriptListener); + // 点击移动“走到下一个目标”的回调:目标队列在主界面,服务端只管走 + mServiceBinder.setClickMoveListener(new ServiceGo.ClickMoveListener() { + @Override + public void onClickTargetArrived(double lng, double lat) { + onServiceClickArrived(); + } + }); + syncStateWithService(); + // 每次连上都把当前的“点击移动 ⇄ 摇杆移动”开关状态推给服务端 + applyClickMoveMode(); + // 重新绑定(切应用 / 切页面回来)时把最后一次真实定位补报一次: + // 服务端在脚本接管期间只记录、不覆盖,所以不会把脚本位置拉回真实位置 + if (mCurrentLat != 0 || mCurrentLon != 0) { + try { + mServiceBinder.onRealLocation(mCurrentLon, mCurrentLat); + } catch (Exception e) { + XLog.e("ERROR: onRealLocation on connect", e); + } + } + updateScriptButton(); } @Override public void onServiceDisconnected(ComponentName name) { - + // 服务异常退出:本地引用已经失效,必须清掉, + // 否则之后的“停止 / 开始”全都打在一个死对象上,表现为点了没反应 + mServiceBinder = null; + mBoundToService = false; + mClickMoveRunning = false; + updateScriptButton(); } }; initStoreHistory(); initSearchView(); - - initUpdateVersion(); - - checkUpdateVersion(false); } @Override protected void onPause() { XLog.i("MainActivity: onPause"); - mMapView.onPause(); - mSensorManager.unregisterListener(this); + if (mMapView != null) { + mMapView.onPause(); + } + unregisterSensorListener(); super.onPause(); } + /** 旋屏 / 重建时保存的“还没开始移动的路线选点” */ + private static final String STATE_ROUTE_POINTS = "state_route_points"; + private static final String STATE_ROUTE_MODE = "state_route_mode"; + private static final String STATE_ROUTE_PICKING = "state_route_picking"; + /** 旋屏 / 重建时保存的“单点模拟是否在跑” */ + private static final String STATE_MOCK_SERVING = "state_mock_serving"; + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + + // 选点还没变成一条真正在跑的路线,只有内存里这一份,不存就白点一遍 + outState.putString(STATE_ROUTE_POINTS, ScriptStore.encodePoints(mRoutePoints)); + outState.putInt(STATE_ROUTE_MODE, mRouteMode.ordinal()); + outState.putBoolean(STATE_ROUTE_PICKING, mRoutePicking); + // 单点模拟的运行标记:跨重建保存,重建后 FAB 状态与退出收尾都靠它 + outState.putBoolean(STATE_MOCK_SERVING, isMockServStart); + } + @Override protected void onResume() { XLog.i("MainActivity: onResume"); - mMapView.onResume(); - mSensorManager.registerListener(this, mSensorAccelerometer, SensorManager.SENSOR_DELAY_UI); - mSensorManager.registerListener(this, mSensorMagnetic, SensorManager.SENSOR_DELAY_UI); + if (mMapView != null) { + mMapView.onResume(); + // 回到界面前模拟位置服务可能移除过测试提供者,这一帧 enable 会失败: + // 立即补一次,再延迟补一次,等提供者建回来箭头就能恢复 + ensureMyLocationEnabled(); + mMapView.postDelayed(this::ensureMyLocationEnabled, 800); + } + if (mSensorManager != null && mSensorAccelerometer != null) { + mSensorManager.registerListener(this, mSensorAccelerometer, SensorManager.SENSOR_DELAY_UI); + } + if (mSensorManager != null && mSensorMagnetic != null) { + mSensorManager.registerListener(this, mSensorMagnetic, SensorManager.SENSOR_DELAY_UI); + } + + // 回到主界面时同步一次脚本播放状态(脚本可能是在脚本模式页面里启动的) + String running = new ScriptStore(this).getRunningScriptId(); + if (running != null) { + mRunningScriptId = running; + // 存储里有运行记录就把服务绑上:既让“停止移动”走 binder(而不是直接 + // stopService 把服务打死),也方便连上后用服务端真实状态纠正本地缓存 + bindServiceIfNeeded(); + } else if (mServiceBinder == null) { + // 存储里没有运行记录,本地残留的“正在播放”一定是过期状态,纠正掉 + // (否则按钮会一直显示“停止移动”,点下去却是停一个没在跑的脚本) + mRunningScriptId = null; + } + if (isMockServStart && mServiceBinder == null) { + // 单点模拟在跑但还没绑上:先绑好,否则 FAB 首次按下只补绑、位置要第二次才生效 + bindServiceIfNeeded(); + } + + if (mServiceBinder != null) { + // 服务端只有一个监听槽位:进过脚本页后,监听会被那边覆盖、并在其 onStop 里清空, + // 此后通知栏 / 脚本页发起的暂停、继续都到不了主界面,路线条按钮会停在错误一侧。 + // 回到主界面先把监听抢回来,再以服务端状态同步一次 + try { + mServiceBinder.setScriptListener(mScriptListener); + syncStateWithService(); + } catch (Exception e) { + XLog.e("ERROR: re-register script listener", e); + } + } + updateScriptButton(); + super.onResume(); } @@ -235,7 +445,7 @@ protected void onResume() { protected void onStop() { XLog.i("MainActivity: onStop"); //取消注册传感器监听 - mSensorManager.unregisterListener(this); + unregisterSensorListener(); super.onStop(); } @@ -243,31 +453,62 @@ protected void onStop() { protected void onDestroy() { XLog.i("MainActivity: onDestroy"); - if (isMockServStart) { - unbindService(mConnection); // 解绑服务,服务要记得解绑,不要造成内存泄漏 - Intent serviceGoIntent = new Intent(MainActivity.this, ServiceGo.class); - stopService(serviceGoIntent); + // 只有真正退出界面(isFinishing)才停服务:旋屏 / 配置变化导致的重建只解绑, + // 否则单点模拟和播放中的脚本会被旧实例顺手 kill 掉(“转个屏脚本就停了”)。 + // 重建后的实例已通过 onSaveInstanceState 恢复 isMockServStart,最终退出时仍会正确收尾。 + if (isFinishing() && isMockServStart) { + try { + unbindServiceIfNeeded(); + Intent serviceGoIntent = new Intent(MainActivity.this, ServiceGo.class); + stopService(serviceGoIntent); + } catch (Exception e) { + XLog.e("ERROR: stop ServiceGo"); + } + isMockServStart = false; + } else { + unbindServiceIfNeeded(); } - unregisterReceiver(mDownloadBdRcv); - mSensorManager.unregisterListener(this); + unregisterSensorListener(); // 退出时销毁定位 - mLocClient.stop(); + if (mSysLocManager != null && mSysLocListener != null) { + try { + mSysLocManager.removeUpdates(mSysLocListener); + } catch (Exception e) { + XLog.e("ERROR: removeUpdates"); + } + } // 关闭定位图层 - mBaiduMap.setMyLocationEnabled(false); - mMapView.onDestroy(); - - //poi search destroy - mSuggestionSearch.destroy(); + if (mMyLocationOverlay != null) { + try { + mMyLocationOverlay.disableMyLocation(); + } catch (Exception e) { + XLog.e("ERROR: disableMyLocation"); + } + } + if (mMapView != null) { + mMapView.onDetach(); + } //close db - mLocationHistoryDB.close(); - mSearchHistoryDB.close(); + if (mLocationHistoryDB != null) { + mLocationHistoryDB.close(); + } + if (mSearchHistoryDB != null) { + mSearchHistoryDB.close(); + } super.onDestroy(); } + /** 注销传感器监听(mSensorManager 可能因为初始化失败而为空) */ + private void unregisterSensorListener() { + if (mSensorManager != null) { + mSensorManager.unregisterListener(this); + } + } + @Override public void onBackPressed() { moveTaskToBack(false); @@ -326,10 +567,7 @@ public boolean onMenuItemActionExpand(MenuItem item) { @Override public boolean onQueryTextSubmit(String query) { try { - mSuggestionSearch.requestSuggestion((new SuggestionSearchOption()) - .keyword(query) - .city(mCurrentCity) - ); + doSearch(query); //搜索历史 插表参数 ContentValues contentValues = new ContentValues(); contentValues.put(DataBaseHistorySearch.DB_COLUMN_KEY, query); @@ -338,7 +576,7 @@ public boolean onQueryTextSubmit(String query) { contentValues.put(DataBaseHistorySearch.DB_COLUMN_TIMESTAMP, System.currentTimeMillis() / 1000); DataBaseHistorySearch.saveHistorySearch(mSearchHistoryDB, contentValues); - mBaiduMap.clear(); + clearMark(mMapView, mMarkMarker); mSearchLayout.setVisibility(View.INVISIBLE); } catch (Exception e) { GoUtils.DisplayToast(MainActivity.this, getResources().getString(R.string.app_error_search)); @@ -356,10 +594,7 @@ public boolean onQueryTextChange(String newText) { if (newText != null && !newText.isEmpty()) { try { - mSuggestionSearch.requestSuggestion((new SuggestionSearchOption()) - .keyword(newText) - .city(mCurrentCity) - ); + doSearch(newText); } catch (Exception e) { GoUtils.DisplayToast(MainActivity.this, getResources().getString(R.string.app_error_search)); XLog.d(getResources().getString(R.string.app_error_search)); @@ -415,6 +650,10 @@ private void initNavigationView() { if (id == R.id.nav_history) { Intent intent = new Intent(MainActivity.this, HistoryActivity.class); + startActivity(intent); + } else if (id == R.id.nav_script) { + Intent intent = new Intent(MainActivity.this, ScriptActivity.class); + startActivity(intent); } else if (id == R.id.nav_settings) { Intent intent = new Intent(MainActivity.this, SettingsActivity.class); @@ -430,13 +669,11 @@ private void initNavigationView() { GoUtils.DisplayToast(this, getResources().getString(R.string.app_error_dev)); } } - } else if (id == R.id.nav_update) { - checkUpdateVersion(true); } else if (id == R.id.nav_feedback) { File file = new File(getExternalFilesDir("Logs"), GoApplication.LOG_FILE_NAME); ShareUtils.shareFile(this, file, item.getTitle().toString()); } else if (id == R.id.nav_contact) { - Uri uri = Uri.parse("https://gitee.com/itexp/gogogo/issues"); + Uri uri = Uri.parse("https://github.com/MilkyPark142008/GoGoGo"); Intent intent = new Intent(Intent.ACTION_VIEW, uri); startActivity(intent); } @@ -456,52 +693,18 @@ private void initNavigationView() { /*============================== 主界面地图 相关 ==============================*/ private void initMap() { // 地图初始化 - mMapView = findViewById(R.id.bdMapView); - mMapView.showZoomControls(false); - mBaiduMap = mMapView.getMap(); - mBaiduMap.setMapType(BaiduMap.MAP_TYPE_NORMAL); - mBaiduMap.setMyLocationEnabled(true); - mBaiduMap.setOnMapTouchListener(event -> { + mMapView = findViewById(R.id.map_main); + mMapView.setBuiltInZoomControls(false); + mMapView.setMultiTouchControls(true); + mMapView.setTilesScaledToDpi(true); + // 默认使用 OpenStreetMap + mMapView.setTileSource(TileSourceUtils.OSM_STANDARD); + mMapView.getController().setZoom(18.0); - }); - mBaiduMap.setOnMapClickListener(new BaiduMap.OnMapClickListener() { - /** - * 单击地图 - */ - @Override - public void onMapClick(LatLng point) { - mMarkLatLngMap = point; - markMap(); - } - /** - * 单击地图中的POI点 - */ - @Override - public void onMapPoiClick(MapPoi poi) { - mMarkLatLngMap = poi.getPosition(); - markMap(); - } - }); - mBaiduMap.setOnMapLongClickListener(new BaiduMap.OnMapLongClickListener() { - /** - * 长按地图 - */ - @Override - public void onMapLongClick(LatLng point) { - mMarkLatLngMap = point; - markMap(); - mGeoCoder.reverseGeoCode(new ReverseGeoCodeOption().location(point)); - } - }); - mBaiduMap.setOnMapDoubleClickListener(new BaiduMap.OnMapDoubleClickListener() { - /** - * 双击地图 - */ - @Override - public void onMapDoubleClick(LatLng point) { - mBaiduMap.animateMapStatus(MapStatusUpdateFactory.zoomIn()); - } - }); + sMapIndicatorDrawable = ContextCompat.getDrawable(this, MAP_INDICATOR_RES); + + // 地点检索 / 逆地理编码(OpenStreetMap Nominatim) + mGeoCoder = new OsmGeocoder(mOkHttpClient); View poiView = View.inflate(MainActivity.this, R.layout.location_poi_info, null); TextView poiAddress = poiView.findViewById(R.id.poi_address); @@ -509,7 +712,7 @@ public void onMapDoubleClick(LatLng point) { TextView poiLatitude = poiView.findViewById(R.id.poi_latitude); ImageButton ibSave = poiView.findViewById(R.id.poi_save); ibSave.setOnClickListener(v -> { - recordCurrentLocation(mMarkLatLngMap.longitude, mMarkLatLngMap.latitude); + recordCurrentLocation(mMarkLatLngMap.getLongitude(), mMarkLatLngMap.getLatitude()); GoUtils.DisplayToast(this, getResources().getString(R.string.app_location_save)); }); ImageButton ibCopy = poiView.findViewById(R.id.poi_copy); @@ -517,7 +720,8 @@ public void onMapDoubleClick(LatLng point) { //获取剪贴板管理器: ClipboardManager cm = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); // 创建普通字符型ClipData - ClipData mClipData = ClipData.newPlainText("Label", mMarkLatLngMap.toString()); + ClipData mClipData = ClipData.newPlainText("Label", + mMarkLatLngMap.getLongitude() + "," + mMarkLatLngMap.getLatitude()); // 将 ClipData内容放到系统剪贴板里。 cm.setPrimaryClip(mClipData); @@ -527,27 +731,74 @@ public void onMapDoubleClick(LatLng point) { ibShare.setOnClickListener(v -> ShareUtils.shareText(MainActivity.this, "分享位置", poiLongitude.getText()+","+poiLatitude.getText())); ImageButton ibFly = poiView.findViewById(R.id.poi_fly); ibFly.setOnClickListener(this::doGoLocation); - mGeoCoder = GeoCoder.newInstance(); - mGeoCoder.setOnGetGeoCodeResultListener(new OnGetGeoCoderResultListener() { + // 选点标记 + mMarkMarker = createMarkMarker(); + if (mMarkMarker != null) { + mMarkMarker.setInfoWindow(new PoiInfoWindow(poiView, mMapView)); + // 单击标记默认会弹出信息气泡,这里屏蔽掉,只在长按逆地理编码成功后主动弹出 + mMarkMarker.setOnMarkerClickListener((marker, mapView) -> true); + mMapView.getOverlays().add(mMarkMarker); + } + + // 地图单击 / 长按事件。osmdroid 通过 MapEventsOverlay 派发,回调里拿到的是 WGS-84 坐标; + // 双击缩放是 osmdroid 内置行为,不需要额外代码。 + MapEventsOverlay eventsOverlay = new MapEventsOverlay(new MapEventsReceiver() { + /** + * 单击地图 + */ @Override - public void onGetGeoCodeResult(GeoCodeResult geoCodeResult) { - XLog.i(geoCodeResult.getLocation()); + public boolean singleTapConfirmedHelper(GeoPoint p) { + // 路线选点模式优先:点地图 = 往路线末尾加一个路点,不改动原来的单点传送标记 + if (mRoutePicking) { + addRoutePoint(p.getLongitude(), p.getLatitude()); + return true; + } + + // 点击移动:开关处于“点击移动”且模拟位置在跑时,点哪朝哪走(脚本播放中会提示并忽略) + if (handleClickMoveTap(p)) { + return true; + } + + mMarkLatLngMap = wgs84ToBd09(p.getLongitude(), p.getLatitude()); + markMap(); + return true; } + /** + * 长按地图 + */ @Override - public void onGetReverseGeoCodeResult(ReverseGeoCodeResult reverseGeoCodeResult) { - if (reverseGeoCodeResult == null || reverseGeoCodeResult.error != SearchResult.ERRORNO.NO_ERROR) { - XLog.i("逆地理位置失败!"); - } else { - mMarkName = String.valueOf(reverseGeoCodeResult.getAddress()); - poiLatitude.setText(String.valueOf(reverseGeoCodeResult.getLocation().latitude)); - poiLongitude.setText(String.valueOf(reverseGeoCodeResult.getLocation().longitude)); - poiAddress.setText(reverseGeoCodeResult.getAddress()); - final InfoWindow mInfoWindow = new InfoWindow(poiView, reverseGeoCodeResult.getLocation(), -100); - mBaiduMap.showInfoWindow(mInfoWindow); + public boolean longPressHelper(GeoPoint p) { + if (mRoutePicking) { + addRoutePoint(p.getLongitude(), p.getLatitude()); + return true; } + + mMarkLatLngMap = wgs84ToBd09(p.getLongitude(), p.getLatitude()); + markMap(); + // 逆地理编码,传入 WGS-84 坐标 + mGeoCoder.reverse(p.getLatitude(), p.getLongitude(), poi -> { + if (poi == null) { + XLog.i("逆地理位置失败!"); + return; + } + mMarkName = String.valueOf(poi.name); + // 界面上一律沿用原有的 BD-09 语义展示 + GeoPoint bd09 = wgs84ToBd09(poi.longitude, poi.latitude); + poiLatitude.setText(String.valueOf(bd09.getLatitude())); + poiLongitude.setText(String.valueOf(bd09.getLongitude())); + poiAddress.setText(poi.address); + if (mMarkMarker != null) { + mMarkMarker.setTitle(poi.address); + mMarkMarker.setSnippet(poi.name); + mMarkMarker.showInfoWindow(); + } + }); + return true; } }); + mMapEventsOverlay = eventsOverlay; + mMapView.getOverlays().add(eventsOverlay); mSensorManager = (SensorManager) getSystemService(SENSOR_SERVICE);// 获取传感器管理服务 if (mSensorManager != null) { @@ -562,202 +813,370 @@ public void onGetReverseGeoCodeResult(ReverseGeoCodeResult reverseGeoCodeResult) } } - //开启地图的定位图层 + /** + * 开启地图的定位图层。 + * 原实现依赖百度定位 SDK,这里改为系统 LocationManager + osmdroid 的定位图层, + * 显示的同样是系统位置(也就是 ServiceGo 注入的模拟位置)。 + */ private void initMapLocation() { try { - // 定位初始化 - mLocClient = new LocationClient(this); - mLocClient.registerLocationListener(new BDAbstractLocationListener() { + // 定位属于运行时权限:WelcomeActivity 已申请,这里再显式检查一次, + // 既避免权限被撤销后抛 SecurityException,也满足 lint 的 MissingPermission 检查。 + if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) + != PackageManager.PERMISSION_GRANTED + && ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) + != PackageManager.PERMISSION_GRANTED) { + XLog.e("ERROR: location permission is not granted"); + return; + } + + // 定位图层 + mMyLocationOverlay = new MyLocationNewOverlay(new GpsMyLocationProvider(this), mMapView); + mMyLocationOverlay.enableMyLocation(); + mMapView.getOverlays().add(mMyLocationOverlay); + + // 系统定位,用于首次进入时把地图移动到当前位置 + mSysLocManager = (LocationManager) getSystemService(Context.LOCATION_SERVICE); + if (mSysLocManager == null) { + return; + } + + mSysLocListener = new LocationListener() { @Override - public void onReceiveLocation(BDLocation bdLocation) { - if (bdLocation == null || mMapView == null) {// mapview 销毁后不在处理新接收的位置 + public void onLocationChanged(@NonNull Location location) { + if (location == null || mMapView == null) {// mapview 销毁后不在处理新接收的位置 return; } - mCurrentCity = bdLocation.getCity(); - mCurrentLat = bdLocation.getLatitude(); - mCurrentLon = bdLocation.getLongitude(); - MyLocationData locData = new MyLocationData.Builder() - .accuracy(bdLocation.getRadius()) - .direction(mCurrentDirection)// 此处设置开发者获取到的方向信息,顺时针0-360 - .latitude(bdLocation.getLatitude()) - .longitude(bdLocation.getLongitude()).build(); - mBaiduMap.setMyLocationData(locData); - MyLocationConfiguration configuration = new MyLocationConfiguration(MyLocationConfiguration.LocationMode.NORMAL, true, null); - mBaiduMap.setMyLocationConfiguration(configuration); - - /* 如果出现错误,则需要重新请求位置 */ - int err = bdLocation.getLocType(); - if (err == BDLocation.TypeCriteriaException || err == BDLocation.TypeNetWorkException) { - mLocClient.requestLocation(); /* 请求位置 */ - } else { - if (isFirstLoc) { - isFirstLoc = false; - // 这里记录百度地图返回的位置 - mMarkLatLngMap = new LatLng(bdLocation.getLatitude(), bdLocation.getLongitude()); - MapStatus.Builder builder = new MapStatus.Builder(); - builder.target(mMarkLatLngMap).zoom(18.0f); - mBaiduMap.animateMapStatus(MapStatusUpdateFactory.newMapStatus(builder.build())); - - XLog.i("First Baidu LatLng: " + mMarkLatLngMap); + mCurrentLat = location.getLatitude(); + mCurrentLon = location.getLongitude(); + + // 把真实定位交给服务端:脚本 / 点击移动正在接管位置时它只记录不覆盖。 + // 以前这里(以及服务端的真实定位回调)会直接写当前位置, + // 切一次应用就把模拟坐标拉回手机所在地,表现为“脚本失灵 + 回到真实位置”。 + if (mServiceBinder != null) { + try { + mServiceBinder.onRealLocation(mCurrentLon, mCurrentLat); + } catch (Exception e) { + XLog.e("ERROR: onRealLocation", e); + } + } + + if (isFirstLoc) { + isFirstLoc = false; + // 内部依旧保存 BD-09 坐标 + mMarkLatLngMap = wgs84ToBd09(mCurrentLon, mCurrentLat); + mMapView.getController().setZoom(18.0); + // 脚本正在接管位置时不要抢镜头(否则切一次应用,地图就飞回真实位置, + // 看起来和“模拟位置回到正确位置”一模一样) + if (!isScriptPlaying()) { + mMapView.getController().animateTo(new GeoPoint(mCurrentLat, mCurrentLon)); } + + XLog.i("First WGS84 LatLng: " + mCurrentLat + "," + mCurrentLon); } } - /** - * 错误的状态码 - * ... - *

- * 回调定位诊断信息,开发者可以根据相关信息解决定位遇到的一些问题 - * - * @param locType 当前定位类型 - * @param diagnosticType 诊断类型(1~9) - * @param diagnosticMessage 具体的诊断信息释义 - */ + @Override - public void onLocDiagnosticMessage(int locType, int diagnosticType, String diagnosticMessage) { - XLog.i("Baidu ERROR: " + locType + "-" + diagnosticType + "-" + diagnosticMessage); + public void onProviderEnabled(@NonNull String provider) { } - }); - LocationClientOption locationOption = getLocationClientOption(); - //需将配置好的LocationClientOption对象,通过setLocOption方法传递给LocationClient对象使用 - mLocClient.setLocOption(locationOption); - //开始定位 - mLocClient.start(); + + @Override + public void onProviderDisabled(@NonNull String provider) { + } + + @Override + public void onStatusChanged(String provider, int status, Bundle extras) { + } + }; + + for (String provider : mSysLocManager.getProviders(true)) { + try { + mSysLocManager.requestLocationUpdates(provider, 1000L, 0.0f, + mSysLocListener, Looper.getMainLooper()); + } catch (Exception e) { + XLog.e("ERROR: requestLocationUpdates - " + provider); + } + } + + // 先用最后一次已知位置把地图定位过去,避免等待首次回调 + Location lastLocation = null; + try { + lastLocation = mSysLocManager.getLastKnownLocation(LocationManager.GPS_PROVIDER); + if (lastLocation == null) { + lastLocation = mSysLocManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER); + } + } catch (Exception e) { + XLog.e("ERROR: getLastKnownLocation"); + } + if (lastLocation != null) { + mSysLocListener.onLocationChanged(lastLocation); + } } catch (Exception e) { XLog.e("ERROR: initMapLocation"); } } - @NonNull - private static LocationClientOption getLocationClientOption() { - LocationClientOption locationOption = new LocationClientOption(); - //可选,默认高精度,设置定位模式,高精度,低功耗,仅设备 - locationOption.setLocationMode(LocationClientOption.LocationMode.Hight_Accuracy); - //可选,默认gcj02,设置返回的定位结果坐标系,如果配合百度地图使用,建议设置为bd09ll; - locationOption.setCoorType("bd09ll"); - //可选,默认0,即仅定位一次,设置发起连续定位请求的间隔需要大于等于1000ms才是有效的 - locationOption.setScanSpan(1000); - //可选,设置是否需要地址信息,默认不需要 - locationOption.setIsNeedAddress(true); - //可选,设置是否需要设备方向结果 - locationOption.setNeedDeviceDirect(false); - //可选,默认false,设置是否当gps有效时按照1S1次频率输出GPS结果 - locationOption.setLocationNotify(true); - //可选,默认true,定位SDK内部是一个SERVICE,并放到了独立进程,设置是否在stop的时候杀死这个进程,默认不杀死 - locationOption.setIgnoreKillProcess(true); - //可选,默认false,设置是否需要位置语义化结果,可以在BDLocation.getLocationDescribe里得到,结果类似于“在北京天安门附近” - locationOption.setIsNeedLocationDescribe(false); - //可选,默认false,设置是否需要POI结果,可以在BDLocation.getPoiList里得到 - locationOption.setIsNeedLocationPoiList(false); - //可选,默认false,设置是否收集CRASH信息,默认收集 - locationOption.setIgnoreCacheException(true); - //可选,默认false,设置是否开启Gps定位 - //locationOption.setOpenGps(true); - locationOption.setOpenGnss(true); - //可选,默认false,设置定位时是否需要海拔信息,默认不需要,除基础定位版本都可用 - locationOption.setIsNeedAltitude(false); - return locationOption; - } - //地图上各按键的监听 private void initMapButton() { RadioGroup mGroupMapType = this.findViewById(R.id.RadioGroupMapType); mGroupMapType.setOnCheckedChangeListener((group, checkedId) -> { + if (mMapView == null) { + return; + } + if (checkedId == R.id.mapNormal) { - mBaiduMap.setMapType(BaiduMap.MAP_TYPE_NORMAL); + // OpenStreetMap 标准地图 + mMapView.setTileSource(TileSourceUtils.OSM_STANDARD); } if (checkedId == R.id.mapSatellite) { - mBaiduMap.setMapType(BaiduMap.MAP_TYPE_SATELLITE); + // Esri World Imagery 卫星影像(瓦片顺序 Z/Y/X) + mMapView.setTileSource(TileSourceUtils.ESRI_WORLD_IMAGERY); } + + mMapView.invalidate(); }); ImageButton curPosBtn = this.findViewById(R.id.cur_position); curPosBtn.setOnClickListener(v -> resetMap()); ImageButton zoomInBtn = this.findViewById(R.id.zoom_in); - zoomInBtn.setOnClickListener(v -> mBaiduMap.animateMapStatus(MapStatusUpdateFactory.zoomIn())); + zoomInBtn.setOnClickListener(v -> { + if (mMapView != null) { + mMapView.getController().zoomIn(); + } + }); ImageButton zoomOutBtn = this.findViewById(R.id.zoom_out); - zoomOutBtn.setOnClickListener(v -> mBaiduMap.animateMapStatus(MapStatusUpdateFactory.zoomOut())); + zoomOutBtn.setOnClickListener(v -> { + if (mMapView != null) { + mMapView.getController().zoomOut(); + } + }); ImageButton inputPosBtn = this.findViewById(R.id.input_pos); - inputPosBtn.setOnClickListener(v -> { - AlertDialog dialog; - AlertDialog.Builder builder = new AlertDialog.Builder(MainActivity.this); - builder.setTitle("请输入经度和纬度"); - View view = LayoutInflater.from(MainActivity.this).inflate(R.layout.location_input, null); - builder.setView(view); - dialog = builder.show(); - - EditText dialog_lng = view.findViewById(R.id.joystick_longitude); - EditText dialog_lat = view.findViewById(R.id.joystick_latitude); - RadioButton rbBD = view.findViewById(R.id.pos_type_bd); - - Button btnGo = view.findViewById(R.id.input_position_ok); - btnGo.setOnClickListener(v2 -> { - String dialog_lng_str = dialog_lng.getText().toString(); - String dialog_lat_str = dialog_lat.getText().toString(); - - if (TextUtils.isEmpty(dialog_lng_str) || TextUtils.isEmpty(dialog_lat_str)) { - GoUtils.DisplayToast(MainActivity.this,getResources().getString(R.string.app_error_input)); - } else { - double dialog_lng_double = Double.parseDouble(dialog_lng_str); - double dialog_lat_double = Double.parseDouble(dialog_lat_str); - - if (dialog_lng_double > 180.0 || dialog_lng_double < -180.0) { - GoUtils.DisplayToast(MainActivity.this, getResources().getString(R.string.app_error_longitude)); - } else { - if (dialog_lat_double > 90.0 || dialog_lat_double < -90.0) { - GoUtils.DisplayToast(MainActivity.this, getResources().getString(R.string.app_error_latitude)); - } else { - if (rbBD.isChecked()) { - mMarkLatLngMap = new LatLng(dialog_lat_double, dialog_lng_double); - } else { - double[] bdLonLat = MapUtils.wgs2bd09(dialog_lat_double, dialog_lng_double); - mMarkLatLngMap = new LatLng(bdLonLat[1], bdLonLat[0]); - } - mMarkName = "手动输入的坐标"; + inputPosBtn.setOnClickListener(v -> showInputPositionDialog()); + } - markMap(); + /** + * 手动输入经纬度定位:经度、纬度分栏输入。 + * + *

剪贴板(或“粘贴”按钮)里的“经度,纬度”整串坐标会自动分填到两格; + * 旧版是单框 + 自动识别,容易看岔格式,改成两格后所见即所得。

+ */ + private void showInputPositionDialog() { + AlertDialog.Builder builder = new MaterialAlertDialogBuilder(MainActivity.this); + builder.setTitle(R.string.input_button); + View view = LayoutInflater.from(MainActivity.this).inflate(R.layout.location_input, null); + builder.setView(view); + + final AlertDialog dialog = builder.show(); + + final EditText lngEdit = view.findViewById(R.id.input_position_lng); + final EditText latEdit = view.findViewById(R.id.input_position_lat); + RadioButton rbGps = view.findViewById(R.id.pos_type_gps); + Button btnPaste = view.findViewById(R.id.input_position_paste); + + // 剪贴板里如果有经纬度,直接分填两格,省去手动粘贴 + fillLngLatFromClipboard(lngEdit, latEdit); + + btnPaste.setOnClickListener(v -> { + if (!fillLngLatFromClipboard(lngEdit, latEdit)) { + GoUtils.DisplayToast(MainActivity.this, + getResources().getString(R.string.input_position_clip_invalid)); + } + }); - MapStatusUpdate mapstatusupdate = MapStatusUpdateFactory.newLatLng(mMarkLatLngMap); - mBaiduMap.setMapStatus(mapstatusupdate); + Button btnGo = view.findViewById(R.id.input_position_ok); + btnGo.setOnClickListener(v2 -> { + double[] lngLat = readLngLatFields(lngEdit, latEdit); + if (lngLat == null) { + return; // 校验失败的提示已经在 readLngLatFields 里发过了 + } - dialog.dismiss(); - } - } - } - }); + if (rbGps.isChecked()) { + // GPS 坐标系(WGS-84):内部仍按 BD-09 保存,绘制时再转回来 + double[] bd09 = MapUtils.wgs2bd09(lngLat[0], lngLat[1]); + mMarkLatLngMap = new GeoPoint(bd09[1], bd09[0]); + } else { + mMarkLatLngMap = new GeoPoint(lngLat[1], lngLat[0]); + } - Button btnCancel = view.findViewById(R.id.input_position_cancel); - btnCancel.setOnClickListener(v1 -> dialog.dismiss()); + mMarkName = getResources().getString(R.string.input_button); + markMap(); + mMapView.getController().setZoom(18.0); + mMapView.getController().setCenter( + bd09ToWgs84(mMarkLatLngMap.getLongitude(), mMarkLatLngMap.getLatitude())); + + dialog.dismiss(); + GoUtils.DisplayToast(MainActivity.this, getResources().getString(R.string.input_position_ok_toast)); }); + + Button btnCancel = view.findViewById(R.id.input_position_cancel); + btnCancel.setOnClickListener(v1 -> dialog.dismiss()); + } + + /** + * 把剪贴板里的经纬度解析出来分填到两个输入框。 + * + * @return 解析成功返回 true;剪贴板为空或不是经纬度返回 false + */ + private boolean fillLngLatFromClipboard(EditText lngEdit, EditText latEdit) { + double[] lngLat = MapUtils.parseLngLat(getClipboardText()); + if (lngLat == null) { + return false; + } + + lngEdit.setText(formatCoordInput(lngLat[0])); + latEdit.setText(formatCoordInput(lngLat[1])); + lngEdit.setSelection(lngEdit.getText().length()); + return true; + } + + /** 输入框里的坐标文本:保留 6 位精度并去掉多余的 0(116.397000 -> 116.397) */ + private static String formatCoordInput(double value) { + String text = String.format(java.util.Locale.US, "%.6f", value); + text = text.replaceFirst("0+$", "").replaceFirst("\\.$", ""); + return text.isEmpty() ? "0" : text; + } + + /** + * 读取并校验经度 / 纬度两个输入框。 + * + * @return {经度, 纬度};非法时弹提示并返回 null + */ + private double[] readLngLatFields(EditText lngEdit, EditText latEdit) { + String lngText = lngEdit.getText().toString().trim(); + String latText = latEdit.getText().toString().trim(); + if (TextUtils.isEmpty(lngText) || TextUtils.isEmpty(latText)) { + GoUtils.DisplayToast(this, getResources().getString(R.string.input_position_empty)); + return null; + } + + double lng; + double lat; + try { + lng = Double.parseDouble(lngText); + lat = Double.parseDouble(latText); + } catch (NumberFormatException e) { + GoUtils.DisplayToast(this, getResources().getString(R.string.app_error_input)); + return null; + } + + if (lng < -180.0 || lng > 180.0 || lat < -90.0 || lat > 90.0) { + GoUtils.DisplayToast(this, getResources().getString(R.string.coord_range_error)); + return null; + } + + return new double[] {lng, lat}; + } + + /*============================== 跳转经纬度 / 地址 ==============================*/ + + /** 读取剪贴板文本,失败或为空时返回 null */ + private String getClipboardText() { + try { + ClipboardManager cm = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + if (cm == null || !cm.hasPrimaryClip()) { + return null; + } + + ClipData clipData = cm.getPrimaryClip(); + if (clipData == null || clipData.getItemCount() <= 0) { + return null; + } + + CharSequence text = clipData.getItemAt(0).coerceToText(this); + return text == null ? null : text.toString().trim(); + } catch (Exception e) { + XLog.e("ERROR: getClipboardText"); + return null; + } } //标定选择的位置 private void markMap() { - if (mMarkLatLngMap != null) { - MarkerOptions ooA = new MarkerOptions().position(mMarkLatLngMap).icon(mMapIndicator); - mBaiduMap.clear(); - mBaiduMap.addOverlay(ooA); + markMapOnView(mMapView, mMarkMarker, mMarkLatLngMap); + } + + /** + * 把 BD-09 的选点绘制到地图上(绘制前转换成 WGS-84) + */ + private static void markMapOnView(MapView mapView, Marker marker, GeoPoint bd09Point) { + if (mapView == null || marker == null || bd09Point == null) { + return; + } + marker.closeInfoWindow(); + marker.setPosition(bd09ToWgs84(bd09Point.getLongitude(), bd09Point.getLatitude())); + if (!mapView.getOverlays().contains(marker)) { + mapView.getOverlays().add(marker); + } + mapView.invalidate(); + } + + /** + * 清除选点标记 + */ + private static void clearMark(MapView mapView, Marker marker) { + if (mapView == null) { + return; + } + if (marker != null) { + marker.closeInfoWindow(); + mapView.getOverlays().remove(marker); + } + mapView.invalidate(); + } + + /** + * 创建选点标记。osmdroid 的 Marker 需要各自持有一份 Drawable, + * 所以这里从 ConstantState 新建,避免与摇杆悬浮窗地图共用同一个实例。 + */ + private Marker createMarkMarker() { + if (mMapView == null || sMapIndicatorDrawable == null) { + return null; } + Drawable icon = sMapIndicatorDrawable; + if (sMapIndicatorDrawable.getConstantState() != null) { + icon = sMapIndicatorDrawable.getConstantState().newDrawable(); + } + Marker marker = new Marker(mMapView); + marker.setIcon(icon); + // 图标底部中点对准坐标点,与原百度地图标记的视觉一致 + marker.setAnchor(0.5f, 1.0f); + return marker; + } + + /** + * BD-09 -> WGS-84 + * + * @return WGS-84 坐标点,可直接交给 osmdroid 绘制 + */ + private static GeoPoint bd09ToWgs84(double bd09Lng, double bd09Lat) { + double[] wgs84 = MapUtils.bd2wgs(bd09Lng, bd09Lat); + return new GeoPoint(wgs84[1], wgs84[0]); + } + + /** + * WGS-84 -> BD-09 + * + * @return BD-09 坐标点,用于保持工程内部原有的坐标语义 + */ + private static GeoPoint wgs84ToBd09(double wgs84Lng, double wgs84Lat) { + double[] bd09 = MapUtils.wgs2bd09(wgs84Lng, wgs84Lat); + return new GeoPoint(bd09[1], bd09[0]); } private void resetMap() { - mBaiduMap.clear(); + clearMark(mMapView, mMarkMarker); mMarkLatLngMap = null; - MyLocationData locData = new MyLocationData.Builder() - .latitude(mCurrentLat) - .longitude(mCurrentLon) - .direction(mCurrentDirection) - .build(); - mBaiduMap.setMyLocationData(locData); - - MapStatus.Builder builder = new MapStatus.Builder(); - builder.target(new LatLng(mCurrentLat, mCurrentLon)).zoom(18.0f); - mBaiduMap.animateMapStatus(MapStatusUpdateFactory.newMapStatus(builder.build())); + // 定位图层由 mMyLocationOverlay 负责刷新,这里只需要把视野移回当前位置 + if (mMapView != null && (mCurrentLat != 0.0 || mCurrentLon != 0.0)) { + mMapView.getController().setZoom(18.0); + mMapView.getController().animateTo(new GeoPoint(mCurrentLat, mCurrentLon)); + } } // 在地图上显示位置 @@ -767,12 +1186,10 @@ public static boolean showLocation(String name, String bd09Longitude, String bd0 try { if (!bd09Longitude.isEmpty() && !bd09Latitude.isEmpty()) { mMarkName = name; - mMarkLatLngMap = new LatLng(Double.parseDouble(bd09Latitude), Double.parseDouble(bd09Longitude)); - MarkerOptions ooA = new MarkerOptions().position(mMarkLatLngMap).icon(mMapIndicator); - mBaiduMap.clear(); - mBaiduMap.addOverlay(ooA); - MapStatusUpdate mapstatusupdate = MapStatusUpdateFactory.newLatLng(mMarkLatLngMap); - mBaiduMap.setMapStatus(mapstatusupdate); + mMarkLatLngMap = new GeoPoint(Double.parseDouble(bd09Latitude), Double.parseDouble(bd09Longitude)); + markMapOnView(mMapView, mMarkMarker, mMarkLatLngMap); + mMapView.getController().setCenter( + bd09ToWgs84(mMarkLatLngMap.getLongitude(), mMarkLatLngMap.getLatitude())); } } catch (Exception e) { ret = false; @@ -782,50 +1199,1273 @@ public static boolean showLocation(String name, String bd09Longitude, String bd0 return ret; } + /** + * 主界面地图上的 POI 信息气泡,对应原实现里的百度 InfoWindow。 + * 布局与按钮逻辑完全复用 location_poi_info。 + */ + private static class PoiInfoWindow extends InfoWindow { + PoiInfoWindow(View view, MapView mapView) { + super(view, mapView); + } + + @Override + public void onOpen(Object item) { + } + + @Override + public void onClose() { + } + } + private void initGoBtn() { mButtonStart = findViewById(R.id.faBtnStart); mButtonStart.setOnClickListener(this::doGoLocation); + + mButtonScript = findViewById(R.id.faBtnScript); + if (mButtonScript != null) { + mButtonScript.setOnClickListener(this::doScriptLocation); + } } - private void startGoLocation() { - Intent serviceGoIntent = new Intent(MainActivity.this, ServiceGo.class); - bindService(serviceGoIntent, mConnection, BIND_AUTO_CREATE); // 绑定服务和活动,之后活动就可以去调服务的方法了 - double[] latLng = MapUtils.bd2wgs(mMarkLatLngMap.longitude, mMarkLatLngMap.latitude); - serviceGoIntent.putExtra(LNG_MSG_ID, latLng[0]); - serviceGoIntent.putExtra(LAT_MSG_ID, latLng[1]); - double alt = Double.parseDouble(sharedPreferences.getString("setting_altitude", "55.0")); - serviceGoIntent.putExtra(ALT_MSG_ID, alt); + /** 脚本播放状态回调(可能在定位线程触发,刷新界面要切回主线程) */ + private final ServiceGo.ScriptListener mScriptListener = new ServiceGo.ScriptListener() { + @Override + public void onScriptSegment(com.zcshou.script.ScriptRoute route, int index, + com.zcshou.script.ScriptWaypoint.Mode mode, double speed) { + mRunningScriptId = route == null ? null : route.id; + runOnUiThread(MainActivity.this::updateScriptButton); + } - startForegroundService(serviceGoIntent); - XLog.d("startForegroundService: ServiceGo"); + @Override + public void onScriptFinish(com.zcshou.script.ScriptRoute route) { + // 跑完 = 结束:本地也按“没在播”处理,路线条直接回到“开始移动”、 + // 传送/点击移动不再被“播放中”拦截,模拟位置可以立刻继续 + mRunningScriptId = null; + mScriptPaused = false; + runOnUiThread(() -> { + updateScriptButton(); + GoUtils.DisplayToast(MainActivity.this, getResources().getString(R.string.script_finished)); + }); + } - isMockServStart = true; - } + @Override + public void onScriptStopped() { + mRunningScriptId = null; + mScriptPaused = false; + runOnUiThread(MainActivity.this::updateScriptButton); + } - private void stopGoLocation() { - unbindService(mConnection); // 解绑服务,服务要记得解绑,不要造成内存泄漏 - Intent serviceGoIntent = new Intent(MainActivity.this, ServiceGo.class); - stopService(serviceGoIntent); - isMockServStart = false; + @Override + public void onScriptPaused() { + mScriptPaused = true; + runOnUiThread(MainActivity.this::updateScriptButton); + } + + @Override + public void onScriptResumed() { + mScriptPaused = false; + runOnUiThread(MainActivity.this::updateScriptButton); + } + }; + + /** 按当前是否在播放脚本切换入口按钮的图标(路线操作条上的按钮随之刷新) */ + private void updateScriptButton() { + // 脚本“没在播 → 在播”的沿变化:脚本启动会把位置对齐到路线首点(常在视野外), + // 开启跟随让地图追着人物走,白色箭头始终在视野里;用户手动拖地图会自动退出跟随 + boolean scriptRunning = mRunningScriptId != null; + if (scriptRunning && !mWasScriptRunning && mMyLocationOverlay != null) { + try { + mMyLocationOverlay.enableFollowLocation(); + } catch (Exception e) { + XLog.e("ERROR: enableFollowLocation", e); + } + } + mWasScriptRunning = scriptRunning; + + // 脚本启停可能伴随模拟位置服务重建,顺手确保定位图层还开着 + ensureMyLocationEnabled(); + // 模拟位置(脚本)的启停决定左下角开关的显隐 + refreshClickMoveSwitch(); + // 点击移动的“开始行走 / 停止行走”跟着运行状态刷新 + updateClickMoveUi(); + + if (mButtonScript != null) { + boolean running = mRunningScriptId != null; + mButtonScript.setImageResource(running ? R.drawable.ic_close : R.drawable.ic_script); + } + + updateRouteUi(); } - private void doGoLocation(View v) { - if (!GoUtils.isNetworkAvailable(this)) { - GoUtils.DisplayToast(this, getResources().getString(R.string.app_error_network)); + /** + * 确保地图的定位图层(人物箭头)处于开启状态,并重新注册一次监听。 + * + *

模拟位置服务销毁时会移除 GPS / 网络测试提供者,这期间回到界面对图层调 + * enableMyLocation() 会因为“没有可用提供者”**静默失败**(GpsMyLocationProvider + * 吞掉异常返回 false,图层的 mIsLocationEnabled 置为 false 且不再自动重试), + * 表现就是“切换到脚本 / 从脚本切回摇杆后白色箭头不见了”,而模拟位置本身照常。

+ * + *

脚本启动、停止走 stopService 都正好落在“提供者先删后加”的窗口里, + * 所以这里失败时会挂一个 1 秒后的自动重试(同一时刻只允许一条重试链, + * 成功即停),提供者一建回来箭头立刻恢复,不必干等下一个界面事件。

+ */ + private void ensureMyLocationEnabled() { + if (mMyLocationOverlay == null) { return; } - if (!GoUtils.isGpsOpened(this)) { - GoUtils.showEnableGpsDialog(this); - return; + try { + boolean ok = mMyLocationOverlay.enableMyLocation(); + if (ok) { + mMyLocationRetryPending = false; + return; + } + + if (!mMyLocationRetryPending && mMapView != null) { + mMyLocationRetryPending = true; + mMapView.postDelayed(() -> { + mMyLocationRetryPending = false; + if (isFinishing() || isDestroyed()) { + return; + } + ensureMyLocationEnabled(); + }, 1000); + } + } catch (Exception e) { + XLog.e("ERROR: enableMyLocation", e); } + } - if (!Settings.canDrawOverlays(getApplicationContext())) {//悬浮窗权限判断 - GoUtils.showEnableFloatWindowDialog(this); - XLog.e("无悬浮窗权限!"); - return; + /** + * 脚本模式入口:直接打开脚本列表。 + * + *

上一次模拟的位置已经由 ServiceGo 保存在 SharedPreferences 里,脚本编辑页可以直接引用, + * 因此这里不需要绑定服务。

+ */ + private void doScriptLocation(View v) { + // 已经在播放脚本时,这个按钮变成“停止脚本” + if (mRunningScriptId != null && mServiceBinder != null) { + boolean finished = mServiceBinder.getScriptState() == ServiceGo.SCRIPT_STATE_FINISHED; + try { + mServiceBinder.stopScript(); + mRunningScriptId = null; + mScriptPaused = false; + updateScriptButton(); + Snackbar.make(v, getResources().getString( + finished ? R.string.script_finished : R.string.script_stopped), + Snackbar.LENGTH_LONG).setAction("Action", null).show(); + + // 单次脚本已经跑完,顺手把服务停掉,避免前台通知一直挂着 + // 注意:stopService 只有在没有绑定时才会真正销毁服务,所以这里要先解绑 + if (finished) { + unbindServiceIfNeeded(); + stopService(new Intent(MainActivity.this, ServiceGo.class)); + isMockServStart = false; + } + return; + } catch (Exception e) { + XLog.e("ERROR: stopScript", e); + } } + startActivity(new Intent(MainActivity.this, ScriptActivity.class)); + } + + /*============================== 主界面连续选点成路线(连贯移动) ==============================*/ + + /** 路线选点入口按钮、操作条与连线图层 */ + private void initRoutePicking() { + mButtonRoute = findViewById(R.id.faBtnRoute); + if (mButtonRoute != null) { + mButtonRoute.setOnClickListener(v -> setRoutePicking(!mRoutePicking)); + } + + mRouteBar = findViewById(R.id.route_bar); + mRouteHint = findViewById(R.id.route_hint); + mRouteGoButton = findViewById(R.id.route_go); + mRoutePauseButton = findViewById(R.id.route_pause); + + mRouteModeSpinner = findViewById(R.id.route_mode); + if (mRouteModeSpinner != null) { + ArrayAdapter adapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, + new String[] { + getResources().getString(R.string.script_mode_walk), + getResources().getString(R.string.script_mode_run), + getResources().getString(R.string.script_mode_bike)}); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + mRouteModeSpinner.setAdapter(adapter); + mRouteModeSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + ScriptWaypoint.Mode mode = position == 1 ? ScriptWaypoint.Mode.RUN + : position == 2 ? ScriptWaypoint.Mode.BIKE : ScriptWaypoint.Mode.WALK; + // setAdapter / setSelection 触发的回声与当前取值一致时忽略, + // 避免进入选点模式的瞬间误切一次移动状态 + if (mode == mRouteMode) { + return; + } + + mRouteMode = mode; + // 路线正在跑时,切换同时对剩余路段生效(走 / 跑 / 骑随时切) + applyLiveMode(mode); + } + + @Override + public void onNothingSelected(AdapterView parent) { + } + }); + } + + View undoButton = findViewById(R.id.route_undo); + if (undoButton != null) { + undoButton.setOnClickListener(v -> undoRoutePoint()); + } + View clearButton = findViewById(R.id.route_clear); + if (clearButton != null) { + clearButton.setOnClickListener(v -> clearRoutePoints()); + } + View closeButton = findViewById(R.id.route_close); + if (closeButton != null) { + closeButton.setOnClickListener(v -> setRoutePicking(false)); + } + if (mRouteGoButton != null) { + mRouteGoButton.setOnClickListener(v -> { + if (isMapRoutePlaying()) { + stopMapRoute(); + } else { + startMapRoute(); + } + }); + } + if (mRoutePauseButton != null) { + mRoutePauseButton.setOnClickListener(v -> toggleMapRoutePause()); + } + + // 连线图层:先放进地图,选点过程中不断更新坐标列表 + if (mMapView != null) { + mRouteLine = new Polyline(mMapView); + mRouteLine.getOutlinePaint().setColor(ContextCompat.getColor(this, R.color.colorAccent)); + mRouteLine.getOutlinePaint().setStrokeWidth(8.0f); + addRouteOverlay(mRouteLine); + } + + updateRouteUi(); + } + + /** + * 加入路线图层(连线 / 图钉)。 + * + *

osmdroid 的事件按图层反序派发:这些图层只用来“看”,如果排在 + * {@link MapEventsOverlay} 之后,点在已有图钉或连线上就触达不到地图回调, + * 也就没法继续加路点。所以每次加完都把点击派发器挪回最上面。

+ */ + private void addRouteOverlay(Overlay overlay) { + if (mMapView == null || overlay == null) { + return; + } + + List overlays = mMapView.getOverlays(); + overlays.add(overlay); + if (mMapEventsOverlay != null && overlays.remove(mMapEventsOverlay)) { + overlays.add(mMapEventsOverlay); + } + } + + /** 开关“路线选点”模式 */ + private void setRoutePicking(boolean picking) { + mRoutePicking = picking; + if (mRouteBar != null) { + mRouteBar.setVisibility(picking ? View.VISIBLE : View.GONE); + } + if (mButtonRoute != null) { + mButtonRoute.setImageResource(picking ? R.drawable.ic_close : R.drawable.ic_route); + } + + // 选点模式与点击移动的目标点操作条互斥:两条操作条都贴顶部,同时显示会叠在一起 + refreshClickMoveSwitch(); + + GoUtils.DisplayToast(this, getResources().getString( + picking ? R.string.route_mode_on : R.string.route_mode_off)); + updateRouteUi(); + } + + /** + * 往路线末尾追加一个路点。 + * + * @param lngWgs84 经度(WGS-84:地图回调给的就是这个坐标系,也正是 ServiceGo 注入系统的坐标系) + * @param latWgs84 纬度(WGS-84) + */ + private void addRoutePoint(double lngWgs84, double latWgs84) { + mRoutePoints.add(new ScriptWaypoint(lngWgs84, latWgs84, getSettingAltitude(), + mRouteMode, getModeSpeed(mRouteMode), 0)); + redrawRoute(); + } + + /** 撤销最后一个路点 */ + private void undoRoutePoint() { + if (mRoutePoints.isEmpty()) { + GoUtils.DisplayToast(this, getResources().getString(R.string.route_empty)); + return; + } + + mRoutePoints.remove(mRoutePoints.size() - 1); + redrawRoute(); + } + + /** 清空所有路点(连同地图上的连线与图钉) */ + private void clearRoutePoints() { + mRoutePoints.clear(); + redrawRoute(); + } + + /** + * 画一组“路点图钉 + 连线”的公共实现。 + * + *

路线选点和点击移动的画法完全一样,只差数据源、图层对象、标题文案和 + * 画完之后要刷新哪条操作条,所以这四样参数化,避免两份一模一样的循环 + * 日后改了一处忘了另一处(例如以后要给“队头那个正在走向的点”换个图标)。

+ * + * @param points 路点(WGS-84) + * @param markers 这组路点当前持有的图钉,会先全部摘掉再按 points 重建 + * @param line 连线图层,可为 null(只画图钉) + * @param titleRes 图钉标题文案,形如“第 %1$d 个点” + * @param refresh 画完之后需要刷新的操作条 + */ + private void redrawPoints(List points, List markers, + Polyline line, int titleRes, Runnable refresh) { + if (mMapView == null) { + return; + } + + for (Marker marker : markers) { + marker.closeInfoWindow(); + mMapView.getOverlays().remove(marker); + } + markers.clear(); + + List geoPoints = new ArrayList<>(); + for (int i = 0; i < points.size(); i++) { + ScriptWaypoint point = points.get(i); + GeoPoint geoPoint = new GeoPoint(point.lat, point.lng); + geoPoints.add(geoPoint); + + Marker marker = createMarkMarker(); + if (marker != null) { + marker.setPosition(geoPoint); + marker.setTitle(getResources().getString(titleRes, i + 1)); + addRouteOverlay(marker); + markers.add(marker); + } + } + + if (line != null) { + line.setPoints(geoPoints); + } + + mMapView.invalidate(); + refresh.run(); + } + + /** 重画路线:连线 + 每个路点一个图钉 */ + private void redrawRoute() { + redrawPoints(mRoutePoints, mRouteMarkers, mRouteLine, + R.string.route_point_title, this::updateRouteUi); + } + + /** 刷新操作条:已选点数 + 主按钮是“开始移动”还是“停止移动”,以及暂停 / 继续按钮的状态 */ + private void updateRouteUi() { + if (mRouteHint != null) { + int count = mRoutePoints.size(); + mRouteHint.setText(count == 0 + ? getResources().getString(R.string.route_hint_empty) + : getResources().getString(R.string.route_hint_count, count)); + } + + if (mRouteGoButton != null) { + boolean playing = isMapRoutePlaying(); + // 图标跟着文案一起切:开始 = 移动箭头,停止 = 叉。 + // 只在 XML 里写死一个图标的话,文案变成“停止移动”时图标还指着移动,会误导 + mRouteGoButton.setText(playing ? R.string.route_stop : R.string.route_go); + mRouteGoButton.setIconResource(playing ? R.drawable.ic_close : R.drawable.ic_move); + } + + if (mRoutePauseButton != null) { + // 暂停按钮只在这条路线跑着时出现;暂停中换成“继续”(图标 暂停 ⇄ 播放) + boolean active = isMapRoutePlaying(); + mRoutePauseButton.setVisibility(active ? View.VISIBLE : View.GONE); + if (active) { + mRoutePauseButton.setText(mScriptPaused ? R.string.route_resume : R.string.route_pause); + mRoutePauseButton.setIconResource(mScriptPaused ? R.drawable.ic_play : R.drawable.ic_pause); + } + } + } + + /** + * 路线操作条上的“暂停 / 继续”切换。 + * + *

暂停 = 位置停在原地、进度保留;继续 = 从暂停处原地接着走(不会瞬移、不会跳段)。 + * 服务还没绑上时先记住请求,连上后在 {@link #syncStateWithService()} 里补执行, + * 避免“点了一下没反应”。

+ */ + private void toggleMapRoutePause() { + if (mServiceBinder == null) { + mPendingPauseRequest = !mScriptPaused; + bindServiceIfNeeded(); + return; + } + + try { + if (mScriptPaused) { + boolean ok = mServiceBinder.resumeScript(); + if (ok) { + mScriptPaused = false; + } + GoUtils.DisplayToast(this, getResources().getString( + ok ? R.string.script_resumed_toast : R.string.script_resume_fail)); + } else { + boolean ok = mServiceBinder.pauseScript(); + if (ok) { + mScriptPaused = true; + } + GoUtils.DisplayToast(this, getResources().getString( + ok ? R.string.script_paused_toast : R.string.script_pause_fail)); + } + updateRouteUi(); + } catch (Exception e) { + XLog.e("ERROR: toggleMapRoutePause", e); + } + } + + /** 当前正在跑的路线是不是主界面选的这条(含被编辑后另存的替换路线) */ + private boolean isMapRoutePlaying() { + if (mRunningScriptId == null) { + return false; + } + + String preferredId = sharedPreferences.getString(KEY_MAIN_MAP_ROUTE_ID, MAP_ROUTE_ID); + return mRunningScriptId.equals(preferredId) || MAP_ROUTE_ID.equals(mRunningScriptId); + } + + /** + * 播放中切换移动状态;没在播放时静默返回(下拉的取值已记在 mRouteMode 里, + * 等开始移动时作为新路点的默认状态)。 + */ + private void applyLiveMode(ScriptWaypoint.Mode mode) { + if (mRunningScriptId == null || mode == null) { + return; + } + + if (mServiceBinder == null) { + // 脚本是在脚本模式页面启动的,这里还没绑上;补绑一次,稍后切换才会生效 + bindServiceIfNeeded(); + return; + } + + try { + boolean ok = mServiceBinder.switchLiveMode(mode); + GoUtils.DisplayToast(this, getResources().getString( + ok ? R.string.mode_switched : R.string.mode_switch_fail, mode.label)); + } catch (Exception e) { + XLog.e("ERROR: switchLiveMode", e); + } + } + + /** 把当前选点存成一条脚本路线,并让 ServiceGo 沿路线平滑移动 */ + private void startMapRoute() { + if (mRoutePoints.size() < 2) { + GoUtils.DisplayToast(this, getResources().getString(R.string.route_need_two)); + return; + } + + if (!GoUtils.isNetworkAvailable(this)) { + GoUtils.DisplayToast(this, getResources().getString(R.string.app_error_network)); + return; + } + if (!GoUtils.isGpsOpened(this)) { + GoUtils.showEnableGpsDialog(this); + return; + } + if (!Settings.canDrawOverlays(getApplicationContext())) { + GoUtils.showEnableFloatWindowDialog(this); + return; + } + if (!GoUtils.isAllowMockLocation(this)) { + GoUtils.showEnableMockLocationDialog(this); + return; + } + + ScriptRoute route = buildMapRoute(); + new ScriptStore(this).save(route); + + ScriptWaypoint first = route.points.get(0); + Intent intent = new Intent(MainActivity.this, ServiceGo.class); + intent.putExtra(SCRIPT_ROUTE_ID, route.id); + intent.putExtra(LNG_MSG_ID, first.lng); + intent.putExtra(LAT_MSG_ID, first.lat); + intent.putExtra(ALT_MSG_ID, first.alt); + + try { + mPendingStop = false; + if (mServiceBinder == null) { + // 先记下待启动的脚本:连接回调里会用 binder 兜底启动一次, + // 不再只依赖 onStartCommand 异步送达(送达失败 = 点开始不动) + mPendingScriptId = route.id; + bindServiceIfNeeded(); + } + startForegroundService(intent); + mRunningScriptId = route.id; + mScriptPaused = false; + updateScriptButton(); + // 服务已经在跑时立刻校验一次:服务端没在播就用 binder 补启动 + ensureScriptPlaying(route.id); + // 服务可能正在(重)建测试提供者:延迟再补一次,让定位图层重新挂上监听 + if (mMapView != null) { + mMapView.postDelayed(this::ensureMyLocationEnabled, 600); + } + GoUtils.DisplayToast(this, getResources().getString(R.string.route_started, + route.points.size(), ScriptParser.formatDistance(ScriptParser.totalDistance(route)))); + } catch (Exception e) { + XLog.e("ERROR: startMapRoute", e); + GoUtils.DisplayToast(this, getResources().getString(R.string.app_error_service)); + } + } + + /** 停止沿路线移动(当前位置保持不变) */ + private void stopMapRoute() { + mPendingScriptId = null; // 还没落地的“开始”作废 + + if (mServiceBinder != null) { + try { + mServiceBinder.stopScript(); + } catch (Exception e) { + XLog.e("ERROR: stopMapRoute", e); + } + } else { + // 先把持久状态清掉:以前这一步只靠服务端 stopScript, + // 服务没绑上时走 stopService,存储里的运行 id 会残留, + // 下次进来按钮还显示“停止移动”,点“开始”实际执行的是停止 + new ScriptStore(this).setRunningScriptId(null); + + if (mBoundToService) { + // 已发起绑定、回调还没回来:直接 stopService 停不掉“已绑定”的服务, + // 记下来等连上后再补一次停止 + mPendingStop = true; + } else { + try { + stopService(new Intent(MainActivity.this, ServiceGo.class)); + } catch (Exception e) { + XLog.e("ERROR: stopMapRoute"); + } + } + } + + mRunningScriptId = null; + mScriptPaused = false; + updateScriptButton(); + GoUtils.DisplayToast(this, getResources().getString(R.string.script_stopped)); + } + + /** + * 校验服务端确实在播放指定脚本;没在播就用 binder 兜底启动一次。 + * + *

“点开始不动”的兜底:不管 onStartCommand 有没有送到,只要服务连上了, + * 最终一定会真正进入播放状态。启动失败(脚本数据异常等)给明确提示,不再静默。

+ * + *

唯一例外是“服务端已经在播同一条脚本”:这时重复启动会让播放器重新走一遍 + * {@code findAnchor},用当前模拟位置把轨迹重新锚定一次,位置会莫名跳一下, + * 这正是“切一次应用脚本就失灵”的来源。所以这里按“播放器双确认 + id 相同”跳过: + * 同一条真的在播就不重启(不会漏掉“停止 → 再开始”的重启,那时已经不在播), + * 换一条脚本则照常切过去,不会出现“换了脚本还在跑旧路线”。

+ */ + private void ensureScriptPlaying(String scriptId) { + if (mServiceBinder == null || TextUtils.isEmpty(scriptId)) { + return; + } + + try { + // 服务端“真的”在播同一条脚本时才跳过启动:重复 startScript 会让播放器 + // 重新走一遍 findAnchor,用当前模拟位置把轨迹重新锚定一次, + // 表现就是切一次应用 / 切一次页面脚本就跳一下甚至失灵。 + // + // 这里必须同时满足两个条件: + // 1) isScriptPlaying() —— 状态 + 播放器双确认。只看 getRunningScriptId() + // 会被“状态停在 PLAYING 但播放器早就停了”的残留骗到,误跳过启动; + // 2) id 相同 —— 换一条脚本必须立刻切过去,否则会一直跑旧路线。 + boolean playingSame = mServiceBinder.isScriptPlaying() + && scriptId.equals(mServiceBinder.getRunningScriptId()); + if (playingSame) { + // 暂停中又点了一次“开始”:当作继续播放,而不是重新对齐轨迹 + if (mServiceBinder.isScriptPaused() && mServiceBinder.resumeScript()) { + mScriptPaused = false; + updateRouteUi(); + } + mRunningScriptId = scriptId; + return; + } + + boolean ok = mServiceBinder.startScript(scriptId); + if (ok) { + mRunningScriptId = scriptId; + mScriptPaused = false; + } else { + GoUtils.DisplayToast(this, getResources().getString(R.string.script_start_fail)); + } + } catch (Exception e) { + XLog.e("ERROR: ensureScriptPlaying", e); + } + } + + /** 服务连上后把挂起的开始 / 停止请求落地,并用服务端状态校准本地缓存 */ + private void syncStateWithService() { + if (mServiceBinder == null) { + return; + } + + try { + if (mPendingStop) { + mPendingStop = false; + mServiceBinder.stopScript(); + mRunningScriptId = null; + } else if (mPendingScriptId != null) { + String pending = mPendingScriptId; + mPendingScriptId = null; + ensureScriptPlaying(pending); + } else { + // 常规同步:以服务端为准(本地可能是服务销毁前留下的过期状态) + mRunningScriptId = mServiceBinder.getRunningScriptId(); + if (mRunningScriptId == null) { + // 服务端没在播放,存储里的运行记录也一定是过期的,一并清掉, + // 否则每次 onResume 都会被它带回“停止移动”的错误状态 + new ScriptStore(MainActivity.this).setRunningScriptId(null); + } + } + + // 挂起的“暂停 / 继续”排在启动、停止之后落地:脚本还没启动就先执行暂停, + // 请求会被直接吞掉,表现就是“点开始又点暂停,脚本照常播放” + if (mPendingPauseRequest != null) { + boolean wantPause = mPendingPauseRequest; + mPendingPauseRequest = null; + if (wantPause) { + mServiceBinder.pauseScript(); + } else { + mServiceBinder.resumeScript(); + } + } + // 点击行走的挂起暂停同理:脚本启动会作废点击目标,也必须排在启动之后 + if (mPendingClickPauseRequest != null) { + boolean wantPause = mPendingClickPauseRequest; + mPendingClickPauseRequest = null; + if (wantPause) { + mServiceBinder.pauseClickMove(); + } else { + mServiceBinder.resumeClickMove(); + } + } + + // 两套“暂停”状态都以服务端为准(可能是在通知栏 / 脚本页发起的) + mScriptPaused = mServiceBinder.isScriptPaused(); + mClickMovePaused = mServiceBinder.isClickMovePaused(); + } catch (Exception e) { + XLog.e("ERROR: syncStateWithService", e); + } + } + + /** + * 把主界面选的点整理成一条脚本路线。 + * + *

同时按脚本格式生成文本:一是让 ServiceGo 重新解析文本后依旧得到这些 WGS-84 坐标 + * (文本按“脚本模式记住的坐标系”输出,见 {@link ScriptParser#KEY_SCRIPT_FROM_BD09}), + * 二是让这条路线在脚本模式里能直接看到和编辑。

+ */ + private ScriptRoute buildMapRoute() { + // 上一次“主界面选点”生成的路线 id;它被手动编辑过就不再覆盖,另存后记住新的 id + String preferredId = sharedPreferences.getString(KEY_MAIN_MAP_ROUTE_ID, MAP_ROUTE_ID); + ScriptStore store = new ScriptStore(this); + ScriptRoute existing = store.load(preferredId); + + ScriptRoute route = new ScriptRoute(); + if (existing != null && existing.edited) { + // 这条路线已经被用户在脚本界面改过,不能再覆盖它:本次选点另存为一条新脚本, + // 并记住新 id,后续选点继续覆盖这条新的 + sharedPreferences.edit().putString(KEY_MAIN_MAP_ROUTE_ID, route.id).apply(); + } else { + route.id = preferredId; + } + route.name = getResources().getString(R.string.route_default_name); + route.endMode = ScriptRoute.END_STOP; + route.points = new ArrayList<>(mRoutePoints); + + // 这条路线由主界面选点生成,坐标本身就是 WGS-84,并把坐标系记在脚本自己身上, + // 之后不管全局单选怎么变,重新解析 route.text 都不会偏移 + boolean fromBd09 = false; + route.fromBd09 = Boolean.FALSE; + StringBuilder text = new StringBuilder(); + for (ScriptWaypoint point : route.points) { + text.append(ScriptParser.formatPoint(point, fromBd09)).append('\n'); + } + route.text = text.toString(); + + return route; + } + + /** “设置”里某个状态对应的速度(米/秒) */ + private double getModeSpeed(ScriptWaypoint.Mode mode) { + double[] speeds = getModeSpeeds(); + return speeds[Math.max(0, Math.min(speeds.length - 1, mode.ordinal()))]; + } + + /** “设置”里的步行 / 跑步 / 骑行速度,顺序与 {@link ScriptWaypoint.Mode} 一致 */ + private double[] getModeSpeeds() { + return ScriptParser.modeSpeeds( + getDoubleSetting("setting_walk", 1.2), + getDoubleSetting("setting_run", 3.6), + getDoubleSetting("setting_bike", 10.0)); + } + + /** 读取浮点型设置项,非法输入时退回默认值 */ + private double getDoubleSetting(String key, double defaultValue) { + try { + return Double.parseDouble(sharedPreferences.getString(key, Double.toString(defaultValue))); + } catch (Exception e) { + XLog.e("ERROR: invalid setting - " + key); + return defaultValue; + } + } + + /** 读取设置里的海拔高度,非法输入时退回默认值,避免 NumberFormatException 闪退 */ + private double getSettingAltitude() { + try { + return Double.parseDouble(sharedPreferences.getString("setting_altitude", "55.0")); + } catch (Exception e) { + XLog.e("ERROR: setting_altitude is not a number"); + return 55.0; + } + } + + private void bindServiceIfNeeded() { + if (mBoundToService) { + return; + } + + try { + if (bindService(new Intent(MainActivity.this, ServiceGo.class), mConnection, BIND_AUTO_CREATE)) { + mBoundToService = true; + } + } catch (Exception e) { + // 绑定失败不能影响“开始移动”:启动走的是 startForegroundService,与绑定无关 + mBoundToService = false; + XLog.e("ERROR: bindService", e); + } + } + + /** 统一解绑,避免退出时因为“服务不是本界面启动的”而漏掉解绑 */ + private void unbindServiceIfNeeded() { + // 解绑后不会再有连接回调,挂起的开始 / 停止请求无处落地,先作废 + mPendingScriptId = null; + mPendingStop = false; + mPendingPauseRequest = null; + mPendingClickPauseRequest = null; + + if (!mBoundToService && !isMockServStart) { + return; + } + + try { + if (mServiceBinder != null) { + mServiceBinder.clearScriptListener(); + mServiceBinder.clearClickMoveListener(); + } + unbindService(mConnection); + } catch (Exception e) { + XLog.e("ERROR: unbindService"); + } + mBoundToService = false; + mServiceBinder = null; + // 解绑后收不到“到达 / 走完”回调,界面按“没在走”处理,避免按钮一直停在“停止行走” + mClickMoveRunning = false; + } + + private void startGoLocation() { + if (mMarkLatLngMap == null) { + GoUtils.DisplayToast(this, getResources().getString(R.string.app_error_location)); + return; + } + + Intent serviceGoIntent = new Intent(MainActivity.this, ServiceGo.class); + double[] latLng = MapUtils.bd2wgs(mMarkLatLngMap.getLongitude(), mMarkLatLngMap.getLatitude()); + serviceGoIntent.putExtra(LNG_MSG_ID, latLng[0]); + serviceGoIntent.putExtra(LAT_MSG_ID, latLng[1]); + serviceGoIntent.putExtra(ALT_MSG_ID, getSettingAltitude()); + + try { + if (!mBoundToService) { + // 已经绑定过就不要再 bind:同一个 ServiceConnection 重复绑定会直接抛异常, + // 把后面的 startForegroundService 一并跳过(表现为点传送 / 开始没反应) + bindService(serviceGoIntent, mConnection, BIND_AUTO_CREATE); // 绑定服务和活动,之后活动就可以去调服务的方法了 + mBoundToService = true; + } + startForegroundService(serviceGoIntent); + XLog.d("startForegroundService: ServiceGo"); + + isMockServStart = true; + refreshClickMoveSwitch(); + } catch (Exception e) { + XLog.e("ERROR: startGoLocation", e); + GoUtils.DisplayToast(this, getResources().getString(R.string.app_error_service)); + } + } + + private void stopGoLocation() { + unbindServiceIfNeeded(); + Intent serviceGoIntent = new Intent(MainActivity.this, ServiceGo.class); + stopService(serviceGoIntent); + isMockServStart = false; + refreshClickMoveSwitch(); + } + + /*============================== 点击移动 ⇄ 摇杆移动 开关 ==============================*/ + + /** 初始化左下角开关:默认状态由“移动类型”设置决定(选“点击移动”时默认开) */ + private void initClickMoveSwitch() { + mClickMoveSwitch = findViewById(R.id.click_move_switch); + if (mClickMoveSwitch == null) { + return; + } + + mClickMoveEnabled = "2".equals(sharedPreferences.getString("setting_joystick_type", "0")); + mClickMoveSwitch.setText(mClickMoveEnabled + ? R.string.move_mode_click : R.string.move_mode_joystick); + mClickMoveSwitch.setOnClickListener(v -> { + mClickMoveEnabled = !mClickMoveEnabled; + // 内部会推给服务端并刷新“目标点操作条”(切到点击移动才显示,切回摇杆就收起) + applyClickMoveMode(); + if (mServiceBinder == null) { + // 状态要推给服务端;连接回来时 onServiceConnected 还会再补推一次, + // 否则切换只生效在本地,点地图会一直被服务端拒绝 + bindServiceIfNeeded(); + } + }); + + refreshClickMoveSwitch(); + } + + /** 把开关状态推给服务端(没连上时连接回调会再推一次)并刷新按钮文案 */ + private void applyClickMoveMode() { + if (mClickMoveSwitch != null) { + mClickMoveSwitch.setText(mClickMoveEnabled + ? R.string.move_mode_click : R.string.move_mode_joystick); + } + + if (mServiceBinder != null) { + try { + mServiceBinder.setClickMoveMode(mClickMoveEnabled); + } catch (Exception e) { + XLog.e("ERROR: setClickMoveMode", e); + } + } + + // 服务端切换模式会作废目标并复位“暂停中”,本地跟着来 + mClickMovePaused = false; + + // 开关状态决定“目标点操作条”是否出现(切到点击移动才显示,切回摇杆就收起) + refreshClickMoveSwitch(); + updateClickMoveUi(); + } + + /** 开关只在模拟位置(单点 / 脚本)运行期间显示,结束后消失 */ + private void refreshClickMoveSwitch() { + if (mClickMoveSwitch == null) { + return; + } + + // 服务连着 = 还在注入位置(脚本跑完后位置冻结但服务仍在),点击移动仍可用 + boolean running = isMockServStart || mRunningScriptId != null || mServiceBinder != null; + mClickMoveSwitch.setVisibility(running ? View.VISIBLE : View.GONE); + + // 点击移动开启时显示目标点操作条(撤回 / 清空 / 开始行走),与路线选点一致 + if (mClickMoveBar != null) { + boolean showBar = running && mClickMoveEnabled && !mRoutePicking; + mClickMoveBar.setVisibility(showBar ? View.VISIBLE : View.GONE); + } + } + + /** 初始化点击移动的目标点操作条(点击地图连续添加目标时的撤回 / 清空 / 开始行走) */ + private void initClickMoveBar() { + mClickMoveBar = findViewById(R.id.click_move_bar); + mClickMoveHint = findViewById(R.id.click_move_hint); + mClickMoveGoButton = findViewById(R.id.click_move_go); + mClickMovePauseButton = findViewById(R.id.click_move_pause); + if (mClickMovePauseButton != null) { + mClickMovePauseButton.setOnClickListener(v -> toggleClickMovePause()); + } + + View undo = findViewById(R.id.click_move_undo); + if (undo != null) { + undo.setOnClickListener(v -> undoClickMovePoint()); + } + View clear = findViewById(R.id.click_move_clear); + if (clear != null) { + clear.setOnClickListener(v -> clearClickMovePoints()); + } + if (mClickMoveGoButton != null) { + mClickMoveGoButton.setOnClickListener(v -> { + if (mClickMoveRunning) { + // 停止行走:让服务端放弃当前这一段。目标点仍留在队列里, + // 之后点“开始行走”会从队头接着走 + cancelServiceClickTarget(); + updateClickMoveUi(); + GoUtils.DisplayToast(this, getResources().getString(R.string.click_move_stop)); + } else if (mClickMovePoints.isEmpty()) { + GoUtils.DisplayToast(this, getResources().getString(R.string.click_move_empty)); + } else { + // 重新开始行走:从第一个还没走到的目标接着走 + dispatchNextClickTarget(); + } + }); + } + + // 轨迹连线图层:与脚本路线的连线画法一致(虚线区分“点击移动的临时目标”), + // 两条线各自持有图层对象,避免互相覆盖 + if (mMapView != null) { + mClickMoveLine = new Polyline(mMapView); + mClickMoveLine.getOutlinePaint().setColor(ContextCompat.getColor(this, R.color.colorAccent)); + mClickMoveLine.getOutlinePaint().setStrokeWidth(6.0f); + mClickMoveLine.getOutlinePaint().setPathEffect( + new DashPathEffect(new float[] {18.0f, 12.0f}, 0.0f)); + addRouteOverlay(mClickMoveLine); + } + + updateClickMoveUi(); + } + + /** + * 主界面地图单击的“点击移动”处理。 + * + *

每点一次追加一个目标点:地图上立刻出现图钉与连线, + * 服务端按队列逐个走过去(走完一个自动接下一个)。

+ * + * @return true 表示本次点击已按点击移动处理(不再走原来的选中标记逻辑) + */ + private boolean handleClickMoveTap(GeoPoint p) { + if (mClickMoveSwitch == null || mClickMoveSwitch.getVisibility() != View.VISIBLE + || !mClickMoveEnabled) { + return false; + } + + if (isScriptPlaying()) { + GoUtils.DisplayToast(this, getResources().getString(R.string.click_move_script_busy)); + return true; + } + + if (mServiceBinder == null) { + // 服务连接还没回来:补绑一次,连上后 onServiceConnected 会把开关状态推过去 + bindServiceIfNeeded(); + GoUtils.DisplayToast(this, getResources().getString(R.string.app_error_service)); + return true; + } + + // 点地图 = 往目标队列末尾追加一个点;还没开始走时,第一个点会立刻开走 + addClickMovePoint(p.getLongitude(), p.getLatitude()); + return true; + } + + /*============================== 点击移动:目标队列 / 轨迹 ==============================*/ + + /** 追加一个点击移动目标点,并在地图上画出图钉与连线 */ + private void addClickMovePoint(double lngWgs84, double latWgs84) { + mClickMovePoints.add(new ScriptWaypoint(lngWgs84, latWgs84, getSettingAltitude(), + mRouteMode, getModeSpeed(mRouteMode), 0)); + redrawClickMove(); + + // 还没开始走:立刻把队首目标下发,服务端随即起步 + if (!mClickMoveRunning) { + dispatchNextClickTarget(); + } + } + + /** + * 把队头目标下发给服务端。 + * + *

队列里只存待走目标,所以队头({@code get(0)})就是下一个要走的点: + * 走到后由 {@link #onServiceClickArrived} 从队头删掉,再回到这里发下一个。 + * 这样即使用户在行走途中继续点地图,也只是往队尾追加,不会打断当前这一段。

+ */ + private void dispatchNextClickTarget() { + if (mServiceBinder == null) { + return; + } + + if (mClickMovePoints.isEmpty()) { + // 没有下一个目标了:行进结束 + mClickMoveRunning = false; + updateClickMoveUi(); + return; + } + + ScriptWaypoint target = mClickMovePoints.get(0); + try { + if (mServiceBinder.appendClickTarget(target.lng, target.lat)) { + mClickMoveRunning = true; + // 暂停状态以服务端为准:到站接下一个目标的瞬间可能与“暂停”发生竞态 + mClickMovePaused = mServiceBinder.isClickMovePaused(); + } else { + // 服务端拒绝(脚本抢走了位置或开关没开):不要卡在“假运行”状态 + mClickMoveRunning = false; + } + } catch (Exception e) { + XLog.e("ERROR: appendClickTarget", e); + mClickMoveRunning = false; + } + + updateClickMoveUi(); + } + + /** 服务端到达队头目标:把它删掉(走过的不再显示),再接着走下一个(没有就收工) */ + private void onServiceClickArrived() { + runOnUiThread(() -> { + if (!mClickMovePoints.isEmpty()) { + mClickMovePoints.remove(0); + } + + if (mClickMovePoints.isEmpty()) { + // 走完了:队列空了,行进结束。先改状态再重画,按钮才能正确显示“开始行走” + mClickMoveRunning = false; + // 服务端到达目标时会把“暂停中”一并复位(没有目标可暂停),本地跟着回读 + mClickMovePaused = false; + redrawClickMove(); + GoUtils.DisplayToast(MainActivity.this, + getResources().getString(R.string.click_move_finished)); + return; + } + + // 还有下一个:先把已走过的点从地图上摘掉,再把新的队头发下去 + redrawClickMove(); + dispatchNextClickTarget(); + }); + } + + /** + * 让服务端放弃当前目标,并把本地运行标记复位。 + * + *

撤回、清空、停止行走三处都要做同一件事,统一走这里, + * 免得哪处漏了 {@code clearClickTargets()},位置就继续朝着已删除的点走。

+ */ + private void cancelServiceClickTarget() { + mClickMoveRunning = false; + // 服务端 clearClickTargets 会连“暂停中”一起复位,本地照着来 + mClickMovePaused = false; + + if (mServiceBinder != null) { + try { + mServiceBinder.clearClickTargets(); + } catch (Exception e) { + XLog.e("ERROR: clearClickTargets", e); + } + } + } + + /** 撤回最后一个还没走到的目标(走过的点已随到达被删掉,这里只处理待走队列) */ + private void undoClickMovePoint() { + if (mClickMovePoints.isEmpty()) { + GoUtils.DisplayToast(this, getResources().getString(R.string.click_move_empty)); + return; + } + + mClickMovePoints.remove(mClickMovePoints.size() - 1); + + // 只有“队里就剩这一个”时,删的才是服务端正在走向的队头, + // 此时必须连服务端的目标一起撤掉,否则位置会继续朝着已删除的点走 + if (mClickMovePoints.isEmpty() && mClickMoveRunning) { + cancelServiceClickTarget(); + } + + redrawClickMove(); + // 显式刷新:redrawPoints 在地图已销毁时会提前返回,不能指望它替我们刷新按钮 + updateClickMoveUi(); + } + + /** 清空目标点与轨迹(正在走向的目标也一并取消) */ + private void clearClickMovePoints() { + mClickMovePoints.clear(); + cancelServiceClickTarget(); + redrawClickMove(); + updateClickMoveUi(); + } + + /** 重画点击移动的图钉与连线(虚线,和脚本路线的实线区分开) */ + private void redrawClickMove() { + redrawPoints(mClickMovePoints, mClickMoveMarkers, mClickMoveLine, + R.string.click_move_point_title, this::updateClickMoveUi); + } + + /** + * 点击行走的预计里程:从当前位置按队列顺序走完全部待走目标的路径长度。 + * + *

只在操作条刷新时重算(加点 / 撤回 / 清空 / 到站 / 暂停切换),作为“预计”值展示, + * 不追求逐帧精确。当前位置取服务端注入端的坐标(人物此刻真正所在的位置); + * 服务没连上时退化成只累计目标之间的路径,缺“当前位置 → 第一个目标”这一截。

+ */ + private double clickMoveEstimatedDistance() { + if (mClickMovePoints.isEmpty()) { + return 0; + } + + double lng; + double lat; + double[] position = null; + if (mServiceBinder != null) { + try { + position = mServiceBinder.getCurrentPosition(); + } catch (Exception e) { + XLog.e("ERROR: getCurrentPosition", e); + } + } + + if (position != null && position.length >= 2) { + lng = position[0]; + lat = position[1]; + } else { + // 服务没连上:从队头目标起算 + ScriptWaypoint first = mClickMovePoints.get(0); + lng = first.lng; + lat = first.lat; + } + + double total = 0; + for (ScriptWaypoint point : mClickMovePoints) { + total += ScriptPlayer.distanceMeters(lng, lat, point.lng, point.lat); + lng = point.lng; + lat = point.lat; + } + return total; + } + + /** + * 刷新点击移动相关的按钮与提示文案。 + * + *

点击移动模式下点地图连续添加目标,这条操作条负责撤回 / 清空 / 开始行走, + * 与脚本路线的选点操作方式保持一致;提示行同时给出剩余目标数与预计里程。

+ */ + private void updateClickMoveUi() { + if (mClickMoveHint != null) { + int count = mClickMovePoints.size(); + if (count == 0) { + mClickMoveHint.setText(getResources().getString(R.string.click_move_hint_empty)); + } else { + // 预计里程:当前位置 → 队列里每个目标依次相连的路径长度 + String distance = ScriptParser.formatDistance(clickMoveEstimatedDistance()); + // 暂停中把状态写进提示行:位置停在原地,别让用户以为还在走 + if (mClickMovePaused) { + mClickMoveHint.setText(getResources().getString( + R.string.click_move_hint_paused, count, distance)); + } else { + mClickMoveHint.setText(getResources().getString( + R.string.click_move_hint_count, count, distance)); + } + } + } + + if (mClickMoveGoButton != null) { + // 同 updateRouteUi:图标与文案一起在“开始 / 停止”之间切换 + boolean running = mClickMoveRunning; + mClickMoveGoButton.setText(running + ? R.string.click_move_stop : R.string.click_move_go); + mClickMoveGoButton.setIconResource(running + ? R.drawable.ic_close : R.drawable.ic_move); + } + + if (mClickMovePauseButton != null) { + // 暂停按钮只在真的在行走时出现;暂停中换成“继续”(图标 暂停 ⇄ 播放) + boolean walking = mClickMoveRunning; + mClickMovePauseButton.setVisibility(walking ? View.VISIBLE : View.GONE); + if (walking) { + mClickMovePauseButton.setText(mClickMovePaused + ? R.string.click_move_resume : R.string.click_move_pause); + mClickMovePauseButton.setIconResource(mClickMovePaused + ? R.drawable.ic_play : R.drawable.ic_pause); + } + } + } + + /** + * 点击移动操作条上的“暂停 / 继续”切换。 + * + *

暂停 = 位置停在原地、当前目标与朝向保留;继续 = 从暂停处朝同一目标接着走 + * (与“停止行走”放弃当前目标不同)。服务还没绑上时先记住请求, + * 连上后在 {@link #syncStateWithService()} 里补执行,避免“点了一下没反应”。

+ */ + private void toggleClickMovePause() { + if (mServiceBinder == null) { + mPendingClickPauseRequest = !mClickMovePaused; + bindServiceIfNeeded(); + return; + } + + try { + if (mClickMovePaused) { + boolean ok = mServiceBinder.resumeClickMove(); + GoUtils.DisplayToast(this, getResources().getString( + ok ? R.string.click_move_resumed_toast : R.string.click_move_resume_fail)); + } else { + boolean ok = mServiceBinder.pauseClickMove(); + GoUtils.DisplayToast(this, getResources().getString( + ok ? R.string.click_move_paused_toast : R.string.click_move_pause_fail)); + } + // 以服务端为准回读(请求可能因为“没在朝目标走”等原因没生效) + mClickMovePaused = mServiceBinder.isClickMovePaused(); + updateClickMoveUi(); + } catch (Exception e) { + XLog.e("ERROR: toggleClickMovePause", e); + } + } + + private void doGoLocation(View v) { + if (!GoUtils.isNetworkAvailable(this)) { + GoUtils.DisplayToast(this, getResources().getString(R.string.app_error_network)); + return; + } + + if (!GoUtils.isGpsOpened(this)) { + GoUtils.showEnableGpsDialog(this); + return; + } + + if (!Settings.canDrawOverlays(getApplicationContext())) {//悬浮窗权限判断 + GoUtils.showEnableFloatWindowDialog(this); + XLog.e("无悬浮窗权限!"); + return; + } + + // 脚本正在播放:传送 / 终止模拟都会打断路线移动。以前这里是“静默打断”, + // 播放中误碰一下按钮,位置就跳回标记点、脚本也被顺手停掉。 + // 现在先弹确认:取消则什么都不动,脚本继续走。 + if (isScriptPlaying()) { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.script_stop_confirm_title) + .setMessage(R.string.script_stop_confirm_message) + .setPositiveButton(R.string.script_stop_confirm_ok, + (dialog, which) -> doGoLocationAction(v)) + .setNegativeButton(R.string.input_position_cancel, null) + .show(); + return; + } + + doGoLocationAction(v); + } + + /** 脚本是否正在播放或暂停中(服务没绑定时以持久状态为准,跑完时服务端会把它清掉) */ + private boolean isScriptPlaying() { + if (mServiceBinder != null) { + int state = mServiceBinder.getScriptState(); + // 暂停也算“脚本占着位置”:传送 / 终止同样会打断它,先弹确认 + return state == ServiceGo.SCRIPT_STATE_PLAYING + || state == ServiceGo.SCRIPT_STATE_PAUSED; + } + return new ScriptStore(this).getRunningScriptId() != null; + } + + /** 真正执行“传送 / 终止模拟”(doGoLocation 前置检查与脚本确认之后的动作) */ + private void doGoLocationAction(View v) { if (isMockServStart) { if (mMarkLatLngMap == null) { stopGoLocation(); @@ -833,15 +2473,22 @@ private void doGoLocation(View v) { .setAction("Action", null).show(); mButtonStart.setImageResource(R.drawable.ic_position); } else { - double[] latLng = MapUtils.bd2wgs(mMarkLatLngMap.longitude, mMarkLatLngMap.latitude); - double alt = Double.parseDouble(sharedPreferences.getString("setting_altitude", "55.0")); + if (mServiceBinder == null) { + // 服务还没连接上(例如进程刚被系统重启),重新绑定,避免空指针闪退 + bindServiceIfNeeded(); + GoUtils.DisplayToast(this, getResources().getString(R.string.app_error_service)); + return; + } + + double[] latLng = MapUtils.bd2wgs(mMarkLatLngMap.getLongitude(), mMarkLatLngMap.getLatitude()); + double alt = getSettingAltitude(); mServiceBinder.setPosition(latLng[0], latLng[1], alt); Snackbar.make(v, "已传送到新位置", Snackbar.LENGTH_LONG) .setAction("Action", null).show(); - recordCurrentLocation(mMarkLatLngMap.longitude, mMarkLatLngMap.latitude); + recordCurrentLocation(mMarkLatLngMap.getLongitude(), mMarkLatLngMap.getLatitude()); - mBaiduMap.clear(); + clearMark(mMapView, mMarkMarker); mMarkLatLngMap = null; if (GoUtils.isWifiEnabled(MainActivity.this)) { @@ -862,8 +2509,8 @@ private void doGoLocation(View v) { Snackbar.make(v, "模拟位置已启动", Snackbar.LENGTH_LONG) .setAction("Action", null).show(); - recordCurrentLocation(mMarkLatLngMap.longitude, mMarkLatLngMap.latitude); - mBaiduMap.clear(); + recordCurrentLocation(mMarkLatLngMap.getLongitude(), mMarkLatLngMap.getLatitude()); + clearMark(mMapView, mMarkMarker); mMarkLatLngMap = null; if (GoUtils.isWifiEnabled(MainActivity.this)) { @@ -918,22 +2565,19 @@ private List> getSearchHistory() { // 记录请求的位置信息 private void recordCurrentLocation(double lng, double lat) { //参数坐标系:bd09 - final String safeCode = BuildConfig.MAPS_SAFE_CODE; - final String ak = sharedPreferences.getString("setting_map_key", BuildConfig.MAPS_API_KEY); - double[] latLng = MapUtils.bd2wgs(lng, lat); - //bd09坐标的位置信息 - String mapApiUrl = "https://api.map.baidu.com/reverse_geocoding/v3/?ak=" + ak + "&output=json&coordtype=bd09ll" + "&location=" + lat + "," + lng + "&mcode=" + safeCode; - - okhttp3.Request request = new okhttp3.Request.Builder().url(mapApiUrl).get().build(); - final Call call = mOkHttpClient.newCall(request); - call.enqueue(new Callback() { - @Override - public void onFailure(@NonNull Call call, @NonNull IOException e) { - //http 请求失败 - XLog.e("HTTP: HTTP GET FAILED"); + final double[] latLng = MapUtils.bd2wgs(lng, lat); + // 逆地理编码改为 OpenStreetMap Nominatim,传入 WGS-84 坐标 + mGeoCoder.reverse(latLng[1], latLng[0], poi -> { + try { + String address = (poi == null || poi.address.isEmpty()) ? null : poi.address; + if (address == null) { + address = mMarkName != null ? mMarkName + : getResources().getString(R.string.history_location_default_name); + } + //插表参数 ContentValues contentValues = new ContentValues(); - contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LOCATION, mMarkName); + contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LOCATION, address); contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LONGITUDE_WGS84, String.valueOf(latLng[0])); contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LATITUDE_WGS84, String.valueOf(latLng[1])); contentValues.put(DataBaseHistoryLocation.DB_COLUMN_TIMESTAMP, System.currentTimeMillis() / 1000); @@ -941,50 +2585,8 @@ public void onFailure(@NonNull Call call, @NonNull IOException e) { contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LATITUDE_CUSTOM, Double.toString(lat)); DataBaseHistoryLocation.saveHistoryLocation(mLocationHistoryDB, contentValues); - } - - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException { - ResponseBody responseBody = response.body(); - if (responseBody != null) { - String resp = responseBody.string(); - try { - JSONObject getRetJson = new JSONObject(resp); - - if (Integer.parseInt(getRetJson.getString("status")) == 0) { // 位置获取成功 - JSONObject posInfoJson = getRetJson.getJSONObject("result"); - String formatted_address = posInfoJson.getString("formatted_address"); - ContentValues contentValues = new ContentValues(); - contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LOCATION, formatted_address); - contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LONGITUDE_WGS84, String.valueOf(latLng[0])); - contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LATITUDE_WGS84, String.valueOf(latLng[1])); - contentValues.put(DataBaseHistoryLocation.DB_COLUMN_TIMESTAMP, System.currentTimeMillis() / 1000); - contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LONGITUDE_CUSTOM, Double.toString(lng)); - contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LATITUDE_CUSTOM, Double.toString(lat)); - DataBaseHistoryLocation.saveHistoryLocation(mLocationHistoryDB, contentValues); - } else { - ContentValues contentValues = new ContentValues(); - contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LOCATION, mMarkName == null ? getRetJson.getString("message"): mMarkName); - contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LONGITUDE_WGS84, String.valueOf(latLng[0])); - contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LATITUDE_WGS84, String.valueOf(latLng[1])); - contentValues.put(DataBaseHistoryLocation.DB_COLUMN_TIMESTAMP, System.currentTimeMillis() / 1000); - contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LONGITUDE_CUSTOM, Double.toString(lng)); - contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LATITUDE_CUSTOM, Double.toString(lat)); - DataBaseHistoryLocation.saveHistoryLocation(mLocationHistoryDB, contentValues); - } - } catch (JSONException e) { - XLog.e("JSON: resolve json error"); - ContentValues contentValues = new ContentValues(); - contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LOCATION, mMarkName == null ? getResources().getString(R.string.history_location_default_name) : mMarkName); - contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LOCATION, mMarkName); - contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LONGITUDE_WGS84, String.valueOf(latLng[0])); - contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LATITUDE_WGS84, String.valueOf(latLng[1])); - contentValues.put(DataBaseHistoryLocation.DB_COLUMN_TIMESTAMP, System.currentTimeMillis() / 1000); - contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LONGITUDE_CUSTOM, Double.toString(lng)); - contentValues.put(DataBaseHistoryLocation.DB_COLUMN_LATITUDE_CUSTOM, Double.toString(lat)); - DataBaseHistoryLocation.saveHistoryLocation(mLocationHistoryDB, contentValues); - } - } + } catch (Exception e) { + XLog.e("ERROR: recordCurrentLocation"); } }); } @@ -999,13 +2601,13 @@ private void initSearchView() { String lng = ((TextView) view.findViewById(R.id.poi_longitude)).getText().toString(); String lat = ((TextView) view.findViewById(R.id.poi_latitude)).getText().toString(); mMarkName = ((TextView) view.findViewById(R.id.poi_name)).getText().toString(); - mMarkLatLngMap = new LatLng(Double.parseDouble(lat), Double.parseDouble(lng)); - MapStatusUpdate mapstatusupdate = MapStatusUpdateFactory.newLatLng(mMarkLatLngMap); - mBaiduMap.setMapStatus(mapstatusupdate); + mMarkLatLngMap = new GeoPoint(Double.parseDouble(lat), Double.parseDouble(lng)); + mMapView.getController().setCenter( + bd09ToWgs84(mMarkLatLngMap.getLongitude(), mMarkLatLngMap.getLatitude())); markMap(); - double[] latLng = MapUtils.bd2wgs(mMarkLatLngMap.longitude, mMarkLatLngMap.latitude); + double[] latLng = MapUtils.bd2wgs(mMarkLatLngMap.getLongitude(), mMarkLatLngMap.getLatitude()); // mSearchList.setVisibility(View.GONE); //搜索历史 插表参数 @@ -1035,13 +2637,13 @@ private void initSearchView() { String lng = ((TextView) view.findViewById(R.id.search_longitude)).getText().toString(); String lat = ((TextView) view.findViewById(R.id.search_latitude)).getText().toString(); // mMarkName = ((TextView) view.findViewById(R.id.poi_name)).getText().toString(); - mMarkLatLngMap = new LatLng(Double.parseDouble(lat), Double.parseDouble(lng)); - MapStatusUpdate mapstatusupdate = MapStatusUpdateFactory.newLatLng(mMarkLatLngMap); - mBaiduMap.setMapStatus(mapstatusupdate); + mMarkLatLngMap = new GeoPoint(Double.parseDouble(lat), Double.parseDouble(lng)); + mMapView.getController().setCenter( + bd09ToWgs84(mMarkLatLngMap.getLongitude(), mMarkLatLngMap.getLatitude())); markMap(); - double[] latLng = MapUtils.bd2wgs(mMarkLatLngMap.longitude, mMarkLatLngMap.latitude); + double[] latLng = MapUtils.bd2wgs(mMarkLatLngMap.getLongitude(), mMarkLatLngMap.getLatitude()); //设置列表不可见 mHistoryLayout.setVisibility(View.INVISIBLE); @@ -1070,7 +2672,7 @@ private void initSearchView() { } }); mSearchHistoryList.setOnItemLongClickListener((parent, view, position, id) -> { - new AlertDialog.Builder(MainActivity.this) + new MaterialAlertDialogBuilder(MainActivity.this) .setTitle("警告")//这里是表头的内容 .setMessage("确定要删除该项搜索记录吗?")//这里是中间显示的具体信息 .setPositiveButton("确定",(dialog, which) -> { @@ -1108,177 +2710,46 @@ private void initSearchView() { .show(); return true; }); - //设置搜索建议返回值监听 - mSuggestionSearch = SuggestionSearch.newInstance(); - mSuggestionSearch.setOnGetSuggestionResultListener(suggestionResult -> { - if (suggestionResult == null || suggestionResult.getAllSuggestions() == null) { - GoUtils.DisplayToast(this,getResources().getString(R.string.app_search_null)); - } else { - List> data = getMapList(suggestionResult); - - SimpleAdapter simAdapt = new SimpleAdapter( - MainActivity.this, - data, - R.layout.search_poi_item, - new String[] {POI_NAME, POI_ADDRESS, POI_LONGITUDE, POI_LATITUDE}, // 与下面数组元素要一一对应 - new int[] {R.id.poi_name, R.id.poi_address, R.id.poi_longitude, R.id.poi_latitude}); - mSearchList.setAdapter(simAdapt); - // mSearchList.setVisibility(View.VISIBLE); - mSearchLayout.setVisibility(View.VISIBLE); + } + + /** + * 关键字检索(OpenStreetMap Nominatim)。 + * 结果统一转换成 BD-09 后再交给列表,列表点击选点的下游逻辑因此完全不用改。 + */ + private void doSearch(String keyword) { + mGeoCoder.search(keyword, pois -> { + if (pois == null || pois.isEmpty()) { + GoUtils.DisplayToast(this, getResources().getString(R.string.app_search_null)); + return; } + + SimpleAdapter simAdapt = new SimpleAdapter( + MainActivity.this, + getMapList(pois), + R.layout.search_poi_item, + new String[] {POI_NAME, POI_ADDRESS, POI_LONGITUDE, POI_LATITUDE}, // 与下面数组元素要一一对应 + new int[] {R.id.poi_name, R.id.poi_address, R.id.poi_longitude, R.id.poi_latitude}); + mSearchList.setAdapter(simAdapt); + mSearchLayout.setVisibility(View.VISIBLE); }); } @NonNull - private static List> getMapList(SuggestionResult suggestionResult) { + private static List> getMapList(List pois) { List> data = new ArrayList<>(); - int retCnt = suggestionResult.getAllSuggestions().size(); - for (int i = 0; i < retCnt; i++) { - if (suggestionResult.getAllSuggestions().get(i).pt == null) { - continue; - } + for (OsmGeocoder.Poi poi : pois) { + // 列表里的经纬度沿用原有 BD-09 语义 + double[] bd09 = MapUtils.wgs2bd09(poi.longitude, poi.latitude); Map poiItem = new HashMap<>(); - poiItem.put(POI_NAME, suggestionResult.getAllSuggestions().get(i).key); - poiItem.put(POI_ADDRESS, suggestionResult.getAllSuggestions().get(i).city + " " + suggestionResult.getAllSuggestions().get(i).district); - poiItem.put(POI_LONGITUDE, "" + suggestionResult.getAllSuggestions().get(i).pt.longitude); - poiItem.put(POI_LATITUDE, "" + suggestionResult.getAllSuggestions().get(i).pt.latitude); + poiItem.put(POI_NAME, poi.name); + poiItem.put(POI_ADDRESS, poi.address); + poiItem.put(POI_LONGITUDE, "" + bd09[0]); + poiItem.put(POI_LATITUDE, "" + bd09[1]); data.add(poiItem); } return data; } - /*============================== 更新 相关 ==============================*/ - private void initUpdateVersion() { - mDownloadManager =(DownloadManager) MainActivity.this.getSystemService(DOWNLOAD_SERVICE); - - // 用于监听下载完成后,转到安装界面 - mDownloadBdRcv = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - installNewVersion(); - } - }; - registerReceiver(mDownloadBdRcv, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)); - } - - private void checkUpdateVersion(boolean result) { - String mapApiUrl = "https://api.github.com/repos/zcshou/gogogo/releases/latest"; - - okhttp3.Request request = new okhttp3.Request.Builder().url(mapApiUrl).get().build(); - final Call call = mOkHttpClient.newCall(request); - call.enqueue(new Callback() { - @Override - public void onFailure(@NonNull Call call, @NonNull IOException e) { - XLog.i("更新检测失败"); - } - - @Override - public void onResponse(@NonNull Call call, @NonNull okhttp3.Response response) throws IOException { - ResponseBody responseBody = response.body(); - if (responseBody != null) { - String resp = responseBody.string(); - // 注意,该请求在子线程,不能直接操作界面 - runOnUiThread(() -> { - try { - JSONObject getRetJson = new JSONObject(resp); - String curVersion = GoUtils.getVersionName(MainActivity.this); - - if (curVersion != null - && (!getRetJson.getString("name").contains(curVersion) - || !getRetJson.getString("tag_name").contains(curVersion))) { - final android.app.AlertDialog alertDialog = new android.app.AlertDialog.Builder(MainActivity.this).create(); - alertDialog.show(); - alertDialog.setCancelable(false); - Window window = alertDialog.getWindow(); - if (window != null) { - window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); // 防止出现闪屏 - window.setContentView(R.layout.update); - window.setGravity(Gravity.CENTER); - window.setWindowAnimations(R.style.DialogAnimFadeInFadeOut); - - TextView updateTitle = window.findViewById(R.id.update_title); - updateTitle.setText(getRetJson.getString("name")); - TextView updateTime = window.findViewById(R.id.update_time); - updateTime.setText(getRetJson.getString("created_at")); - TextView updateCommit = window.findViewById(R.id.update_commit); - updateCommit.setText(getRetJson.getString("target_commitish")); - - TextView updateContent = window.findViewById(R.id.update_content); - final Markwon markwon = Markwon.create(MainActivity.this); - markwon.setMarkdown(updateContent, getRetJson.getString("body")); - - Button updateCancel = window.findViewById(R.id.update_ignore); - updateCancel.setOnClickListener(v -> alertDialog.cancel()); - - /* 这里用来保存下载地址 */ - JSONArray jsonArray = new JSONArray(getRetJson.getString("assets")); - JSONObject jsonObject = jsonArray.getJSONObject(0); - String download_url = jsonObject.getString("browser_download_url"); - mUpdateFilename = jsonObject.getString("name"); - - Button updateAgree = window.findViewById(R.id.update_agree); - updateAgree.setOnClickListener(v -> { - alertDialog.cancel(); - GoUtils.DisplayToast(MainActivity.this, getResources().getString(R.string.update_downloading)); - downloadNewVersion(download_url); - }); - } - } else { - if (result) { - GoUtils.DisplayToast(MainActivity.this, getResources().getString(R.string.update_last)); - } - } - } catch (JSONException e) { - XLog.e("ERROR: resolve json"); - } - }); - } - } - }); - } - - private void downloadNewVersion(String url) { - if (mDownloadManager == null) { - return; - } - - DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url)); - request.setAllowedOverRoaming(false); - request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED); - request.setTitle(GoUtils.getAppName(this)); - request.setDescription("正在下载新版本..."); - request.setMimeType("application/vnd.android.package-archive"); - - // DownloadManager不会覆盖已有的同名文件,需要自己来删除已存在的文件 - File file = new File(getExternalFilesDir("Updates"), mUpdateFilename); - if (file.exists()) { - if(!file.delete()) { - return; - } - } - request.setDestinationUri(Uri.fromFile(file)); - - mDownloadId = mDownloadManager.enqueue(request); - } - - private void installNewVersion() { - Intent install = new Intent(Intent.ACTION_VIEW); - Uri downloadFileUri = mDownloadManager.getUriForDownloadedFile(mDownloadId); - File file = new File(getExternalFilesDir("Updates"), mUpdateFilename); - if (downloadFileUri != null) { - install.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - // 在Broadcast中启动活动需要添加Intent.FLAG_ACTIVITY_NEW_TASK - install.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - install.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); //添加这一句表示对目标应用临时授权该Uri所代表的文件 - install.addCategory("android.intent.category.DEFAULT"); - install.setDataAndType(ShareUtils.getUriFromFile(MainActivity.this, file), "application/vnd.android.package-archive"); - startActivity(install); - } else { - Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName())); - intent.addCategory("android.intent.category.DEFAULT"); - startActivity(intent); - } - } } diff --git a/app/src/main/java/com/zcshou/gogogo/ScriptActivity.java b/app/src/main/java/com/zcshou/gogogo/ScriptActivity.java new file mode 100644 index 00000000..44aac659 --- /dev/null +++ b/app/src/main/java/com/zcshou/gogogo/ScriptActivity.java @@ -0,0 +1,883 @@ +package com.zcshou.gogogo; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.net.Uri; +import android.os.Bundle; +import android.os.IBinder; +import android.provider.Settings; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.BaseAdapter; +import android.widget.CheckBox; +import android.widget.ImageButton; +import android.widget.ListView; +import android.widget.Spinner; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.zcshou.script.ScriptParser; +import com.zcshou.script.ScriptRoute; +import com.zcshou.script.ScriptStore; +import com.zcshou.script.ScriptWaypoint; +import com.zcshou.service.ServiceGo; +import com.zcshou.utils.GoUtils; +import com.zcshou.utils.ShareUtils; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 脚本模式:管理脚本并启动“按脚本预设自动移动”的模拟位置。 + * + *

列表里点 ▶ 开始播放(播放后自动退回地图),正在播放的那条按钮变成暂停 / 继续, + * 播放状态条上也有同样的切换按钮;点条目或铅笔进入编辑页; + * 删除走工具栏按钮:点一次进入勾选模式,勾选后再次点删除确认。 + * 工具栏还提供脚本的导入 / 导出。

+ */ +public class ScriptActivity extends BaseActivity { + /** 编辑页返回时用来判断是否需要刷新 */ + private static final int REQUEST_EDIT = 1001; + /** 导入脚本选完文件 */ + private static final int REQUEST_IMPORT = 1002; + + private ScriptStore mStore; + private final List mRoutes = new ArrayList<>(); + private ScriptAdapter mAdapter; + + /** 勾选删除模式 */ + private boolean mSelectMode; + /** 勾选中的脚本 id */ + private final Set mCheckedIds = new LinkedHashSet<>(); + + private ListView mListView; + private TextView mNoScriptText; + + /* 服务绑定,用于拿到播放状态 */ + private ServiceGo.ServiceGoBinder mServiceBinder; + private boolean mBound; + private String mPlayingScriptId; + /** 刚点过“开始”、但服务连接还没回来时记下的脚本 id,连上后兜底补一次启动 */ + private String mPendingStartId; + private int mPlayingIndex; + /** 播放中切换走 / 跑 / 骑的下拉(只在有脚本播放时显示) */ + private Spinner mLiveModeSpinner; + private View mLiveModeBar; + /** 操作条上的“暂停 / 继续播放”按钮(跟着播放状态切换文案与图标) */ + private MaterialButton mPauseResumeButton; + /** 当前脚本是否处于暂停中(由服务端状态回调 / 绑定时同步更新) */ + private volatile boolean mScriptPaused; + /** 已经点过“暂停 / 继续”、但服务连接还没回来时记下的目标状态,连上后补执行一次 */ + private Boolean mPendingPauseRequest; + /** 当前生效的移动状态(服务回调 / 绑定时同步过来,用于忽略 setSelection 的回声) */ + private ScriptWaypoint.Mode mEffectiveMode = ScriptWaypoint.Mode.WALK; + + private final ServiceConnection mConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + mServiceBinder = (ServiceGo.ServiceGoBinder) service; + mBound = true; + mServiceBinder.setScriptListener(mScriptListener); + + if (mPendingStartId != null) { + // 刚点过“开始播放”:服务端没真的在播就用 binder 补一次, + // 不再只依赖 onStartCommand 异步送达(没送到 = 点了不动) + String pending = mPendingStartId; + mPendingStartId = null; + ensureScriptPlaying(pending); + } else { + mPlayingScriptId = mServiceBinder.getRunningScriptId(); + if (mPlayingScriptId == null) { + // 服务端没在播放,存储里的运行记录一定是过期的,一并清掉, + // 否则列表会一直显示“正在播放” + mStore.setRunningScriptId(null); + } + } + + // 挂起的“暂停 / 继续”请求连上后补执行一次(点按钮时服务还没绑上) + if (mPendingPauseRequest != null) { + boolean wantPause = mPendingPauseRequest; + mPendingPauseRequest = null; + if (wantPause) { + mServiceBinder.pauseScript(); + } else { + mServiceBinder.resumeScript(); + } + } + // 暂停状态以服务端为准(暂停也可能是在通知栏 / 主界面发起的) + mScriptPaused = mServiceBinder.isScriptPaused(); + + ScriptWaypoint.Mode mode = mServiceBinder.getLiveMode(); + if (mode == null) { + mode = mServiceBinder.getCurrentMode(); + } + if (mode != null) { + mEffectiveMode = mode; + } + notifyAdapter(); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + mServiceBinder = null; + mBound = false; + notifyAdapter(); + } + }; + + /** 服务端的播放状态变化回调(在定位线程上触发,需要切回主线程刷新界面) */ + private final ServiceGo.ScriptListener mScriptListener = new ServiceGo.ScriptListener() { + @Override + public void onScriptSegment(ScriptRoute route, int index, ScriptWaypoint.Mode mode, double speed) { + mPlayingIndex = index; + mPlayingScriptId = route == null ? null : route.id; + if (mode != null) { + mEffectiveMode = mode; + } + notifyAdapter(); + } + + @Override + public void onScriptFinish(ScriptRoute route) { + // 跑完即结束:别再让列表行显示“正在播放” + mPlayingScriptId = null; + mScriptPaused = false; + notifyAdapter(); + } + + @Override + public void onScriptStopped() { + mPlayingScriptId = null; + mScriptPaused = false; + notifyAdapter(); + } + + @Override + public void onScriptPaused() { + mScriptPaused = true; + notifyAdapter(); + } + + @Override + public void onScriptResumed() { + mScriptPaused = false; + notifyAdapter(); + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_script); + + Toolbar toolbar = findViewById(R.id.toolbar); + if (toolbar != null) { + setSupportActionBar(toolbar); + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + } + } + + mStore = new ScriptStore(this); + + mListView = findViewById(R.id.script_list_view); + mNoScriptText = findViewById(R.id.script_no_textview); + + mAdapter = new ScriptAdapter(); + mListView.setAdapter(mAdapter); + + // 勾选模式下点条目 = 切换勾选;正常模式点条目 = 进编辑页(长按删除已改为工具栏按钮勾选删除) + mListView.setOnItemClickListener((parent, view, position, id) -> { + if (mSelectMode) { + toggleChecked(mRoutes.get(position).id); + } else { + openEditor(mRoutes.get(position)); + } + }); + + FloatingActionButton addButton = findViewById(R.id.script_add); + addButton.setOnClickListener(v -> openEditor(null)); + + initLiveModeBar(); + + refreshList(); + } + + /** 播放中切换走 / 跑 / 骑的操作条(没有脚本在跑时隐藏) */ + private void initLiveModeBar() { + mLiveModeBar = findViewById(R.id.script_mode_bar); + mLiveModeSpinner = findViewById(R.id.script_live_mode); + mPauseResumeButton = findViewById(R.id.script_pause_resume); + if (mPauseResumeButton != null) { + mPauseResumeButton.setOnClickListener(v -> togglePause()); + } + if (mLiveModeSpinner == null) { + return; + } + + ArrayAdapter adapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, + new String[] { + getResources().getString(R.string.script_mode_walk), + getResources().getString(R.string.script_mode_run), + getResources().getString(R.string.script_mode_bike)}); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + mLiveModeSpinner.setAdapter(adapter); + + mLiveModeSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + ScriptWaypoint.Mode mode = modeAt(position); + // setAdapter / setSelection 触发的回调与当前状态一致,直接忽略 + if (mode == mEffectiveMode || mPlayingScriptId == null) { + return; + } + + boolean ok = mServiceBinder != null && mServiceBinder.switchLiveMode(mode); + if (ok) { + mEffectiveMode = mode; + notifyAdapter(); + } + GoUtils.DisplayToast(ScriptActivity.this, getResources().getString( + ok ? R.string.mode_switched : R.string.mode_switch_fail, mode.label)); + } + + @Override + public void onNothingSelected(AdapterView parent) { + } + }); + } + + private static ScriptWaypoint.Mode modeAt(int position) { + if (position == 1) { + return ScriptWaypoint.Mode.RUN; + } + if (position == 2) { + return ScriptWaypoint.Mode.BIKE; + } + return ScriptWaypoint.Mode.WALK; + } + + /** 刷新切换条的可见性与选中项(跟随当前播放状态) */ + private void refreshModeBar() { + if (mLiveModeBar == null || mLiveModeSpinner == null) { + return; + } + + boolean playing = mPlayingScriptId != null; + mLiveModeBar.setVisibility(playing ? View.VISIBLE : View.GONE); + if (playing) { + int position = mEffectiveMode.ordinal(); + if (mLiveModeSpinner.getSelectedItemPosition() != position) { + mLiveModeSpinner.setSelection(position); + } + } + + if (mPauseResumeButton != null) { + // 暂停中按钮变成“继续播放”,图标 暂停 ⇄ 播放 + mPauseResumeButton.setText(mScriptPaused ? R.string.script_resume : R.string.script_pause); + mPauseResumeButton.setIconResource(mScriptPaused ? R.drawable.ic_play : R.drawable.ic_pause); + } + } + + @Override + protected void onStart() { + super.onStart(); + + // 注意:这里不能无条件 bindService,否则“只是想看看脚本列表”也会把模拟位置服务拉起来 + // (服务会立刻挂上摇杆悬浮窗)。只有确认有脚本在跑时才绑定。 + bindIfScriptRunning(); + } + + /** 有脚本正在播放时绑定服务,用于获取播放进度 */ + private void bindIfScriptRunning() { + if (mStore.getRunningScriptId() == null) { + return; + } + + bindServiceIfNeeded(); + } + + private void bindServiceIfNeeded() { + if (mBound) { + return; + } + + try { + bindService(new Intent(this, ServiceGo.class), mConnection, Context.BIND_AUTO_CREATE); + } catch (Exception e) { + mBound = false; + } + } + + @Override + protected void onStop() { + // 解绑后不会再有连接回调,挂起的“补启动”请求无处落地,先作废 + mPendingStartId = null; + mPendingPauseRequest = null; + + if (mBound && mServiceBinder != null) { + try { + mServiceBinder.clearScriptListener(); + } catch (Exception ignored) { + // 服务已退出时忽略 + } + } + if (mBound) { + try { + unbindService(mConnection); + } catch (Exception ignored) { + // 忽略未绑定异常 + } + mBound = false; + mServiceBinder = null; + } + + super.onStop(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.menu_script_list, menu); + return true; + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuItem cancel = menu.findItem(R.id.action_script_select_cancel); + if (cancel != null) { + cancel.setVisible(mSelectMode); + } + return super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + if (item.getItemId() == R.id.action_script_delete) { + handleDeleteMenu(); + return true; + } + if (item.getItemId() == R.id.action_script_select_cancel) { + exitSelectMode(); + return true; + } + if (item.getItemId() == R.id.action_script_export) { + exportScripts(); + return true; + } + if (item.getItemId() == R.id.action_script_import) { + importScripts(); + return true; + } + return super.onOptionsItemSelected(item); + } + + /*============================== 勾选删除 ==============================*/ + + /** 删除按钮:第一次点进入勾选模式,已勾选时弹确认后删除 */ + private void handleDeleteMenu() { + if (!mSelectMode) { + mSelectMode = true; + mCheckedIds.clear(); + notifyAdapter(); + invalidateOptionsMenu(); + GoUtils.DisplayToast(this, getResources().getString(R.string.script_select_hint)); + return; + } + + if (mCheckedIds.isEmpty()) { + GoUtils.DisplayToast(this, getResources().getString(R.string.script_select_none)); + return; + } + + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.script_delete_title) + .setMessage(getResources().getString(R.string.script_delete_checked, mCheckedIds.size())) + .setPositiveButton(R.string.script_delete_title, (dialog, which) -> deleteChecked()) + .setNegativeButton(R.string.input_position_cancel, (dialog, which) -> { + }) + .show(); + } + + private void deleteChecked() { + int count = mCheckedIds.size(); + for (String id : mCheckedIds) { + mStore.delete(id); + if (id.equals(mPlayingScriptId)) { + stopPlaying(); + } + } + mCheckedIds.clear(); + exitSelectMode(); + refreshList(); + GoUtils.DisplayToast(this, getResources().getString(R.string.script_deleted_n, count)); + } + + private void toggleChecked(String id) { + if (!mCheckedIds.remove(id)) { + mCheckedIds.add(id); + } + notifyAdapter(); + } + + private void exitSelectMode() { + mSelectMode = false; + mCheckedIds.clear(); + notifyAdapter(); + invalidateOptionsMenu(); + } + + /*============================== 导入 / 导出 ==============================*/ + + /** 把全部脚本导出成 JSON 文件,并调起系统分享 */ + private void exportScripts() { + if (mStore.loadAll().isEmpty()) { + GoUtils.DisplayToast(this, getResources().getString(R.string.script_list_empty)); + return; + } + + try { + File dir = new File(getCacheDir(), "scripts"); + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("cannot create dir"); + } + File file = new File(dir, "gogogo_scripts.json"); + try (FileOutputStream out = new FileOutputStream(file)) { + out.write(mStore.exportJson().getBytes(StandardCharsets.UTF_8)); + } + + ShareUtils.shareFile(this, file, getResources().getString(R.string.script_export)); + } catch (Exception e) { + GoUtils.DisplayToast(this, getResources().getString(R.string.script_export_fail)); + } + } + + /** 从系统文件选择器挑一个导出的 JSON 并导入 */ + private void importScripts() { + try { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("*/*"); + intent.putExtra(Intent.EXTRA_MIME_TYPES, + new String[] {"application/json", "text/plain", "application/octet-stream"}); + startActivityForResult(Intent.createChooser(intent, + getResources().getString(R.string.script_import)), REQUEST_IMPORT); + } catch (Exception e) { + GoUtils.DisplayToast(this, getResources().getString(R.string.script_import_fail)); + } + } + + private void handleImportResult(Intent data) { + if (data == null || data.getData() == null) { + return; + } + + Uri uri = data.getData(); + try (InputStream in = getContentResolver().openInputStream(uri)) { + if (in == null) { + GoUtils.DisplayToast(this, getResources().getString(R.string.script_import_fail)); + return; + } + + java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream(); + byte[] chunk = new byte[4096]; + int read; + while ((read = in.read(chunk)) != -1) { + buffer.write(chunk, 0, read); + } + + int count = mStore.importJson(new String(buffer.toByteArray(), StandardCharsets.UTF_8)); + if (count > 0) { + refreshList(); + GoUtils.DisplayToast(this, getResources().getString(R.string.script_import_ok, count)); + } else { + GoUtils.DisplayToast(this, getResources().getString(R.string.script_import_fail)); + } + } catch (Exception e) { + GoUtils.DisplayToast(this, getResources().getString(R.string.script_import_fail)); + } + } + + /** 编辑页保存后刷新列表;导入选完文件后解析入库 */ + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == REQUEST_EDIT) { + refreshList(); + } else if (requestCode == REQUEST_IMPORT && resultCode == RESULT_OK) { + handleImportResult(data); + } + } + + private void openEditor(ScriptRoute route) { + Intent intent = new Intent(this, ScriptEditActivity.class); + if (route != null) { + intent.putExtra(ScriptEditActivity.EXTRA_ROUTE_ID, route.id); + } + startActivityForResult(intent, REQUEST_EDIT); + } + + private void refreshList() { + mRoutes.clear(); + mRoutes.addAll(mStore.loadAll()); + + // 服务还没绑定回来时,用存储里的运行状态兜底 + if (!mBound) { + mPlayingScriptId = mStore.getRunningScriptId(); + } + + notifyAdapter(); + } + + private void notifyAdapter() { + runOnUiThread(() -> { + if (isFinishing()) { + return; + } + + boolean empty = mRoutes.isEmpty(); + mNoScriptText.setVisibility(empty ? View.VISIBLE : View.GONE); + mListView.setVisibility(empty ? View.GONE : View.VISIBLE); + refreshModeBar(); + mAdapter.notifyDataSetChanged(); + }); + } + + /*============================== 开始 / 删除 ==============================*/ + + private void startPlay(ScriptRoute route) { + if (route == null || route.points.isEmpty()) { + GoUtils.DisplayToast(this, getResources().getString(R.string.script_need_point)); + return; + } + + if (!GoUtils.isNetworkAvailable(this)) { + GoUtils.DisplayToast(this, getResources().getString(R.string.app_error_network)); + return; + } + + if (!GoUtils.isGpsOpened(this)) { + GoUtils.showEnableGpsDialog(this); + return; + } + + if (!Settings.canDrawOverlays(getApplicationContext())) { + GoUtils.showEnableFloatWindowDialog(this); + return; + } + + if (!GoUtils.isAllowMockLocation(this)) { + GoUtils.showEnableMockLocationDialog(this); + return; + } + + // 以脚本第一个路点作为起始位置(ServiceGo 会把脚本对齐到离它最近的轨迹点) + ScriptWaypoint first = route.points.get(0); + Intent intent = new Intent(this, ServiceGo.class); + intent.putExtra(MainActivity.SCRIPT_ROUTE_ID, route.id); + intent.putExtra(MainActivity.LNG_MSG_ID, first.lng); + intent.putExtra(MainActivity.LAT_MSG_ID, first.lat); + intent.putExtra(MainActivity.ALT_MSG_ID, first.alt); + + try { + startForegroundService(intent); + mPlayingScriptId = route.id; + mPlayingIndex = 0; + // 新开一条:本地先按“播放中”显示,连上后由服务端状态校准 + mScriptPaused = false; + notifyAdapter(); + + // 已经绑定着就立刻校验一次;还没绑上就立刻绑定,连接回调里兜底补启动, + // 保证“点开始”最终一定会真的进入播放(不只依赖 onStartCommand 送达) + mPendingStartId = null; + if (mServiceBinder != null) { + ensureScriptPlaying(route.id); + } else { + mPendingStartId = route.id; + bindServiceIfNeeded(); + } + + GoUtils.DisplayToast(this, getResources().getString(R.string.script_start)); + + // 播放已开始:自动退回地图主界面。本地服务连接通常几十毫秒就回来, + // 留 500ms 让“连上后兜底启动”先跑完再退 + mListView.postDelayed(this::finish, 500); + } catch (Exception e) { + GoUtils.DisplayToast(this, getResources().getString(R.string.script_start_fail)); + } + } + + /** 校验服务端确实在播放指定脚本;没在播就用 binder 兜底启动一次 */ + private void ensureScriptPlaying(String scriptId) { + if (mServiceBinder == null || scriptId == null || scriptId.isEmpty()) { + return; + } + + try { + // 服务端“真的”在播同一条脚本就跳过:重复 startScript 会让播放器重新走一遍 + // findAnchor,用当前模拟位置把轨迹重新锚定一次,位置会莫名跳一下。 + // 以前这里无条件重启,是“先启动模拟位置、再起用脚本”表现异常的来源之一。 + // + // 双条件缺一不可:isScriptPlaying() 是状态 + 播放器双确认(防状态残留误判), + // id 相同才叫“同一条”(换脚本必须能立刻切过去)。 + boolean playingSame = mServiceBinder.isScriptPlaying() + && scriptId.equals(mServiceBinder.getRunningScriptId()); + if (playingSame) { + // 暂停中又点了一次 ▶:当作继续播放,而不是重新对齐轨迹 + if (mServiceBinder.isScriptPaused() && mServiceBinder.resumeScript()) { + mScriptPaused = false; + } + mPlayingScriptId = scriptId; + return; + } + + boolean ok = mServiceBinder.startScript(scriptId); + if (ok) { + mPlayingScriptId = scriptId; + mScriptPaused = false; + } else { + GoUtils.DisplayToast(this, getResources().getString(R.string.script_start_fail)); + } + } catch (Exception e) { + // 忽略服务异常 + } + } + + /** + * 暂停 / 继续播放当前脚本。 + * + *

暂停 = 位置停在原地、进度保留;继续 = 从暂停处原地接着走(不瞬移、不跳段)。 + * 服务还没绑上时先记住请求,连上后在 onServiceConnected 里补执行, + * 避免“点了一下没反应”。

+ */ + private void togglePause() { + if (mServiceBinder == null) { + mPendingPauseRequest = !mScriptPaused; + bindServiceIfNeeded(); + return; + } + + try { + if (mScriptPaused) { + boolean ok = mServiceBinder.resumeScript(); + if (ok) { + mScriptPaused = false; + } + GoUtils.DisplayToast(this, getResources().getString( + ok ? R.string.script_resumed_toast : R.string.script_resume_fail)); + } else { + boolean ok = mServiceBinder.pauseScript(); + if (ok) { + mScriptPaused = true; + } + GoUtils.DisplayToast(this, getResources().getString( + ok ? R.string.script_paused_toast : R.string.script_pause_fail)); + } + notifyAdapter(); + } catch (Exception e) { + // 忽略服务异常 + } + } + + /** 正在播放的脚本被删除时,顺手停掉播放 */ + private void stopPlaying() { + mPendingStartId = null; + + if (mBound && mServiceBinder != null) { + try { + mServiceBinder.stopScript(); + } catch (Exception ignored) { + // 忽略服务异常 + } + } else { + Intent intent = new Intent(this, ServiceGo.class); + try { + stopService(intent); + } catch (Exception ignored) { + // 忽略服务异常 + } + } + + mPlayingScriptId = null; + mScriptPaused = false; + notifyAdapter(); + } + + private static String safeName(ScriptRoute route) { + if (route == null || route.name == null || route.name.trim().isEmpty()) { + return ""; + } + return route.name.trim(); + } + + /*============================== 列表适配器 ==============================*/ + + private class ScriptAdapter extends BaseAdapter { + private final LayoutInflater inflater = LayoutInflater.from(ScriptActivity.this); + + @Override + public int getCount() { + return mRoutes.size(); + } + + @Override + public Object getItem(int position) { + return mRoutes.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View view = convertView; + if (view == null) { + view = inflater.inflate(R.layout.script_item, parent, false); + } + + ScriptRoute route = mRoutes.get(position); + if (route == null) { + return view; + } + + TextView name = view.findViewById(R.id.script_item_name); + TextView badge = view.findViewById(R.id.script_item_badge); + TextView loop = view.findViewById(R.id.script_item_loop); + TextView info = view.findViewById(R.id.script_item_info); + TextView status = view.findViewById(R.id.script_item_status); + ImageButton edit = view.findViewById(R.id.script_item_edit); + ImageButton start = view.findViewById(R.id.script_item_start); + CheckBox check = view.findViewById(R.id.script_item_check); + + // 勾选删除模式:显示复选框,隐藏“开始 / 编辑”,条目点击即切换勾选 + check.setVisibility(mSelectMode ? View.VISIBLE : View.GONE); + check.setChecked(mCheckedIds.contains(route.id)); + check.setOnClickListener(v -> toggleChecked(route.id)); + int actionVisibility = mSelectMode ? View.GONE : View.VISIBLE; + start.setVisibility(actionVisibility); + edit.setVisibility(actionVisibility); + + name.setText(safeName(route)); + + // 状态角标:单一状态显示“走 / 跑 / 骑”,混用状态提示“含多种状态” + ScriptWaypoint.Mode mode = primaryMode(route); + if (mode == null) { + badge.setVisibility(View.GONE); + } else { + badge.setVisibility(View.VISIBLE); + badge.setText(modeLabel(mode)); + badge.setBackgroundColor(modeColor(mode)); + } + + loop.setText(route.isLoop() ? R.string.script_loop_on : R.string.script_loop_off); + loop.setBackgroundResource(route.isLoop() ? R.drawable.round_badge_accent : R.drawable.round_badge_gray); + + double distance = ScriptParser.totalDistance(route); + long seconds = ScriptParser.totalSeconds(route); + info.setText(getResources().getString(R.string.script_item_info, + route.size(), + ScriptParser.formatDistance(distance), + ScriptParser.formatDuration(seconds))); + + boolean playing = route.id.equals(mPlayingScriptId); + if (playing) { + status.setVisibility(View.VISIBLE); + int index = Math.min(mPlayingIndex + 1, Math.max(1, route.size())); + // 展示服务端真正生效的状态(播放中手动切换过的也反映出来),暂停中单独提示 + status.setText(getResources().getString( + mScriptPaused ? R.string.script_paused_status : R.string.script_playing, + index, route.size(), + modeLabel(mEffectiveMode != null ? mEffectiveMode : mode))); + } else { + status.setVisibility(View.GONE); + status.setText(""); + } + + // 铅笔按钮:显式的编辑入口(点条目本身也能进编辑页) + edit.setOnClickListener(v -> openEditor(route)); + if (playing) { + // 正在跑的这条:按钮变成“暂停 / 继续”,就地切换,不重新启动脚本 + start.setImageResource(mScriptPaused ? R.drawable.ic_play : R.drawable.ic_pause); + start.setContentDescription(getResources().getString( + mScriptPaused ? R.string.script_resume : R.string.script_pause)); + start.setOnClickListener(v -> togglePause()); + } else { + // 复用行会被反复绑定,图标必须每次显式重置,否则会残留上一行的暂停图标 + start.setImageResource(R.drawable.ic_script_play); + start.setContentDescription(getResources().getString(R.string.script_start)); + start.setOnClickListener(v -> startPlay(route)); + } + + return view; + } + } + + private static ScriptWaypoint.Mode primaryMode(ScriptRoute route) { + if (route == null || route.points == null || route.points.isEmpty()) { + return null; + } + + ScriptWaypoint.Mode mode = route.points.get(0).mode; + for (ScriptWaypoint point : route.points) { + if (point.mode != mode) { + return null; + } + } + + return mode; + } + + private String modeLabel(ScriptWaypoint.Mode mode) { + if (mode == null) { + return getResources().getString(R.string.script_badge_multi); + } + return mode.label; + } + + private int modeColor(ScriptWaypoint.Mode mode) { + int colorRes; + switch (mode) { + case RUN: + colorRes = R.color.darkorange; + break; + case BIKE: + colorRes = R.color.steelblue; + break; + case WALK: + default: + colorRes = R.color.colorAccent; + break; + } + + return ContextCompat.getColor(this, colorRes); + } +} diff --git a/app/src/main/java/com/zcshou/gogogo/ScriptEditActivity.java b/app/src/main/java/com/zcshou/gogogo/ScriptEditActivity.java new file mode 100644 index 00000000..7b48e4e9 --- /dev/null +++ b/app/src/main/java/com/zcshou/gogogo/ScriptEditActivity.java @@ -0,0 +1,1064 @@ +package com.zcshou.gogogo; + +import android.content.SharedPreferences; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.BaseAdapter; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.ListView; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.Spinner; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; +import androidx.preference.PreferenceManager; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.osmdroid.events.MapEventsReceiver; +import org.osmdroid.util.GeoPoint; +import org.osmdroid.views.MapView; +import org.osmdroid.views.overlay.MapEventsOverlay; +import org.osmdroid.views.overlay.Marker; +import org.osmdroid.views.overlay.Polyline; + +import com.zcshou.script.ScriptParser; +import com.zcshou.script.ScriptRoute; +import com.zcshou.script.ScriptStore; +import com.zcshou.script.ScriptTextEditor; +import com.zcshou.script.ScriptWaypoint; +import com.zcshou.utils.GoUtils; +import com.zcshou.utils.MapUtils; +import com.zcshou.utils.TileSourceUtils; + +import java.util.ArrayList; +import java.util.List; + +/** + * 脚本编辑页:路点列表 + 原文文本 + 地图选点。 + * + *

默认在「路点列表」视图:每行一个路点,点开表单即可改状态、经纬度(分栏填写)、 + * 速度、停留,也能删除 / 调顺序;「原文」视图保留纯文本编辑(注释、自由写法都还在)。 + * 两个视图共用同一份脚本文本——列表只是 {@link ScriptParser#parseLines} 的视图, + * 表单的每次保存都只改原文里对应的那一行。

+ */ +public class ScriptEditActivity extends BaseActivity { + /** 编辑已有脚本时传入脚本 id */ + public static final String EXTRA_ROUTE_ID = "EXTRA_ROUTE_ID"; + /** 地图选点时的默认缩放级别 */ + private static final double DEFAULT_ZOOM = 17.0; + + private ScriptStore mStore; + private ScriptRoute mRoute; + + private EditText mNameEdit; + private EditText mTextEdit; + private CheckBox mLoopCheck; + private TextView mPreviewText; + private RadioButton mCoordWgs84; + private RadioButton mCoordBd09; + + /** 「路点列表 / 原文」切换 */ + private RadioGroup mTabGroup; + /** 原文视图容器 */ + private View mTextPanel; + /** 路点列表与空态提示 */ + private ListView mPointList; + private TextView mPointEmpty; + private PointAdapter mPointAdapter; + + /** 地图选点对话框打开期间的地图(关闭后置空,避免操作打到已回收的地图上) */ + private MapView mPickMap; + /** 路点连线:只用来看,点在连线上仍会继续加点 */ + private Polyline mPickLine; + /** 已选路点的图钉:单击 = 编辑该点,长按拖动 = 挪位置 */ + private final List mPickMarkers = new ArrayList<>(); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_script_edit); + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + } + + mStore = new ScriptStore(this); + + mNameEdit = findViewById(R.id.script_name); + mTextEdit = findViewById(R.id.script_text); + mLoopCheck = findViewById(R.id.script_loop); + mPreviewText = findViewById(R.id.script_preview); + mCoordWgs84 = findViewById(R.id.script_coord_wgs84); + mCoordBd09 = findViewById(R.id.script_coord_bd09); + + String routeId = getIntent().getStringExtra(EXTRA_ROUTE_ID); + if (!TextUtils.isEmpty(routeId)) { + mRoute = mStore.load(routeId); + } + + // 坐标系优先跟着脚本自己走;旧脚本没有记录时才回落到“上次使用的坐标系” + boolean fromBd09; + if (mRoute != null && mRoute.fromBd09 != null) { + fromBd09 = mRoute.fromBd09; + } else { + fromBd09 = PreferenceManager.getDefaultSharedPreferences(this) + .getBoolean(ScriptParser.KEY_SCRIPT_FROM_BD09, false); + } + mCoordBd09.setChecked(fromBd09); + mCoordWgs84.setChecked(!fromBd09); + + if (mRoute == null) { + mRoute = new ScriptRoute(); + mRoute.name = getResources().getString(R.string.script_name_default); + if (actionBar != null) { + actionBar.setTitle(R.string.script_edit_new_title); + } + } else { + if (actionBar != null) { + actionBar.setTitle(R.string.script_edit_title); + } + } + + mNameEdit.setText(mRoute.name); + mTextEdit.setText(mRoute.text); + mLoopCheck.setChecked(mRoute.isLoop()); + + findViewById(R.id.script_map_pick).setOnClickListener(v -> showMapPicker()); + findViewById(R.id.script_append_current).setOnClickListener(v -> appendCurrentPosition()); + findViewById(R.id.script_check).setOnClickListener(v -> checkScript()); + + // 路点列表:数据来自脚本文本,点行 = 表单编辑,右下角 + = 表单新增 + mTabGroup = findViewById(R.id.script_tab_group); + mTextPanel = findViewById(R.id.script_text_panel); + mPointList = findViewById(R.id.script_point_list); + mPointEmpty = findViewById(R.id.script_point_empty); + + mPointAdapter = new PointAdapter(); + mPointList.setAdapter(mPointAdapter); + mPointList.setOnItemClickListener((parent, view, position, id) -> showPointForm(position)); + findViewById(R.id.script_point_add).setOnClickListener(v -> showPointForm(-1)); + mTabGroup.setOnCheckedChangeListener((group, checkedId) -> applyTab(checkedId)); + + // 坐标系切换会改变原文的解读方式,摘要和列表都要跟着刷新 + ((RadioGroup) findViewById(R.id.script_coord_group)) + .setOnCheckedChangeListener((group, checkedId) -> { + showPreview(); + refreshPointList(); + }); + + // 编辑过程中实时更新摘要与路点列表,随时能看到脚本是否还合法 + mTextEdit.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable s) { + showPreview(); + refreshPointList(); + } + }); + + applyTab(mTabGroup.getCheckedRadioButtonId()); + showPreview(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.menu_script_edit, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + if (item.getItemId() == R.id.action_script_save) { + save(); + return true; + } + return super.onOptionsItemSelected(item); + } + + /*============================== 保存 / 校验 ==============================*/ + + private void save() { + String name = mNameEdit.getText().toString().trim(); + if (TextUtils.isEmpty(name)) { + GoUtils.DisplayToast(this, getResources().getString(R.string.script_need_name)); + return; + } + + String text = mTextEdit.getText().toString(); + if (TextUtils.isEmpty(text.trim())) { + GoUtils.DisplayToast(this, getResources().getString(R.string.script_need_point)); + return; + } + + boolean fromBd09 = mCoordBd09.isChecked(); + ScriptParser.ParseResult parseResult = ScriptParser.parse(text, fromBd09, getModeSpeeds()); + if (!parseResult.isOk()) { + GoUtils.DisplayToast(this, getResources().getString(R.string.script_check_fail, parseResult.getMessage())); + return; + } + + // 脚本里没写海拔时,用设置里的海拔补齐 + double altitude = getSettingAltitude(); + for (ScriptWaypoint point : parseResult.route.points) { + point.alt = altitude; + } + + mRoute.name = name; + mRoute.text = text; + mRoute.points = parseResult.route.points; + mRoute.endMode = mLoopCheck.isChecked() ? ScriptRoute.END_LOOP : ScriptRoute.END_STOP; + // 坐标系记在这条脚本自己身上,之后改全局单选也不会让它偏移 + mRoute.fromBd09 = fromBd09; + // 标记“用户手动改过”,主界面重新选点时就不会再覆盖这条脚本 + mRoute.edited = true; + + mStore.save(mRoute); + PreferenceManager.getDefaultSharedPreferences(this).edit() + .putBoolean(ScriptParser.KEY_SCRIPT_FROM_BD09, fromBd09) + .apply(); + + GoUtils.DisplayToast(this, getResources().getString(R.string.script_saved)); + setResult(RESULT_OK); + finish(); + } + + private void checkScript() { + String text = mTextEdit.getText().toString(); + boolean fromBd09 = mCoordBd09.isChecked(); + ScriptParser.ParseResult parseResult = ScriptParser.parse(text, fromBd09, getModeSpeeds()); + + if (!parseResult.isOk()) { + mPreviewText.setText(getResources().getString(R.string.script_check_fail, parseResult.getMessage())); + GoUtils.DisplayToast(this, getResources().getString(R.string.script_check_fail, parseResult.error)); + return; + } + + double distance = ScriptParser.totalDistance(parseResult.route); + long seconds = ScriptParser.totalSeconds(parseResult.route); + String summary = getResources().getString(R.string.script_check_ok, + parseResult.route.size(), + ScriptParser.formatDistance(distance), + ScriptParser.formatDuration(seconds)); + + if (parseResult.warnings.isEmpty()) { + mPreviewText.setText(summary); + GoUtils.DisplayToast(this, summary); + } else { + String message = summary + "\n" + String.join("\n", parseResult.warnings); + mPreviewText.setText(message); + GoUtils.DisplayToast(this, getResources().getString(R.string.script_check_warn, + String.join("\n", parseResult.warnings))); + } + } + + /** 编辑过程中的实时摘要,随时提示当前脚本是否可用 */ + private void showPreview() { + String text = mTextEdit.getText().toString(); + if (TextUtils.isEmpty(text.trim())) { + mPreviewText.setText(R.string.script_format_tips); + return; + } + + ScriptParser.ParseResult parseResult = ScriptParser.parse(text, mCoordBd09.isChecked(), getModeSpeeds()); + if (!parseResult.isOk()) { + mPreviewText.setText(getResources().getString(R.string.script_check_fail, parseResult.error)); + return; + } + + mPreviewText.setText(getResources().getString(R.string.script_check_ok, + parseResult.route.size(), + ScriptParser.formatDistance(ScriptParser.totalDistance(parseResult.route)), + ScriptParser.formatDuration(ScriptParser.totalSeconds(parseResult.route)))); + } + + /*============================== 插入路点 ==============================*/ + + /** 当前选中的移动状态(走 / 跑 / 骑) */ + private ScriptWaypoint.Mode getInsertMode() { + if (((RadioButton) findViewById(R.id.script_mode_run)).isChecked()) { + return ScriptWaypoint.Mode.RUN; + } + if (((RadioButton) findViewById(R.id.script_mode_bike)).isChecked()) { + return ScriptWaypoint.Mode.BIKE; + } + return ScriptWaypoint.Mode.WALK; + } + + /** + * 插入一个路点。 + * + * @param lngWgs84 经度(WGS-84,地图回调与内部模拟坐标都是这个坐标系) + * @param latWgs84 纬度(WGS-84) + */ + private void insertPoint(double lngWgs84, double latWgs84) { + ScriptWaypoint.Mode mode = getInsertMode(); + double speed = getModeSpeed(mode); + + // formatPoint 负责按脚本坐标系转换(BD-09 勾选时写 BD-09 坐标) + ScriptWaypoint point = new ScriptWaypoint(lngWgs84, latWgs84, getSettingAltitude(), mode, speed, 0); + String line = ScriptParser.formatPoint(point, mCoordBd09.isChecked()); + + String text = mTextEdit.getText().toString(); + + // 列表视图下没有明确的光标语义,新点直接追加到末尾; + // 原文视图保持原来的“插到光标处”,方便手工编排顺序 + int start; + int end; + if (isListTab()) { + start = text.length(); + end = start; + } else { + start = Math.max(0, Math.min(mTextEdit.getSelectionStart(), text.length())); + end = Math.max(start, Math.min(mTextEdit.getSelectionEnd(), text.length())); + } + + String prefix = ""; + if (start > 0 && text.charAt(start - 1) != '\n') { + prefix = "\n"; + } + + mTextEdit.getText().replace(start, end, prefix + line); + // 插入后把光标移到行尾,方便继续追加 + mTextEdit.setSelection(Math.min(mTextEdit.getText().length(), start + prefix.length() + line.length())); + + showPreview(); + } + + /** 把上一次模拟的位置作为路点插入 */ + private void appendCurrentPosition() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + String lngText = preferences.getString("service_last_lng", null); + String latText = preferences.getString("service_last_lat", null); + + if (TextUtils.isEmpty(lngText) || TextUtils.isEmpty(latText)) { + GoUtils.DisplayToast(this, getResources().getString(R.string.script_no_location_yet)); + return; + } + + try { + insertPoint(Double.parseDouble(lngText), Double.parseDouble(latText)); + } catch (NumberFormatException e) { + GoUtils.DisplayToast(this, getResources().getString(R.string.script_no_location_yet)); + } + } + + /*============================== 路点列表 / 表单 ==============================*/ + + /** 当前是否在「路点列表」视图 */ + private boolean isListTab() { + return mTabGroup != null && mTabGroup.getCheckedRadioButtonId() == R.id.script_tab_points; + } + + /** 切换「路点列表 / 原文」两个视图 */ + private void applyTab(int checkedId) { + if (mTextPanel == null || mPointList == null) { + return; + } + + boolean listTab = checkedId == R.id.script_tab_points; + mTextPanel.setVisibility(listTab ? View.GONE : View.VISIBLE); + findViewById(R.id.script_point_add).setVisibility(listTab ? View.VISIBLE : View.GONE); + refreshPointList(); + } + + /** 文本 / 坐标系 / 表单改动后重建列表(列表只是脚本文本的视图) */ + private void refreshPointList() { + if (mPointAdapter == null) { + return; + } + + mPointAdapter.setEntries(currentPickerEntries()); + mPointAdapter.notifyDataSetChanged(); + applyEmptyState(); + } + + /** 列表空态:只在列表视图生效 */ + private void applyEmptyState() { + boolean listTab = isListTab(); + boolean empty = mPointAdapter.getCount() == 0; + mPointList.setVisibility(listTab && !empty ? View.VISIBLE : View.GONE); + mPointEmpty.setVisibility(listTab && empty ? View.VISIBLE : View.GONE); + } + + /** + * 添加(entryIndex < 0)或编辑一个路点的表单。 + * + *

表单里经度、纬度分栏填写(显示 WGS-84);保存时按脚本坐标系生成一行, + * 编辑只重写原文里对应的那一行,注释与其它路点不受影响。

+ */ + private void showPointForm(final int entryIndex) { + final List entries = currentPickerEntries(); + final boolean editing = entryIndex >= 0 && entryIndex < entries.size(); + final int lineIndex = editing ? entries.get(entryIndex).lineIndex : -1; + + View form = LayoutInflater.from(this).inflate(R.layout.script_point_form, null); + final EditText lngEdit = form.findViewById(R.id.script_form_lng); + final EditText latEdit = form.findViewById(R.id.script_form_lat); + final EditText speedEdit = form.findViewById(R.id.script_form_speed); + final EditText waitEdit = form.findViewById(R.id.script_form_wait); + final RadioButton formWalk = form.findViewById(R.id.script_form_walk); + final RadioButton formRun = form.findViewById(R.id.script_form_run); + final RadioButton formBike = form.findViewById(R.id.script_form_bike); + View ops = form.findViewById(R.id.script_form_ops); + + if (editing) { + ScriptWaypoint point = entries.get(entryIndex).point; + lngEdit.setText(formatCoord(point.lng)); + latEdit.setText(formatCoord(point.lat)); + if (point.speed > 0) { + speedEdit.setText(trimSpeed(point.speed)); + } + if (point.waitSeconds > 0) { + waitEdit.setText(String.valueOf(point.waitSeconds)); + } + if (point.mode == ScriptWaypoint.Mode.RUN) { + formRun.setChecked(true); + } else if (point.mode == ScriptWaypoint.Mode.BIKE) { + formBike.setChecked(true); + } else { + formWalk.setChecked(true); + } + ops.setVisibility(View.VISIBLE); + } else { + // 新增时状态默认跟随底部的“插入状态”单选 + ScriptWaypoint.Mode mode = getInsertMode(); + if (mode == ScriptWaypoint.Mode.RUN) { + formRun.setChecked(true); + } else if (mode == ScriptWaypoint.Mode.BIKE) { + formBike.setChecked(true); + } else { + formWalk.setChecked(true); + } + } + + String title = editing + ? getResources().getString(R.string.script_point_title, entryIndex + 1) + : getResources().getString(R.string.script_point_add); + final AlertDialog dialog = new MaterialAlertDialogBuilder(this) + .setTitle(title) + .setView(form) + .setPositiveButton(R.string.script_save, null) + .setNegativeButton(R.string.input_position_cancel, null) + .show(); + + // 保存按钮先拦下来校验,通过才关闭弹窗 + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> { + ScriptWaypoint.Mode mode = formRun.isChecked() ? ScriptWaypoint.Mode.RUN + : formBike.isChecked() ? ScriptWaypoint.Mode.BIKE + : ScriptWaypoint.Mode.WALK; + + String lngText = lngEdit.getText().toString().trim(); + String latText = latEdit.getText().toString().trim(); + if (TextUtils.isEmpty(lngText) || TextUtils.isEmpty(latText)) { + GoUtils.DisplayToast(this, getResources().getString(R.string.input_position_empty)); + return; + } + + double lng; + double lat; + try { + lng = Double.parseDouble(lngText); + lat = Double.parseDouble(latText); + } catch (NumberFormatException e) { + GoUtils.DisplayToast(this, getResources().getString(R.string.app_error_input)); + return; + } + if (lng < -180.0 || lng > 180.0 || lat < -90.0 || lat > 90.0) { + GoUtils.DisplayToast(this, getResources().getString(R.string.coord_range_error)); + return; + } + + double speed = 0; + String speedText = speedEdit.getText().toString().trim(); + if (!TextUtils.isEmpty(speedText)) { + try { + speed = Double.parseDouble(speedText); + } catch (NumberFormatException e) { + GoUtils.DisplayToast(this, getResources().getString(R.string.app_error_input)); + return; + } + if (speed < 0) { + speed = 0; + } + } + + int wait = 0; + String waitText = waitEdit.getText().toString().trim(); + if (!TextUtils.isEmpty(waitText)) { + try { + wait = (int) Math.round(Double.parseDouble(waitText)); + } catch (NumberFormatException e) { + GoUtils.DisplayToast(this, getResources().getString(R.string.app_error_input)); + return; + } + if (wait < 0) { + wait = 0; + } + } + + // 表单里是 WGS-84,formatPoint 按脚本坐标系写回 + ScriptWaypoint point = new ScriptWaypoint(lng, lat, getSettingAltitude(), mode, speed, wait); + String line = ScriptParser.formatPoint(point, mCoordBd09.isChecked(), true); + + String text = mTextEdit.getText().toString(); + if (editing) { + text = ScriptTextEditor.setLine(text, lineIndex, line); + } else { + if (!text.isEmpty() && !text.endsWith("\n")) { + text = text + "\n"; + } + text = text + line; + } + + applyPickerText(text, editing ? R.string.script_point_saved : R.string.script_point_added); + dialog.dismiss(); + }); + + if (editing) { + form.findViewById(R.id.script_form_delete).setOnClickListener(v -> { + deletePoint(lineIndex); + dialog.dismiss(); + }); + form.findViewById(R.id.script_form_up).setOnClickListener(v -> { + movePoint(entryIndex, lineIndex, -1); + dialog.dismiss(); + }); + form.findViewById(R.id.script_form_down).setOnClickListener(v -> { + movePoint(entryIndex, lineIndex, 1); + dialog.dismiss(); + }); + } + } + + /** 输入框里的坐标文本:保留 6 位精度并去掉多余的 0(116.397000 -> 116.397) */ + private static String formatCoord(double value) { + String text = String.format(java.util.Locale.US, "%.6f", value); + text = text.replaceFirst("0+$", "").replaceFirst("\\.$", ""); + return text.isEmpty() ? "0" : text; + } + + /** 速度的显示写法:10.0 -> 10,3.6 -> 3.6 */ + private static String trimSpeed(double speed) { + String text = Double.toString(speed); + return text.endsWith(".0") ? text.substring(0, text.length() - 2) : text; + } + + private String modeLabel(ScriptWaypoint.Mode mode) { + return mode == null ? "" : mode.label; + } + + private int modeColor(ScriptWaypoint.Mode mode) { + int colorRes; + switch (mode == null ? ScriptWaypoint.Mode.WALK : mode) { + case RUN: + colorRes = R.color.darkorange; + break; + case BIKE: + colorRes = R.color.steelblue; + break; + default: + colorRes = R.color.colorAccent; + break; + } + return ContextCompat.getColor(this, colorRes); + } + + /** 路点列表适配器:行内容完全来自解析结果,与校验摘要的点数保持一致 */ + private class PointAdapter extends BaseAdapter { + private final LayoutInflater mInflater = LayoutInflater.from(ScriptEditActivity.this); + private List mEntries = new ArrayList<>(); + + void setEntries(List entries) { + mEntries = entries == null ? new ArrayList<>() : entries; + } + + @Override + public int getCount() { + return mEntries.size(); + } + + @Override + public Object getItem(int position) { + return mEntries.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View view = convertView; + if (view == null) { + view = mInflater.inflate(R.layout.script_point_item, parent, false); + } + + ScriptWaypoint point = mEntries.get(position).point; + + TextView index = view.findViewById(R.id.script_point_index); + TextView mode = view.findViewById(R.id.script_point_mode); + TextView coords = view.findViewById(R.id.script_point_coords); + TextView extra = view.findViewById(R.id.script_point_extra); + + index.setText(String.valueOf(position + 1)); + mode.setText(modeLabel(point.mode)); + mode.setBackgroundColor(modeColor(point.mode)); + coords.setText(String.format(java.util.Locale.US, "%.6f, %.6f", point.lng, point.lat)); + + String extraText; + if (point.speed > 0 && point.waitSeconds > 0) { + extraText = getResources().getString(R.string.script_point_speed_wait, + trimSpeed(point.speed), point.waitSeconds); + } else if (point.speed > 0) { + extraText = getResources().getString(R.string.script_point_speed_only, trimSpeed(point.speed)); + } else if (point.waitSeconds > 0) { + extraText = getResources().getString(R.string.script_point_wait_only, point.waitSeconds); + } else { + extraText = ""; + } + extra.setText(extraText); + extra.setVisibility(TextUtils.isEmpty(extraText) ? View.GONE : View.VISIBLE); + + return view; + } + } + + /*============================== 地图选点 ==============================*/ + + private void showMapPicker() { + View view = LayoutInflater.from(this).inflate(R.layout.script_map_pick, null); + AlertDialog dialog = new MaterialAlertDialogBuilder(this) + .setTitle(R.string.script_map_pick) + .setView(view) + .create(); + dialog.show(); + + MapView mapView = view.findViewById(R.id.script_map); + mapView.setBuiltInZoomControls(false); + mapView.setMultiTouchControls(true); + mapView.setTilesScaledToDpi(true); + mapView.setTileSource(TileSourceUtils.OSM_STANDARD); + mapView.getController().setZoom(DEFAULT_ZOOM); + + // 初位置:脚本第一个点,或者上一次模拟的位置(有路点时随后会被 fitPickerPoints 校正) + GeoPoint start = firstPointOrLastPosition(); + mapView.getController().setCenter(start); + mPickMap = mapView; + + // 连线图层放最下面:只用来看,点在连线上仍要能继续加点 + mPickLine = new Polyline(mapView); + mPickLine.getOutlinePaint().setColor(ContextCompat.getColor(this, R.color.colorAccent)); + mPickLine.getOutlinePaint().setStrokeWidth(8.0f); + mapView.getOverlays().add(mPickLine); + + // 点击派发器:单击空白处 = 按当前状态往光标处插入一个路点。 + // osmdroid 按图层倒序派发,路点图钉在它上面,所以点在已有路点上不会走到这里。 + mapView.getOverlays().add(new MapEventsOverlay(new MapEventsReceiver() { + @Override + public boolean singleTapConfirmedHelper(GeoPoint p) { + // 地图回调给的是 WGS-84;insertPoint 会按编辑页选定的坐标系落到脚本里 + insertPoint(p.getLongitude(), p.getLatitude()); + refreshPickerPoints(); + return true; + } + + @Override + public boolean longPressHelper(GeoPoint p) { + return singleTapConfirmedHelper(p); + } + })); + + // 把脚本里已有的路点画出来(历史选点可见、可编辑) + refreshPickerPoints(); + // 等对话框完成布局后再把视野缩放到能装下全部路点 + mapView.post(this::fitPickerPoints); + + final Spinner modeSpinner = view.findViewById(R.id.script_map_mode); + ArrayAdapter adapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, + new String[] { + getResources().getString(R.string.script_mode_walk), + getResources().getString(R.string.script_mode_run), + getResources().getString(R.string.script_mode_bike)}); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + modeSpinner.setAdapter(adapter); + // 与编辑页当前选中的状态保持一致 + ScriptWaypoint.Mode mode = getInsertMode(); + modeSpinner.setSelection(mode == ScriptWaypoint.Mode.RUN ? 1 : mode == ScriptWaypoint.Mode.BIKE ? 2 : 0); + + // 下拉框切换状态时同步回编辑页的单选按钮,避免两个入口状态不一致 + modeSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view1, int position, long id) { + int radioId; + if (position == 1) { + radioId = R.id.script_mode_run; + } else if (position == 2) { + radioId = R.id.script_mode_bike; + } else { + radioId = R.id.script_mode_walk; + } + ((RadioButton) findViewById(radioId)).setChecked(true); + } + + @Override + public void onNothingSelected(AdapterView parent) { + } + }); + + view.findViewById(R.id.script_map_close).setOnClickListener(v -> dialog.dismiss()); + view.findViewById(R.id.script_map_clear).setOnClickListener(v -> { + mTextEdit.setText(""); + showPreview(); + refreshPickerPoints(); + }); + + dialog.setOnDismissListener(d -> { + try { + mapView.onDetach(); + } catch (Exception e) { + // 地图已经回收时忽略 + } + mPickMap = null; + mPickLine = null; + mPickMarkers.clear(); + }); + } + + /*-------------------------- 选点地图上的路点渲染与编辑 --------------------------*/ + + /** 当前文本按路点解析的结果(带行号,与校验摘要的点数一致) */ + private List currentPickerEntries() { + return ScriptParser.parseLines(mTextEdit.getText().toString(), + mCoordBd09.isChecked(), getModeSpeeds()); + } + + /** 按当前文本重画地图上的路点与连线(增删改 / 拖动之后都要调) */ + private void refreshPickerPoints() { + if (mPickMap == null) { + return; + } + + for (Marker marker : mPickMarkers) { + marker.closeInfoWindow(); + mPickMap.getOverlays().remove(marker); + } + mPickMarkers.clear(); + + List entries = currentPickerEntries(); + List points = new ArrayList<>(); + Drawable baseIcon = ContextCompat.getDrawable(this, R.drawable.icon_gcoding); + + for (int i = 0; i < entries.size(); i++) { + ScriptWaypoint point = entries.get(i).point; + GeoPoint geoPoint = new GeoPoint(point.lat, point.lng); + points.add(geoPoint); + + Marker marker = new Marker(mPickMap); + if (baseIcon != null) { + // 每个 Marker 需要各自持有 Drawable,避免共用一份被回收 + Drawable icon = baseIcon.getConstantState() != null + ? baseIcon.getConstantState().newDrawable() : baseIcon; + marker.setIcon(icon); + } + marker.setAnchor(0.5f, 1.0f); + marker.setPosition(geoPoint); + marker.setTitle(getResources().getString(R.string.script_point_title, i + 1)); + marker.setSnippet(point.mode.label); + marker.setDraggable(true); + + final int entryIndex = i; + marker.setOnMarkerClickListener((m, mv) -> { + showPointOptions(entryIndex); + return true; + }); + marker.setOnMarkerDragListener(new Marker.OnMarkerDragListener() { + @Override + public void onMarkerDragStart(Marker m) { + } + + @Override + public void onMarkerDrag(Marker m) { + } + + @Override + public void onMarkerDragEnd(Marker m) { + GeoPoint dropped = m.getPosition(); + movePickerPoint(entryIndex, dropped.getLongitude(), dropped.getLatitude()); + } + }); + + mPickMap.getOverlays().add(marker); + mPickMarkers.add(marker); + } + + if (mPickLine != null) { + mPickLine.setPoints(points); + } + mPickMap.invalidate(); + } + + /** 把视野缩放到能装下全部路点(对话框布局完成后调一次) */ + private void fitPickerPoints() { + if (mPickMap == null) { + return; + } + + List entries = currentPickerEntries(); + if (entries.isEmpty()) { + return; + } + + double minLat = 90.0; + double maxLat = -90.0; + double minLng = 180.0; + double maxLng = -180.0; + for (ScriptParser.ParsedLine entry : entries) { + minLat = Math.min(minLat, entry.point.lat); + maxLat = Math.max(maxLat, entry.point.lat); + minLng = Math.min(minLng, entry.point.lng); + maxLng = Math.max(maxLng, entry.point.lng); + } + + double centerLat = (minLat + maxLat) / 2.0; + double centerLng = (minLng + maxLng) / 2.0; + mPickMap.getController().setCenter(new GeoPoint(centerLat, centerLng)); + + if (entries.size() == 1) { + return; // 单点保持默认缩放 + } + + int width = Math.max(mPickMap.getWidth(), 480); + int height = Math.max(mPickMap.getHeight(), 320); + double spanLat = (maxLat - minLat) * 110540.0; + double spanLng = (maxLng - minLng) * 111320.0 * Math.cos(Math.toRadians(centerLat)); + double spanMeters = Math.max(Math.max(spanLat, spanLng), 10.0); + // Web 墨卡托的 米/像素 = 156543.03392 * cos(纬度) / 2^层级,反解出能装下全部路点的层级 + double metersPerPixelNeeded = spanMeters / (Math.min(width, height) * 0.8); + double zoom = Math.log(156543.03392 * Math.cos(Math.toRadians(centerLat)) / metersPerPixelNeeded) + / Math.log(2); + zoom = Math.max(3.0, Math.min(19.0, zoom)); + mPickMap.getController().setZoom(zoom); + } + + /** 点已有路点:改状态 / 调顺序 / 删除 */ + private void showPointOptions(final int entryIndex) { + final List entries = currentPickerEntries(); + if (entryIndex < 0 || entryIndex >= entries.size()) { + return; + } + final int lineIndex = entries.get(entryIndex).lineIndex; + + new MaterialAlertDialogBuilder(this) + .setTitle(getResources().getString(R.string.script_point_title, entryIndex + 1)) + .setItems(new String[] { + getResources().getString(R.string.script_point_mode), + getResources().getString(R.string.script_point_up), + getResources().getString(R.string.script_point_down), + getResources().getString(R.string.script_point_delete)}, + (dialog, which) -> { + switch (which) { + case 0: + choosePointMode(lineIndex); + break; + case 1: + movePoint(entryIndex, lineIndex, -1); + break; + case 2: + movePoint(entryIndex, lineIndex, 1); + break; + default: + deletePoint(lineIndex); + break; + } + }) + .setNegativeButton(R.string.input_position_cancel, null) + .show(); + } + + /** 修改某个路点的移动状态;速度原本就是旧状态默认值时,跟着换成新状态的默认值 */ + private void choosePointMode(final int lineIndex) { + ScriptWaypoint current = null; + for (ScriptParser.ParsedLine entry : currentPickerEntries()) { + if (entry.lineIndex == lineIndex) { + current = entry.point; + break; + } + } + if (current == null) { + return; + } + + final ScriptWaypoint point = current; + final String[] labels = { + getResources().getString(R.string.script_mode_walk), + getResources().getString(R.string.script_mode_run), + getResources().getString(R.string.script_mode_bike)}; + + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.script_point_mode) + .setSingleChoiceItems(labels, Math.max(0, point.mode.ordinal()), (dialog, which) -> { + ScriptWaypoint.Mode newMode = modeAt(which); + if (newMode != null && newMode != point.mode) { + String text = ScriptTextEditor.replaceMode( + mTextEdit.getText().toString(), lineIndex, newMode.key); + if (Math.abs(point.speed - getModeSpeed(point.mode)) < 1e-6) { + text = ScriptTextEditor.replaceSpeed(text, lineIndex, getModeSpeed(newMode)); + } + applyPickerText(text, R.string.script_point_updated); + } + dialog.dismiss(); + }) + .setNegativeButton(R.string.input_position_cancel, null) + .show(); + } + + private static ScriptWaypoint.Mode modeAt(int position) { + if (position == 1) { + return ScriptWaypoint.Mode.RUN; + } + if (position == 2) { + return ScriptWaypoint.Mode.BIKE; + } + return ScriptWaypoint.Mode.WALK; + } + + /** 与上 / 下一个路点交换顺序(按路点顺序,注释行留在原地) */ + private void movePoint(int entryIndex, int lineIndex, int direction) { + List entries = currentPickerEntries(); + int other = entryIndex + direction; + if (entryIndex < 0 || entryIndex >= entries.size() || other < 0 || other >= entries.size()) { + return; // 已经在最前 / 最后 + } + + String text = ScriptTextEditor.swapLines(mTextEdit.getText().toString(), + lineIndex, entries.get(other).lineIndex); + applyPickerText(text, R.string.script_point_moved); + } + + /** 删除某个路点(只删它那一行,注释与其它路点不受影响) */ + private void deletePoint(int lineIndex) { + String text = ScriptTextEditor.deleteLine(mTextEdit.getText().toString(), lineIndex); + applyPickerText(text, R.string.script_point_deleted); + } + + /** 拖动结束:把该路点在原文里的坐标换成新位置 */ + private void movePickerPoint(int entryIndex, double lngWgs84, double latWgs84) { + List entries = currentPickerEntries(); + if (entryIndex < 0 || entryIndex >= entries.size()) { + return; + } + + // 脚本声明为 BD-09 时,写回的坐标也要转成 BD-09,保证整份脚本坐标系一致 + double lng = lngWgs84; + double lat = latWgs84; + if (mCoordBd09.isChecked()) { + double[] bd09 = MapUtils.wgs2bd09(lngWgs84, latWgs84); + lng = bd09[0]; + lat = bd09[1]; + } + + String text = ScriptTextEditor.replaceCoords(mTextEdit.getText().toString(), + entries.get(entryIndex).lineIndex, lng, lat); + applyPickerText(text, R.string.script_point_dropped); + } + + /** 用新文本替换编辑框内容(保留光标位置),并同步实时预览、路点列表与地图上的路点 */ + private void applyPickerText(String newText, int messageRes) { + int selection = mTextEdit.getSelectionStart(); + mTextEdit.setText(newText); + int length = mTextEdit.getText().length(); + mTextEdit.setSelection(Math.max(0, Math.min(Math.max(selection, 0), length))); + + showPreview(); + refreshPointList(); + refreshPickerPoints(); + + GoUtils.DisplayToast(this, getResources().getString(messageRes)); + } + + /** 地图初始位置:脚本第一个点 > 上次模拟位置 > 默认点 */ + private GeoPoint firstPointOrLastPosition() { + if (mRoute != null && !mRoute.points.isEmpty()) { + ScriptWaypoint first = mRoute.points.get(0); + return new GeoPoint(first.lat, first.lng); + } + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + String lngText = preferences.getString("service_last_lng", null); + String latText = preferences.getString("service_last_lat", null); + if (!TextUtils.isEmpty(lngText) && !TextUtils.isEmpty(latText)) { + try { + return new GeoPoint(Double.parseDouble(latText), Double.parseDouble(lngText)); + } catch (NumberFormatException ignored) { + // 落到默认位置 + } + } + + // 与 ServiceGo 的默认位置保持一致 + return new GeoPoint(com.zcshou.service.ServiceGo.DEFAULT_LAT, com.zcshou.service.ServiceGo.DEFAULT_LNG); + } + + /*============================== 参数读取 ==============================*/ + + private double getSettingAltitude() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + return parseDouble(preferences.getString("setting_altitude", "55.0"), 55.0); + } + + private double[] getModeSpeeds() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + return ScriptParser.modeSpeeds( + parseDouble(preferences.getString("setting_walk", "1.2"), 1.2), + parseDouble(preferences.getString("setting_run", "3.6"), 3.6), + parseDouble(preferences.getString("setting_bike", "10.0"), 10.0)); + } + + private double getModeSpeed(ScriptWaypoint.Mode mode) { + double[] speeds = getModeSpeeds(); + return speeds[Math.max(0, Math.min(speeds.length - 1, mode.ordinal()))]; + } + + private static double parseDouble(String value, double defaultValue) { + if (value == null) { + return defaultValue; + } + + try { + return Double.parseDouble(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } +} diff --git a/app/src/main/java/com/zcshou/gogogo/WelcomeActivity.java b/app/src/main/java/com/zcshou/gogogo/WelcomeActivity.java index 8d822a57..664466d5 100644 --- a/app/src/main/java/com/zcshou/gogogo/WelcomeActivity.java +++ b/app/src/main/java/com/zcshou/gogogo/WelcomeActivity.java @@ -2,10 +2,10 @@ import android.Manifest; import android.annotation.SuppressLint; -import android.app.AlertDialog; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; +import android.os.Build; import android.os.Bundle; import android.text.Spannable; import android.text.SpannableStringBuilder; @@ -22,9 +22,11 @@ import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.preference.PreferenceManager; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.zcshou.utils.GoUtils; import java.util.ArrayList; @@ -38,6 +40,13 @@ public class WelcomeActivity extends AppCompatActivity { private static final int SDK_PERMISSION_REQUEST = 127; private static final ArrayList ReqPermissions = new ArrayList<>(); + /* + * 通知权限(POST_NOTIFICATIONS)与 Android 13(API 33)都是 API 33 才引入的符号, + * 而本工程的 compileSdk 仍是 32,直接引用会编译不过,所以这里用常量/字符串字面量代替。 + */ + private static final int ANDROID_13_API_LEVEL = 33; + private static final String PERMISSION_POST_NOTIFICATIONS = "android.permission.POST_NOTIFICATIONS"; + private CheckBox checkBox; private Boolean mAgreement; private Boolean mPrivacy; @@ -80,19 +89,35 @@ protected void onDestroy() { @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { if (requestCode == SDK_PERMISSION_REQUEST) { - for (int i = 0; i < ReqPermissions.size(); i++) { - if (grantResults[i] != PackageManager.PERMISSION_GRANTED) { - GoUtils.DisplayToast(this, getResources().getString(R.string.app_error_permission)); - return; - } + // 只有定位属于必要权限,通知等权限被拒绝不应该阻塞进入应用; + // 这里也不再按下标访问 grantResults(顺序不保证,被系统直接拒绝时还可能为空) + if (hasLocationPermission()) { + isPermission = true; + } else { + isPermission = false; + GoUtils.DisplayToast(this, getResources().getString(R.string.app_error_permission)); + GoUtils.showPermissionSettingsDialog(this, getResources().getString(R.string.app_error_permission)); } - isPermission = true; } super.onRequestPermissionsResult(requestCode, permissions, grantResults); } + /** 是否已获得定位权限(精确 / 粗略任意一个即可) */ + private boolean hasLocationPermission() { + return checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + || checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED; + } + private void checkDefaultPermissions() { - // 定位精确位置 + // 注意:ReqPermissions 是静态集合,每次申请前必须清空,否则会不断累加 + ReqPermissions.clear(); + + /* + * 只申请必要权限: + * 1. 定位:地图定位图层、“跳转经纬度/地址”后把视野移到该点都需要; + * 2. 通知(Android 13+):前台服务通知上挂着“显示摇杆/隐藏摇杆”,属于可选权限,拒绝不阻塞。 + * 存储、电话状态、读取日志等权限本应用用不到,已从清单中移除。 + */ if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { ReqPermissions.add(Manifest.permission.ACCESS_FINE_LOCATION); } @@ -101,18 +126,12 @@ private void checkDefaultPermissions() { ReqPermissions.add(Manifest.permission.ACCESS_COARSE_LOCATION); } - /* - * 读写权限和电话状态权限非必要权限(建议授予)只会申请一次,用户同意或者禁止,只会弹一次 - */ - // 读写权限 - if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { - ReqPermissions.add(Manifest.permission.READ_EXTERNAL_STORAGE); + if (Build.VERSION.SDK_INT >= ANDROID_13_API_LEVEL + && checkSelfPermission(PERMISSION_POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + ReqPermissions.add(PERMISSION_POST_NOTIFICATIONS); } - // 读取电话状态权限 - if (checkSelfPermission(Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) { - ReqPermissions.add(Manifest.permission.READ_PHONE_STATE); - } + isPermission = hasLocationPermission(); if (ReqPermissions.isEmpty()) { isPermission = true; @@ -137,7 +156,8 @@ private void startMainActivity() { return; } - if (isPermission) { + if (isPermission || hasLocationPermission()) { + isPermission = true; Intent intent = new Intent(WelcomeActivity.this, MainActivity.class); startActivity(intent); WelcomeActivity.this.finish(); @@ -163,7 +183,7 @@ private void doAcceptation() { } private void showAgreementDialog() { - final AlertDialog alertDialog = new AlertDialog.Builder(this).create(); + final AlertDialog alertDialog = new MaterialAlertDialogBuilder(this).create(); alertDialog.show(); alertDialog.setCancelable(false); Window window = alertDialog.getWindow(); @@ -199,7 +219,7 @@ private void showAgreementDialog() { } private void showPrivacyDialog() { - final AlertDialog alertDialog = new AlertDialog.Builder(this).create(); + final AlertDialog alertDialog = new MaterialAlertDialogBuilder(this).create(); alertDialog.show(); alertDialog.setCancelable(false); Window window = alertDialog.getWindow(); diff --git a/app/src/main/java/com/zcshou/joystick/JoyStick.java b/app/src/main/java/com/zcshou/joystick/JoyStick.java index 859b9efc..2ee0b4d5 100644 --- a/app/src/main/java/com/zcshou/joystick/JoyStick.java +++ b/app/src/main/java/com/zcshou/joystick/JoyStick.java @@ -6,6 +6,7 @@ import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.graphics.PixelFormat; +import android.graphics.drawable.Drawable; import android.text.TextUtils; import android.util.AttributeSet; import android.util.Log; @@ -22,24 +23,25 @@ import android.widget.TextView; import android.widget.SearchView; +import androidx.core.content.ContextCompat; import androidx.preference.PreferenceManager; -import com.baidu.mapapi.map.BaiduMap; -import com.baidu.mapapi.map.MapPoi; -import com.baidu.mapapi.map.MapStatus; -import com.baidu.mapapi.map.MapStatusUpdateFactory; -import com.baidu.mapapi.map.MapView; -import com.baidu.mapapi.map.MarkerOptions; -import com.baidu.mapapi.map.MyLocationData; -import com.baidu.mapapi.model.LatLng; -import com.baidu.mapapi.search.sug.SuggestionSearch; -import com.baidu.mapapi.search.sug.SuggestionSearchOption; +import org.osmdroid.events.MapEventsReceiver; +import org.osmdroid.util.GeoPoint; +import org.osmdroid.views.MapView; +import org.osmdroid.views.overlay.MapEventsOverlay; +import org.osmdroid.views.overlay.Marker; + import com.zcshou.database.DataBaseHistoryLocation; import com.zcshou.gogogo.HistoryActivity; import com.zcshou.gogogo.MainActivity; import com.zcshou.gogogo.R; import com.zcshou.utils.GoUtils; import com.zcshou.utils.MapUtils; +import com.zcshou.utils.OsmGeocoder; +import com.zcshou.utils.TileSourceUtils; + +import okhttp3.OkHttpClient; import java.math.BigDecimal; import java.math.RoundingMode; @@ -77,6 +79,11 @@ public class JoyStick extends View { private double mR = 0; private double disLng = 0; private double disLat = 0; + /** 方向输入控件(圆形摇杆 / 方向按键),点击移动开启时收起 */ + private RockerView mRockerView; + private ButtonView mButtonView; + /** 点击移动是否生效(与摇杆方向输入为切换关系) */ + private boolean mClickMoveEnabled; private final SharedPreferences sharedPreferences; /* 历史记录悬浮窗相关 */ private FrameLayout mHistoryLayout; @@ -86,61 +93,50 @@ public class JoyStick extends View { /* 地图悬浮窗相关 */ private FrameLayout mMapLayout; private MapView mMapView; - private BaiduMap mBaiduMap; - private LatLng mCurMapLngLat; - private LatLng mMarkMapLngLat; - private SuggestionSearch mSuggestionSearch; + /** 当前位置,BD-09(保持原工程内部坐标语义,绘制时转 WGS-84) */ + private GeoPoint mCurMapLngLat; + /** 当前选点,BD-09 */ + private GeoPoint mMarkMapLngLat; + private Marker mCurMarker; + private Marker mMarkMarker; + private OsmGeocoder mGeoCoder; private ListView mSearchList; private LinearLayout mSearchLayout; public JoyStick(Context context) { super(context); - this.mContext = context; - - sharedPreferences = PreferenceManager.getDefaultSharedPreferences(mContext); - - initWindowManager(); - - inflater = LayoutInflater.from(mContext); - - if (inflater != null) { - initJoyStickView(); - - initJoyStickMapView(); - - initHistoryView(); - } + mContext = context; + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + inflater = LayoutInflater.from(context); + initUi(); } public JoyStick(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); - this.mContext = context; - - sharedPreferences = PreferenceManager.getDefaultSharedPreferences(mContext); - - initWindowManager(); - - inflater = LayoutInflater.from(mContext); - - if (inflater != null) { - initJoyStickView(); - - initJoyStickMapView(); - - initHistoryView(); - } + mContext = context; + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + inflater = LayoutInflater.from(context); + initUi(); } public JoyStick(Context context, AttributeSet attrs) { super(context, attrs); - this.mContext = context; - - sharedPreferences = PreferenceManager.getDefaultSharedPreferences(mContext); + mContext = context; + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + inflater = LayoutInflater.from(context); + initUi(); + } + /** + * 三个构造函数共用的初始化。 + * + *

mContext / inflater / sharedPreferences 是 final 字段,只能在构造器里赋值, + * 所以那三行留在各个构造器中;剩下的“建窗口 → 建三个悬浮视图”完全相同, + * 抽到这里,以后改初始化顺序只改一处。

+ */ + private void initUi() { initWindowManager(); - inflater = LayoutInflater.from(mContext); - if (inflater != null) { initJoyStickView(); @@ -152,10 +148,10 @@ public JoyStick(Context context, AttributeSet attrs) { public void setCurrentPosition(double lng, double lat, double alt) { double[] lngLat = MapUtils.wgs2bd09(lng, lat); - mCurMapLngLat = new LatLng(lngLat[1], lngLat[0]); + mCurMapLngLat = new GeoPoint(lngLat[1], lngLat[0]); mAltitude = alt; - resetBaiduMap(); + resetMap(); } public void show() { @@ -168,7 +164,7 @@ public void show() { mWindowManager.removeView(mHistoryLayout); } if (mMapLayout.getParent() == null) { - resetBaiduMap(); + resetMap(); mWindowManager.addView(mMapLayout, mWindowParamCurrent); } break; @@ -224,8 +220,9 @@ public void destroy() { mWindowManager.removeViewImmediate(mHistoryLayout); } - mBaiduMap.setMyLocationEnabled(false); - mMapView.onDestroy(); + if (mMapView != null) { + mMapView.onDetach(); + } } public void setListener(JoyStickClickListener mListener) { @@ -352,24 +349,63 @@ public void onFinish() { } }); /* 方向键点击处理 */ - RockerView rckView = mJoystickLayout.findViewById(R.id.joystick_rocker); - rckView.setListener(this::processDirection); + mRockerView = mJoystickLayout.findViewById(R.id.joystick_rocker); + mRockerView.setListener(this::processDirection); /* 方向键点击处理 */ - ButtonView btnView = mJoystickLayout.findViewById(R.id.joystick_button); - btnView.setListener(this::processDirection); - - /* 这里用来决定摇杆类型 */ - if (sharedPreferences.getString("setting_joystick_type", "0").equals("0")) { - rckView.setVisibility(VISIBLE); - btnView.setVisibility(GONE); + mButtonView = mJoystickLayout.findViewById(R.id.joystick_button); + mButtonView.setListener(this::processDirection); + + /* 这里用来决定移动类型(“移动类型”设置:0/2 圆形摇杆,1 方向按键;2=点击移动时先显示摇杆, + 服务连上后 setClickMoveEnabled 会按开关状态收起方向输入) */ + if (sharedPreferences.getString("setting_joystick_type", "0").equals("1")) { + mRockerView.setVisibility(GONE); + mButtonView.setVisibility(VISIBLE); } else { - rckView.setVisibility(GONE); - btnView.setVisibility(VISIBLE); + mRockerView.setVisibility(VISIBLE); + mButtonView.setVisibility(GONE); + } + } + + /** 摇杆当前选择的行走速度(走 / 跑 / 骑按钮的结果),点击移动按它走 */ + public double getCurrentSpeed() { + return mSpeed; + } + + /** + * 点击移动开关:开启时收起方向摇杆(点击与摇杆是切换关系), + * 行走方式按钮保留——点击移动的行走速度取的正是这里的选择。 + */ + public void setClickMoveEnabled(boolean enabled) { + mClickMoveEnabled = enabled; + + // 无论开关方向都先把移动计时器停掉:控件可能在按住途中被收起, + // 收不到“抬起”事件,留着计时器会变成无人触碰也在移动的幽灵位移 + if (mTimer != null) { + mTimer.cancel(); + } + isMove = false; + + boolean useRocker = !"1".equals(sharedPreferences.getString("setting_joystick_type", "0")); + + if (mRockerView != null) { + mRockerView.setVisibility(enabled ? GONE : (useRocker ? VISIBLE : GONE)); + } + if (mButtonView != null) { + mButtonView.setVisibility(enabled ? GONE : (useRocker ? GONE : VISIBLE)); } } private void processDirection(boolean auto, double angle, double r) { + // 点击移动开启时方向输入整体无效(两者为切换关系)。 + // 顺带取消移动计时器:否则“手指还按着时切换模式导致控件收起、收不到抬起事件”, + // 计时器会一直空转,等切回摇杆就会在无人触碰时自己动(幽灵位移) + if (mClickMoveEnabled) { + mTimer.cancel(); + isMove = false; + return; + } + if (r <= 0) { mTimer.cancel(); isMove = false; @@ -438,43 +474,14 @@ private void initJoyStickMapView() { mSearchList = mMapLayout.findViewById(R.id.map_search_list_view); mSearchLayout = mMapLayout.findViewById(R.id.map_search_linear); - mSuggestionSearch = SuggestionSearch.newInstance(); - mSuggestionSearch.setOnGetSuggestionResultListener(suggestionResult -> { - if (suggestionResult == null || suggestionResult.getAllSuggestions() == null) { - GoUtils.DisplayToast(mContext,getResources().getString(R.string.app_search_null)); - } else { - List> data = new ArrayList<>(); - int retCnt = suggestionResult.getAllSuggestions().size(); - - for (int i = 0; i < retCnt; i++) { - if (suggestionResult.getAllSuggestions().get(i).pt == null) { - continue; - } - - Map poiItem = new HashMap<>(); - poiItem.put(MainActivity.POI_NAME, suggestionResult.getAllSuggestions().get(i).key); - poiItem.put(MainActivity.POI_ADDRESS, suggestionResult.getAllSuggestions().get(i).city + " " + suggestionResult.getAllSuggestions().get(i).district); - poiItem.put(MainActivity.POI_LONGITUDE, "" + suggestionResult.getAllSuggestions().get(i).pt.longitude); - poiItem.put(MainActivity.POI_LATITUDE, "" + suggestionResult.getAllSuggestions().get(i).pt.latitude); - data.add(poiItem); - } - - SimpleAdapter simAdapt = new SimpleAdapter( - mContext, - data, - R.layout.search_poi_item, - new String[] {MainActivity.POI_NAME, MainActivity.POI_ADDRESS, MainActivity.POI_LONGITUDE, MainActivity.POI_LATITUDE}, // 与下面数组元素要一一对应 - new int[] {R.id.poi_name, R.id.poi_address, R.id.poi_longitude, R.id.poi_latitude}); - mSearchList.setAdapter(simAdapt); - mSearchLayout.setVisibility(View.VISIBLE); - } - }); + // 地点检索改用 OpenStreetMap Nominatim(替代百度 SuggestionSearch) + mGeoCoder = new OsmGeocoder(new OkHttpClient()); mSearchList.setOnItemClickListener((parent, view, position, id) -> { mSearchLayout.setVisibility(View.GONE); String lng = ((TextView) view.findViewById(R.id.poi_longitude)).getText().toString(); String lat = ((TextView) view.findViewById(R.id.poi_latitude)).getText().toString(); - markBaiduMap(new LatLng(Double.parseDouble(lat), Double.parseDouble(lng))); + markMap(new GeoPoint(Double.parseDouble(lat), Double.parseDouble(lng))); }); TextView tips = mMapLayout.findViewById(R.id.joystick_map_tips); @@ -508,10 +515,7 @@ public boolean onQueryTextSubmit(String query) { public boolean onQueryTextChange(String newText) { if (newText != null && newText.length() > 0) { try { - mSuggestionSearch.requestSuggestion((new SuggestionSearchOption()) - .keyword(newText) - .city(MainActivity.mCurrentCity) - ); + doSearch(newText); } catch (Exception e) { GoUtils.DisplayToast(mContext,getResources().getString(R.string.app_error_search)); e.printStackTrace(); @@ -543,10 +547,10 @@ public boolean onQueryTextChange(String newText) { mCurMapLngLat = mMarkMapLngLat; mMarkMapLngLat = null; - double[] lngLat = MapUtils.bd2wgs(mCurMapLngLat.longitude, mCurMapLngLat.latitude); + double[] lngLat = MapUtils.bd2wgs(mCurMapLngLat.getLongitude(), mCurMapLngLat.getLatitude()); mListener.onPositionInfo(lngLat[0], lngLat[1], mAltitude); - resetBaiduMap(); + resetMap(); GoUtils.DisplayToast(mContext, getResources().getString(R.string.app_location_ok)); } @@ -571,86 +575,178 @@ public boolean onQueryTextChange(String newText) { }); ImageButton btnBack = mMapLayout.findViewById(R.id.btnBack); - btnBack.setOnClickListener(v -> resetBaiduMap()); + btnBack.setOnClickListener(v -> resetMap()); btnBack.setColorFilter(getResources().getColor(R.color.colorAccent, mContext.getTheme())); - initBaiduMap(); + initOsmMap(); } - private void initBaiduMap() { + private void initOsmMap() { mMapView = mMapLayout.findViewById(R.id.map_joystick); - mMapView.showZoomControls(false); - mBaiduMap = mMapView.getMap(); - mBaiduMap.setMapType(BaiduMap.MAP_TYPE_NORMAL); - mBaiduMap.setMyLocationEnabled(true); - - mBaiduMap.setOnMapTouchListener(event -> { + mMapView.setBuiltInZoomControls(false); + mMapView.setMultiTouchControls(true); + mMapView.setTilesScaledToDpi(true); + // 默认使用 OpenStreetMap,与主界面保持一致 + mMapView.setTileSource(TileSourceUtils.OSM_STANDARD); + mMapView.getController().setZoom(18.0); + + // 当前位置标记:对应原实现里通过 setMyLocationData 显示的“我的位置” + mCurMarker = createMarker(ContextCompat.getDrawable(mContext, R.drawable.ic_home_position), 0.5f); + if (mCurMarker != null) { + mMapView.getOverlays().add(mCurMarker); + } - }); + // 选点标记 + mMarkMarker = createMarker(ContextCompat.getDrawable(mContext, MainActivity.MAP_INDICATOR_RES), 1.0f); + if (mMarkMarker != null) { + mMapView.getOverlays().add(mMarkMarker); + } - mBaiduMap.setOnMapClickListener(new BaiduMap.OnMapClickListener() { + // 地图单击 / 长按(回调坐标为 WGS-84)。双击缩放由 osmdroid 内置处理。 + mMapView.getOverlays().add(new MapEventsOverlay(new MapEventsReceiver() { /** * 单击地图 */ @Override - public void onMapClick(LatLng point) { - markBaiduMap(point); + public boolean singleTapConfirmedHelper(GeoPoint p) { + markMap(wgs84ToBd09(p.getLongitude(), p.getLatitude())); + return true; } /** - * 单击地图中的POI点 + * 长按地图 */ @Override - public void onMapPoiClick(MapPoi poi) { - markBaiduMap(poi.getPosition()); + public boolean longPressHelper(GeoPoint p) { + markMap(wgs84ToBd09(p.getLongitude(), p.getLatitude())); + return true; } - }); + })); + } - mBaiduMap.setOnMapLongClickListener(new BaiduMap.OnMapLongClickListener() { - /** - * 长按地图 - */ - @Override - public void onMapLongClick(LatLng point) { - markBaiduMap(point); + /** + * 创建地图标记。osmdroid 的 Marker 需要各自持有一份 Drawable,所以每次从 ConstantState 新建。 + * + * @param anchorV 图标垂直锚点,1.0f 表示图标底部对准坐标(图钉样式) + */ + private Marker createMarker(Drawable drawable, float anchorV) { + if (mMapView == null || drawable == null) { + return null; + } + Drawable icon = drawable; + if (drawable.getConstantState() != null) { + icon = drawable.getConstantState().newDrawable(); + } + Marker marker = new Marker(mMapView); + marker.setIcon(icon); + marker.setAnchor(0.5f, anchorV); + return marker; + } + + private void resetMap() { + if (mMapView == null) { + return; + } + + // 清除选点:osmdroid 的 Marker 不接受 null 坐标(内部会做位置运算,直接 NPE), + // 这里把标记从图层里摘掉,选点时再放回去 + clearMark(); + + if (mCurMapLngLat != null) { + GeoPoint wgs84 = bd09ToWgs84(mCurMapLngLat); + if (mCurMarker != null) { + mCurMarker.setPosition(wgs84); } - }); + mMapView.getController().setZoom(18.0); + mMapView.getController().animateTo(wgs84); + } - mBaiduMap.setOnMapDoubleClickListener(new BaiduMap.OnMapDoubleClickListener() { - /** - * 双击地图 - */ - @Override - public void onMapDoubleClick(LatLng point) { - markBaiduMap(point); + mMapView.invalidate(); + } + + private void markMap(GeoPoint bd09Point) { + if (mMapView == null || bd09Point == null) { + return; + } + + mMarkMapLngLat = bd09Point; + GeoPoint wgs84 = bd09ToWgs84(bd09Point); + + if (mMarkMarker != null) { + mMarkMarker.setEnabled(true); + if (!mMapView.getOverlays().contains(mMarkMarker)) { + mMapView.getOverlays().add(mMarkMarker); } - }); + mMarkMarker.setPosition(wgs84); + } + + mMapView.getController().setZoom(18.0); + mMapView.getController().animateTo(wgs84); + mMapView.invalidate(); } - private void resetBaiduMap() { - mBaiduMap.clear(); + /** 摘掉选点标记(不销毁对象,选点时再放回去) */ + private void clearMark() { + if (mMapView == null || mMarkMarker == null) { + return; + } + + try { + mMarkMarker.closeInfoWindow(); + mMarkMarker.setEnabled(false); + mMapView.getOverlays().remove(mMarkMarker); + } catch (Exception e) { + Log.e("JOYSTICK", "ERROR - clearMark"); + } + } - MyLocationData locData = new MyLocationData.Builder() - .latitude(mCurMapLngLat.latitude) - .longitude(mCurMapLngLat.longitude) - .build(); - mBaiduMap.setMyLocationData(locData); + /** + * BD-09 -> WGS-84(osmdroid 的瓦片是 WGS-84) + */ + private static GeoPoint bd09ToWgs84(GeoPoint bd09) { + double[] wgs84 = MapUtils.bd2wgs(bd09.getLongitude(), bd09.getLatitude()); + return new GeoPoint(wgs84[1], wgs84[0]); + } - MapStatus.Builder builder = new MapStatus.Builder(); - builder.target(mCurMapLngLat).zoom(18.0f); - mBaiduMap.animateMapStatus(MapStatusUpdateFactory.newMapStatus(builder.build())); + /** + * WGS-84 -> BD-09(保持工程内部原有的坐标语义) + */ + private static GeoPoint wgs84ToBd09(double lng, double lat) { + double[] bd09 = MapUtils.wgs2bd09(lng, lat); + return new GeoPoint(bd09[1], bd09[0]); } - private void markBaiduMap(LatLng latLng) { - mMarkMapLngLat = latLng; + /** + * 关键字检索(OpenStreetMap Nominatim),结果统一转成 BD-09 后再填入列表 + */ + private void doSearch(String keyword) { + mGeoCoder.search(keyword, pois -> { + if (pois == null || pois.isEmpty()) { + GoUtils.DisplayToast(mContext, getResources().getString(R.string.app_search_null)); + return; + } + + List> data = new ArrayList<>(); + for (OsmGeocoder.Poi poi : pois) { + double[] bd09 = MapUtils.wgs2bd09(poi.longitude, poi.latitude); - MarkerOptions ooA = new MarkerOptions().position(latLng).icon(MainActivity.mMapIndicator); - mBaiduMap.clear(); - mBaiduMap.addOverlay(ooA); + Map poiItem = new HashMap<>(); + poiItem.put(MainActivity.POI_NAME, poi.name); + poiItem.put(MainActivity.POI_ADDRESS, poi.address); + poiItem.put(MainActivity.POI_LONGITUDE, "" + bd09[0]); + poiItem.put(MainActivity.POI_LATITUDE, "" + bd09[1]); + data.add(poiItem); + } - MapStatus.Builder builder = new MapStatus.Builder(); - builder.target(latLng).zoom(18.0f); - mBaiduMap.animateMapStatus(MapStatusUpdateFactory.newMapStatus(builder.build())); + SimpleAdapter simAdapt = new SimpleAdapter( + mContext, + data, + R.layout.search_poi_item, + new String[] {MainActivity.POI_NAME, MainActivity.POI_ADDRESS, MainActivity.POI_LONGITUDE, MainActivity.POI_LATITUDE}, // 与下面数组元素要一一对应 + new int[] {R.id.poi_name, R.id.poi_address, R.id.poi_longitude, R.id.poi_latitude}); + mSearchList.setAdapter(simAdapt); + mSearchLayout.setVisibility(View.VISIBLE); + }); } @@ -737,7 +833,7 @@ public boolean onQueryTextChange(String newText) {// 当搜索内容改变时触 String[] bdLatLngStr = bdLatLng.split(" "); String bdLongitude = bdLatLngStr[0].substring(bdLatLngStr[0].indexOf(':') + 1); String bdLatitude = bdLatLngStr[1].substring(bdLatLngStr[1].indexOf(':') + 1); - mCurMapLngLat = new LatLng(Double.parseDouble(bdLatitude), Double.parseDouble(bdLongitude)); + mCurMapLngLat = new GeoPoint(Double.parseDouble(bdLatitude), Double.parseDouble(bdLongitude)); GoUtils.DisplayToast(mContext, getResources().getString(R.string.app_location_ok)); }); diff --git a/app/src/main/java/com/zcshou/script/ScriptParser.java b/app/src/main/java/com/zcshou/script/ScriptParser.java new file mode 100644 index 00000000..49160f16 --- /dev/null +++ b/app/src/main/java/com/zcshou/script/ScriptParser.java @@ -0,0 +1,573 @@ +package com.zcshou.script; + +import com.zcshou.utils.MapUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 脚本文本解析器。 + * + *

脚本格式:一行一个路点,格式为 + * {@code 状态 [经度] [纬度] [速度] [停留秒数]},用空格或逗号分隔,例如:

+ * + *
+ * # 以 # 或 // 开头的是注释,空行会被忽略
+ * walk 116.397,39.908         步行(速度取设置里的步行速度)
+ * run  116.399,39.909 4.5     跑步,速度 4.5 米/秒
+ * bike 116.405,39.910 20km/h  骑行,速度 20 公里/小时
+ * walk 116.410 39.910 1.2 10  步行 1.2 米/秒,到达后原地停留 10 秒
+ * 
+ * + *

坐标默认按 WGS-84 解析(与手机系统定位一致),也可以按 BD-09 解析, + * 但需要显式指定,因为两套坐标的值域完全重叠、无法自动识别。

+ */ +public class ScriptParser { + /** 记忆“脚本里的经纬度是否为 BD-09”,编辑页与服务端解析共用 */ + public static final String KEY_SCRIPT_FROM_BD09 = "script_from_bd09"; + + /** 脚本里可以使用的状态关键字 */ + private static final String[] MODE_KEYWORDS = { + "walk", "run", "bike", "cycle", "riding", "walking", "running", + "步行", "走路", "行走", "跑步", "奔跑", "骑行", "骑车", "单车", "自行车" + }; + + /** 速度标签:标签后面第一个数字优先当作速度,避免和坐标混淆 */ + private static final String[] SPEED_LABELS = {"速度", "speed", "v="}; + /** 停留标签 */ + private static final String[] WAIT_LABELS = {"停留", "等待", "wait", "pause"}; + + private static final Pattern NUMBER_PATTERN = Pattern.compile("-?\\d+(?:\\.\\d+)?"); + private static final Pattern LEADING_MODE_PATTERN = Pattern.compile("^[A-Za-z\\u4e00-\\u9fa5]+"); + + private ScriptParser() { + } + + /** 解析结果:成功时 route 可用,失败时 error 里是给用户看的原因 */ + public static class ParseResult { + public ScriptRoute route; + public final List warnings = new ArrayList<>(); + public String error; + + public boolean isOk() { + return route != null && error == null; + } + + /** 校验信息:错误优先,其次是警告 */ + public String getMessage() { + StringBuilder builder = new StringBuilder(); + if (error != null) { + builder.append(error).append('\n'); + } + for (String warning : warnings) { + builder.append(warning).append('\n'); + } + return builder.toString().trim(); + } + } + + /** + * 解析脚本文本。 + * + * @param text 脚本内容 + * @param fromBd09 文本里的经纬度是否为 BD-09 坐标系 + * @param modeSpeeds 各状态在“设置”里的默认速度,长度需与 {@link ScriptWaypoint.Mode#values()} 一致 + */ + public static ParseResult parse(String text, boolean fromBd09, double[] modeSpeeds) { + ParseResult result = new ParseResult(); + + if (text == null || text.trim().isEmpty()) { + result.error = "脚本内容为空,请先写入路点"; + return result; + } + + ScriptRoute route = new ScriptRoute(); + route.text = text; + + String[] lines = text.split("\r\n|\r|\n"); + for (int i = 0; i < lines.length; i++) { + int lineNo = i + 1; + LineParse lineParse = parseLine(lines[i], fromBd09, modeSpeeds); + + if (lineParse.skip) { + continue; + } + if (lineParse.error != null) { + result.error = "第 " + lineNo + " 行:" + lineParse.error; + return result; + } + if (lineParse.warning != null) { + result.warnings.add("第 " + lineNo + " 行:" + lineParse.warning); + } + + ScriptWaypoint point = lineParse.point; + if (point == null) { + continue; + } + + // 相邻完全重合的点没有意义,直接忽略(只保留最后一个状态) + if (!route.points.isEmpty()) { + ScriptWaypoint last = route.points.get(route.points.size() - 1); + if (almostSame(last.lng, point.lng) && almostSame(last.lat, point.lat)) { + if (last.mode != point.mode || Math.abs(last.speed - point.speed) > 1e-6) { + route.points.set(route.points.size() - 1, point); + result.warnings.add("第 " + lineNo + " 行:与上一个路点位置重复,已按新状态覆盖"); + } else { + result.warnings.add("第 " + lineNo + " 行:与上一个路点位置重复,已忽略"); + } + continue; + } + } + + route.points.add(point); + } + + if (route.points.isEmpty()) { + result.error = "没有解析到任何路点,请检查脚本格式"; + return result; + } + + result.route = route; + return result; + } + + private static boolean almostSame(double a, double b) { + return Math.abs(a - b) < 1e-9; + } + + /*============================== 带行号的解析(供地图选点) ==============================*/ + + /** + * 带行号的路点:行号从 0 开始,用来把“地图上的第 N 个路点”映射回脚本原文的那一行, + * 地图选点里删除 / 改状态 / 调顺序都只动这一行。 + */ + public static class ParsedLine { + /** 该路点在脚本原文里的行号(0 起) */ + public final int lineIndex; + /** 解析出的路点(WGS-84) */ + public final ScriptWaypoint point; + + public ParsedLine(int lineIndex, ScriptWaypoint point) { + this.lineIndex = lineIndex; + this.point = point; + } + } + + /** + * 逐行解析并保留行号:注释 / 空行跳过、相邻重合点的合并规则与 {@link #parse} 完全一致, + * 解析失败的行直接忽略(错误提示交给 {@link #parse} 负责)。 + * + * @return 按路点顺序排列的条目,lineIndex 可直接定位到原文 + */ + public static List parseLines(String text, boolean fromBd09, double[] modeSpeeds) { + List entries = new ArrayList<>(); + if (text == null || text.trim().isEmpty()) { + return entries; + } + + String[] lines = text.split("\r\n|\r|\n"); + for (int i = 0; i < lines.length; i++) { + LineParse lineParse = parseLine(lines[i], fromBd09, modeSpeeds); + if (lineParse.skip || lineParse.error != null || lineParse.point == null) { + continue; + } + + ScriptWaypoint point = lineParse.point; + // 与 parse() 相同的“相邻完全重合”合并规则,保证地图上的点数和校验摘要一致 + if (!entries.isEmpty()) { + ParsedLine lastEntry = entries.get(entries.size() - 1); + if (almostSame(lastEntry.point.lng, point.lng) && almostSame(lastEntry.point.lat, point.lat)) { + if (lastEntry.point.mode != point.mode + || Math.abs(lastEntry.point.speed - point.speed) > 1e-6) { + entries.set(entries.size() - 1, new ParsedLine(i, point)); + } + continue; + } + } + + entries.add(new ParsedLine(i, point)); + } + + return entries; + } + + /** 单行解析的中间结果 */ + private static class LineParse { + ScriptWaypoint point; + String error; + String warning; + boolean skip; + } + + private static LineParse parseLine(String rawLine, boolean fromBd09, double[] modeSpeeds) { + LineParse lineParse = new LineParse(); + + String line = rawLine == null ? "" : rawLine.trim(); + if (line.isEmpty() || line.startsWith("#") || line.startsWith("//") || line.startsWith(";")) { + lineParse.skip = true; + return lineParse; + } + + // 去掉行尾注释 + int commentIndex = line.indexOf('#'); + if (commentIndex > 0) { + line = line.substring(0, commentIndex).trim(); + } + if (line.isEmpty()) { + lineParse.skip = true; + return lineParse; + } + + // 1) 状态关键字 + ScriptWaypoint.Mode mode = null; + String leading = null; + Matcher leadingMatcher = LEADING_MODE_PATTERN.matcher(line); + if (leadingMatcher.find()) { + leading = leadingMatcher.group(); + mode = ScriptWaypoint.Mode.parse(leading); + } + if (mode == null) { + lineParse.error = "缺少移动状态,请以 步行/跑步/骑行(walk/run/bike)开头"; + return lineParse; + } + + // 2) 标签指定的速度 / 停留时间(标签写法优先,避免和坐标混淆) + String afterMode = line.substring(leading.length()); + double labeledSpeed = findLabeledNumber(afterMode, SPEED_LABELS); + double labeledWait = findLabeledNumber(afterMode, WAIT_LABELS); + + // 3) 剩余数字:前两个是坐标,之后依次是速度、停留时间 + List numbers = new ArrayList<>(); + Matcher numberMatcher = NUMBER_PATTERN.matcher(afterMode); + while (numberMatcher.find()) { + try { + numbers.add(Double.parseDouble(numberMatcher.group())); + } catch (NumberFormatException e) { + lineParse.error = "数字格式不正确:" + numberMatcher.group(); + return lineParse; + } + } + + if (numbers.size() < 2) { + lineParse.error = "缺少经纬度,格式为:状态 经度 纬度 [速度] [停留秒数]"; + return lineParse; + } + + double lng = numbers.get(0); + double lat = numbers.get(1); + + if (fromBd09) { + double[] wgs84 = MapUtils.bd2wgs(lng, lat); + lng = wgs84[0]; + lat = wgs84[1]; + } + + if (lat > 90.0 || lat < -90.0) { + lineParse.error = "纬度超出范围(-90 ~ 90):" + lat; + return lineParse; + } + if (lng > 180.0 || lng < -180.0) { + lineParse.error = "经度超出范围(-180 ~ 180):" + lng; + return lineParse; + } + + double speed = 0; + if (labeledSpeed > 0) { + speed = labeledSpeed; + } else if (numbers.size() > 2) { + speed = numbers.get(2); + } + if (speed < 0) { + speed = 0; + } + + // 公里/小时写法换算成米/秒 + String lower = afterMode.toLowerCase(Locale.US); + if (speed > 0 && (lower.contains("km/h") || lower.contains("kmh") || lower.contains("千米/时") + || lower.contains("公里/时") || lower.contains("码"))) { + speed = speed / 3.6; + } + + int wait = 0; + if (labeledWait > 0) { + wait = (int) Math.round(labeledWait); + } else if (numbers.size() > 3) { + wait = (int) Math.round(numbers.get(3)); + } + if (wait < 0) { + wait = 0; + } + + // 速度没写时,用状态对应的默认速度(只区分走 / 跑 / 骑) + if (speed <= 0 && modeSpeeds != null && modeSpeeds.length > mode.ordinal()) { + speed = modeSpeeds[mode.ordinal()]; + } + + lineParse.point = new ScriptWaypoint(lng, lat, ServiceDefaults.ALTITUDE, mode, speed, wait); + return lineParse; + } + + /** 取标签后面出现的第一个数字 */ + private static double findLabeledNumber(String text, String[] labels) { + String lower = text.toLowerCase(Locale.US); + + for (String label : labels) { + int index = lower.indexOf(label); + if (index < 0) { + continue; + } + + Matcher matcher = NUMBER_PATTERN.matcher(text); + while (matcher.find()) { + if (matcher.start() >= index + label.length()) { + try { + return Double.parseDouble(matcher.group()); + } catch (NumberFormatException e) { + return 0; + } + } + } + } + + return 0; + } + + /** 脚本解析时的默认海拔(设置里的海拔在 ServiceGo 里读取,这里只用于路点占位) */ + private static class ServiceDefaults { + private static final double ALTITUDE = 55.0D; + } + + /*============================== 脚本生成 ==============================*/ + + /** + * 把一个路点格式化成脚本里的一行,供地图插点 / 表单保存使用。 + * + * @param bd09 是否按 BD-09 输出坐标(入参始终是 WGS-84) + */ + public static String formatPoint(ScriptWaypoint point, boolean bd09) { + return formatPoint(point, bd09, false); + } + + /** + * 同上,可选择带出停留秒数。 + * + * @param withWait 是否在行尾追加停留秒数(<= 0 时不追加) + */ + public static String formatPoint(ScriptWaypoint point, boolean bd09, boolean withWait) { + double lng = point.lng; + double lat = point.lat; + + if (bd09) { + double[] bd = MapUtils.wgs2bd09(lng, lat); + lng = bd[0]; + lat = bd[1]; + } + + StringBuilder line = new StringBuilder(); + line.append(String.format(Locale.US, "%s %.6f %.6f", point.mode.key, lng, lat)); + // 速度没写(<=0)时省略,让解析端用该状态的默认速度 + if (point.speed > 0) { + line.append(' ').append(Double.toString(point.speed)); + } + if (withWait && point.waitSeconds > 0) { + line.append(' ').append(point.waitSeconds); + } + + return line.toString(); + } + + /*============================== 统计 ==============================*/ + + /** 路点之间的地面距离(米) */ + public static double distance(ScriptWaypoint from, ScriptWaypoint to) { + if (from == null || to == null) { + return 0; + } + + double lng1 = Math.toRadians(from.lng); + double lat1 = Math.toRadians(from.lat); + double lng2 = Math.toRadians(to.lng); + double lat2 = Math.toRadians(to.lat); + + double sinLat = Math.sin((lat2 - lat1) / 2.0); + double sinLng = Math.sin((lng2 - lng1) / 2.0); + double h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLng * sinLng; + + return 2.0 * ScriptPlayer.EARTH_RADIUS * Math.asin(Math.min(1.0, Math.sqrt(h))); + } + + /** 整条路线的长度(米),循环时额外包含“最后一点回到第一点”的闭合段 */ + public static double totalDistance(ScriptRoute route) { + if (route == null || route.points == null || route.points.size() < 2) { + return 0; + } + + double total = 0; + for (int i = 1; i < route.points.size(); i++) { + total += distance(route.points.get(i - 1), route.points.get(i)); + } + if (route.isLoop()) { + total += distance(route.points.get(route.points.size() - 1), route.points.get(0)); + } + + return total; + } + + /** + * 预计耗时(秒)。 + * + *

与 {@link ScriptPlayer} 的实际推进保持一致:段内速度从起点速度线性过渡到终点速度, + * 平均速度为两者之和的一半;停留时间算在“到达的那个点”上,起点的停留不计 + * (循环模式下最后一点回到起点的闭合段会在起点停留,所以循环时起点也算一次)。

+ */ + public static long totalSeconds(ScriptRoute route) { + if (route == null || route.points == null || route.points.isEmpty()) { + return 0; + } + + long seconds = 0; + for (int i = 1; i < route.points.size(); i++) { + seconds += segmentSeconds(route.points.get(i - 1), route.points.get(i)); + seconds += Math.max(0, route.points.get(i).waitSeconds); + } + if (route.isLoop() && route.points.size() > 1) { + ScriptWaypoint first = route.points.get(0); + ScriptWaypoint last = route.points.get(route.points.size() - 1); + seconds += segmentSeconds(last, first); + seconds += Math.max(0, first.waitSeconds); + } + + return seconds; + } + + /** 走完一段的预计秒数:平均速度取两端速度的算术平均,与播放器的插值一致 */ + private static long segmentSeconds(ScriptWaypoint from, ScriptWaypoint to) { + double v0 = speedOr(from, 1.2); + double v1 = speedOr(to, 1.2); + double average = (v0 + v1) / 2.0; + if (average <= 0) { + average = 1.2; + } + + return (long) Math.ceil(distance(from, to) / average); + } + + /** 取路点速度,非法(<=0)时回落到默认值 */ + private static double speedOr(ScriptWaypoint point, double fallback) { + if (point == null || point.speed <= 0) { + return fallback; + } + + return point.speed; + } + + /** 把米格式化成 km / m 的显示文本 */ + public static String formatDistance(double meters) { + if (meters >= 1000) { + return String.format(Locale.US, "%.2f km", meters / 1000.0); + } + return String.format(Locale.US, "%.0f m", meters); + } + + /** 把秒格式化成 分:秒 / 时:分:秒 的显示文本 */ + public static String formatDuration(long seconds) { + long hour = seconds / 3600; + long minute = (seconds % 3600) / 60; + long second = seconds % 60; + + if (hour > 0) { + return String.format(Locale.US, "%d:%02d:%02d", hour, minute, second); + } + return String.format(Locale.US, "%d:%02d", minute, second); + } + + /** 计算从 from 到 to 的方位角(0 ~ 360,正北为 0) */ + public static double bearing(ScriptWaypoint from, ScriptWaypoint to) { + if (from == null || to == null) { + return 0; + } + + return bearing(from.lng, from.lat, to.lng, to.lat); + } + + /** + * 计算从 (lng1, lat1) 到 (lng2, lat2) 的方位角(0 ~ 360,正北为 0)。 + * + *

全工程只有这一份球面公式实现。ServiceGo 里注入朝向时也要算方位角, + * 以前它自己复了一份同样的 sin/cos,两处一旦只改一处就会算出不同的朝向。

+ */ + public static double bearing(double lng1, double lat1, double lng2, double lat2) { + double lat1r = Math.toRadians(lat1); + double lat2r = Math.toRadians(lat2); + double dLng = Math.toRadians(lng2 - lng1); + + double y = Math.sin(dLng) * Math.cos(lat2r); + double x = Math.cos(lat1r) * Math.sin(lat2r) - Math.sin(lat1r) * Math.cos(lat2r) * Math.cos(dLng); + + double degree = Math.toDegrees(Math.atan2(y, x)); + return (degree + 360.0) % 360.0; + } + + /** + * 计算某个位置到脚本轨迹的最短距离(米),用于开始脚本时自动对齐到最近的一段。 + */ + public static double distanceToRoute(ScriptRoute route, double lng, double lat) { + if (route == null || route.points.isEmpty()) { + return Double.MAX_VALUE; + } + + double best = Double.MAX_VALUE; + ScriptWaypoint target = new ScriptWaypoint(lng, lat, 0, ScriptWaypoint.Mode.WALK, 0, 0); + + for (ScriptWaypoint point : route.points) { + best = Math.min(best, distance(point, target)); + } + + // 投影到每一段上再取最小值,避免“离路点远但就在线段上”被误判 + for (int i = 1; i < route.points.size(); i++) { + best = Math.min(best, distanceToSegment(route.points.get(i - 1), route.points.get(i), lng, lat)); + } + if (route.isLoop() && route.points.size() > 1) { + best = Math.min(best, distanceToSegment(route.points.get(route.points.size() - 1), route.points.get(0), lng, lat)); + } + + return best; + } + + /** 点到线段的距离(米),用局部的平面近似,路程级别足够精确 */ + private static double distanceToSegment(ScriptWaypoint from, ScriptWaypoint to, double lng, double lat) { + double scale = Math.cos(Math.toRadians(lat)); + double ax = (to.lng - from.lng) * scale; + double ay = to.lat - from.lat; + double px = (lng - from.lng) * scale; + double py = lat - from.lat; + + double lengthSquared = ax * ax + ay * ay; + double t = 0; + if (lengthSquared > 0) { + t = (px * ax + py * ay) / lengthSquared; + t = Math.max(0.0, Math.min(1.0, t)); + } + + double dx = px - ax * t; + double dy = py - ay * t; + + // 度 -> 米 + double latMeters = dy * 111320.0; + double lngMeters = dx * 111320.0; + return Math.sqrt(latMeters * latMeters + lngMeters * lngMeters); + } + + /** 判断一个点是否足够接近轨迹(默认 80 米内视为在路上) */ + public static boolean isOnRoute(ScriptRoute route, double lng, double lat, double toleranceMeters) { + return distanceToRoute(route, lng, lat) <= toleranceMeters; + } + + /** 取“设置”里的移动参数,顺序与 {@link ScriptWaypoint.Mode} 一致 */ + public static double[] modeSpeeds(double walk, double run, double bike) { + return new double[] {walk, run, bike}; + } +} diff --git a/app/src/main/java/com/zcshou/script/ScriptPlayer.java b/app/src/main/java/com/zcshou/script/ScriptPlayer.java new file mode 100644 index 00000000..a9854c1a --- /dev/null +++ b/app/src/main/java/com/zcshou/script/ScriptPlayer.java @@ -0,0 +1,679 @@ +package com.zcshou.script; + +import java.util.List; + +/** + * 脚本播放引擎:按时间推进,在路点之间做经纬度插值。 + * + *

播放本身不关心线程,由 {@link com.zcshou.service.ServiceGo} 的定位线程周期性调用 + * {@link #onTick()} 推进即可。每个 {@link ScriptWaypoint} 自带移动状态(走 / 跑 / 骑)和速度, + * 因此“同一段脚本里既能走路又能骑车”是靠分段速度实现的:切线方向不变,速度随当前段的状态变化。

+ * + *

start / stop 可能在主线程调用,而 onTick 在定位线程调用,所以这几个入口都做成同步方法, + * 回调也统一由引擎自己加锁,避免“停止播放”和“推进位置”交叉执行。

+ * + *

支持暂停 / 继续:暂停期间时间轴整体冻结(onTick 直接空转), + * 继续时把暂停的时长从起始时刻里补回去,因此恢复后从暂停处原地接着走, + * 既不会跳过路段,也不会瞬移回别处。

+ */ +public class ScriptPlayer { + /** 地球平均半径(米) */ + public static final double EARTH_RADIUS = 6371008.8D; + + /** 上一次位置与本次位置小于该距离时,认为没有移动(用于避免重复注入相同坐标) */ + private static final double MOVE_EPSILON_METERS = 0.05D; + /** 计算下一位置时允许的最小推进距离,避免除零 */ + private static final double MIN_STEP_METERS = 0.01D; + /** 速度兜底值(米/秒),避免除零 */ + private static final double MIN_SPEED = 0.1D; + + private ScriptRoute mRoute; + private boolean mPlaying; + private boolean mEndReached; + /** 是否处于暂停中:暂停期间 onTick 空转,位置停在原地 */ + private boolean mPaused; + /** 进入暂停的时刻(毫秒),继续播放时把这段时长从时间轴里补回去 */ + private long mPausedRealtime; + + /** 开始播放的时刻(毫秒,用来把等待时间和移动时间统一成一条时间轴) */ + private long mStartRealtime; + /** + * 已经“消费掉”的时间轴长度(秒):走过路段、到站停留都计入。 + * + *

每个 tick 的有效时间 = (now - mStartRealtime) / 1000 - mConsumedSeconds, + * 也就是“从当前路段起点算起经过的秒数”。以前没有这个扣减,全局累计时间会被 + * 从当前段重新扣一遍,导致段落被成片跳过、脚本提前跑完。

+ */ + private double mConsumedSeconds; + /** 当前正在走的路段:从 mIndex 到 mIndex + 1 */ + private int mIndex; + /** 是否循环 */ + private boolean mLoop; + /** 是否已经产生过有效位置(用于首次强制回调一次) */ + private boolean mHasPosition; + + /** + * 播放过程中手动切换的移动状态(走 / 跑 / 骑)。 + * + *

非 null 时它会覆盖后面路点自带的状态与速度,直到再次切换或脚本结束, + * 这样“边走边切”不需要停下来重新开始。

+ */ + private ScriptWaypoint.Mode mOverrideMode; + /** 与 {@link #mOverrideMode} 配套的速度(米/秒),<= 0 表示没有手动切换 */ + private double mOverrideSpeed; + /** + * 上一次 {@link #onTick()} 结束时,当前路段上已经消耗的时间(秒)。 + * + *

切换速度时用它换算出“已经走过的距离”,再按新速度重算时间轴, + * 位置就不会跳变。

+ */ + private double mElapsedInSegment; + + private double mLng; + private double mLat; + private ScriptWaypoint.Mode mMode = ScriptWaypoint.Mode.WALK; + private double mSpeed; + private double mBearing; + private double mAltitude; + + private Handler mHandler; + + /** 播放过程中的回调,实现方需要保证线程安全(默认在 ServiceGo 的定位线程里回调) */ + public interface Handler { + /** 开始播放,参数为第一个路点的位置 */ + void onScriptStart(ScriptRoute route, double lng, double lat, double alt); + + /** 位置推进(每一步都保证确实移动了) */ + void onScriptPosition(double lng, double lat, double alt, ScriptWaypoint.Mode mode, double speed, double bearing); + + /** 进入某一段(该段起点的状态),可以用来提示“开始骑行” */ + void onScriptSegment(ScriptRoute route, int index, ScriptWaypoint.Mode mode, double speed); + + /** 单次模式跑到终点 */ + void onScriptFinish(ScriptRoute route); + + /** 异常停止(脚本无效等) */ + void onScriptError(String message); + } + + public void setHandler(Handler handler) { + mHandler = handler; + } + + public synchronized boolean isPlaying() { + return mPlaying && mRoute != null; + } + + /** + * 当前路段的移动状态(走 / 跑 / 骑)。 + * + *

这是本类唯一对外暴露的“当前状态”读数,被 ServiceGo 的界面状态查询使用; + * 位置 / 速度 / 方向这些内部状态不提供 getter——它们只在本类内部参与插值计算, + * 暴露出去只会让人误以为可以外部改写,徒增维护成本。

+ */ + public ScriptWaypoint.Mode getMode() { + return mMode; + } + + /** + * 开始播放。 + * + * @param route 已经被解析好的路线(路点至少一个) + * @param fromLng 模拟位置当前所在经度,用于把脚本对齐到最近的轨迹点 + * @param fromLat 模拟位置当前所在纬度 + */ + public synchronized void start(ScriptRoute route, double fromLng, double fromLat) { + if (route == null || route.points == null || route.points.isEmpty()) { + notifyError("脚本没有可用的路点"); + return; + } + + mRoute = route; + mLoop = route.isLoop(); + mEndReached = false; + mHasPosition = false; + mPaused = false; + mOverrideMode = null; + mOverrideSpeed = 0; + mElapsedInSegment = 0; + mConsumedSeconds = 0; + + List points = route.points; + + // 只有一个点:原地保持该状态(不会移动,但速度 / 方向按该点设置) + if (points.size() == 1) { + mIndex = 0; + mLng = points.get(0).lng; + mLat = points.get(0).lat; + mAltitude = points.get(0).alt; + mMode = points.get(0).mode; + mSpeed = points.get(0).speed; + mBearing = 0; + mStartRealtime = System.currentTimeMillis(); + mPlaying = true; + + if (mHandler != null) { + mHandler.onScriptStart(route, mLng, mLat, mAltitude); + mHandler.onScriptSegment(route, 0, mMode, mSpeed); + } + return; + } + + Anchor anchor = findAnchor(points, fromLng, fromLat); + + // 站在(或越过)单次路线的终点时按“开始”:接入点会落在最后一段的尽头, + // 第一个 tick 就直接判“已到终点”,表现为“点开始后一动不动”。 + // 这里改成从头重新走,保证任何入口按下“开始”都一定有位移。 + if (!mLoop && anchor.index >= points.size() - 2) { + ScriptWaypoint lastFrom = points.get(anchor.index); + ScriptWaypoint lastTo = points.get(anchor.index + 1); + double lastTravel = travelTime(ScriptParser.distance(lastFrom, lastTo), + speedOf(lastFrom), speedOf(lastTo)); + if (anchor.offsetSeconds >= lastTravel - 0.05D) { + anchor.index = 0; + anchor.offsetSeconds = 0; + } + } + + mIndex = anchor.index; + + ScriptWaypoint current = points.get(mIndex); + mLng = current.lng; + mLat = current.lat; + mAltitude = current.alt; + mMode = current.mode; + mSpeed = current.speed; + mBearing = ScriptParser.bearing(current, points.get(mIndex + 1)); + + // 接入点之前已经走过的路程折进时间轴:t=0 时位置正好落在接入点上,不会瞬移 + mStartRealtime = System.currentTimeMillis() - (long) Math.round(anchor.offsetSeconds * 1000.0); + mPlaying = true; + + if (mHandler != null) { + mHandler.onScriptStart(route, mLng, mLat, mAltitude); + mHandler.onScriptSegment(route, mIndex, mMode, mSpeed); + } + + // 立即推进一次,避免第一次 tick 之前位置没变化 + onTick(); + } + + /** 停止播放(保留当前位置,不再推进) */ + public synchronized void stop() { + mPlaying = false; + mEndReached = false; + mPaused = false; + mRoute = null; + mOverrideMode = null; + mOverrideSpeed = 0; + mElapsedInSegment = 0; + mConsumedSeconds = 0; + } + + /** + * 暂停播放:位置停在原地,时间轴冻结。 + * + *

暂停不等于停止:路线、进度、手动切换过的状态全部保留, + * 只是不再按时间推进;恢复时从暂停的位置原地接着走。

+ * + *

没在播放或已到终点时不会进入暂停——调用方(ServiceGo)靠返回值区分 + * “真暂停了”和“这一拍刚好跑完”,否则 FINISHED 状态会被误改成“暂停中”。

+ * + * @return 是否进入暂停 + */ + public synchronized boolean pause() { + if (!mPlaying || mRoute == null || mEndReached || mPaused) { + return false; + } + + mPaused = true; + mPausedRealtime = System.currentTimeMillis(); + return true; + } + + /** + * 继续播放暂停中的脚本。 + * + *

把暂停的时长加回起始时刻,等价于“这段时间里时钟没走”, + * 因此恢复后 elapsed 从暂停处继续,不会跳过任何路段。

+ * + * @return 是否恢复成功(没在暂停时返回 false) + */ + public synchronized boolean resume() { + if (!mPlaying || mRoute == null || mEndReached || !mPaused) { + return false; + } + + mStartRealtime += System.currentTimeMillis() - mPausedRealtime; + mPaused = false; + + // 单点脚本位置永远不变:恢复后坐标不动,位置回调不会再触发(moved=false), + // 注入速度会一直停在暂停时写下的 0,这里按该点的状态强制补发一次 + List points = mRoute.points; + if (points != null && points.size() == 1) { + ScriptWaypoint only = points.get(0); + updatePosition(only.lng, only.lat, only.alt, liveMode(only), liveSpeed(only), mBearing, true); + } + + return true; + } + + /** 是否处于暂停中(与 {@link #isPlaying()} 互补:暂停时仍在播放,只是不推进) */ + public synchronized boolean isPaused() { + return mPaused; + } + + /** + * 播放过程中手动切换移动状态(走 / 跑 / 骑)。 + * + *

切换时先按当前已走过的距离重新对齐时间轴,再让剩下的路以新速度走完, + * 因此位置不会跳变。切换会一直生效到再次切换或脚本结束。

+ * + * @param mode 新的移动状态 + * @param speed 新状态对应的速度(米/秒),必须大于 0 + * @return 是否切换成功(没有在播放、参数非法时返回 false) + */ + public synchronized boolean setLiveMode(ScriptWaypoint.Mode mode, double speed) { + if (!mPlaying || mRoute == null || mEndReached || mode == null || speed <= 0) { + return false; + } + + List points = mRoute.points; + if (points == null || points.isEmpty()) { + return false; + } + + // 单点脚本:没有路段可走,只把状态和速度换掉 + if (points.size() < 2) { + mOverrideMode = mode; + mOverrideSpeed = speed; + return true; + } + + // 必须在改 mOverrideSpeed 之前算,此时用的还是“切换前”的速度模型 + double covered = coveredDistanceInSegment(); + double segment = segmentLength(); + + mOverrideMode = mode; + mOverrideSpeed = speed; + + // 新模型下 v0 = v1 = 新速度,所以“已走 covered 米”等价于新时间轴上的 covered / speed 秒。 + // 时间轴 = 原始时钟 - 已消费时间,所以要把 mConsumedSeconds 一起加回去。 + // 暂停中改状态时,时钟按“停在暂停那一刻”算:暂停的时间不参与推进, + // 这样继续播放时 elapsed 正好从切换时的进度接着走 + double elapsed = Math.min(segment, Math.max(0, covered)) / speed; + long now = mPaused ? mPausedRealtime : System.currentTimeMillis(); + mStartRealtime = now - (long) Math.round((elapsed + mConsumedSeconds) * 1000.0); + mElapsedInSegment = elapsed; + + return true; + } + + /** 当前播放中手动切换的状态,没有切换过时返回 null */ + public synchronized ScriptWaypoint.Mode getLiveMode() { + return mOverrideMode; + } + + /** + * 周期调用:按“开始播放到现在”的时间推进位置。 + * + *

用时间轴而不是累加距离,可以让每段的移动速度严格等于该段状态的速度 + * (包括停留时间,也不需要额外计时器)。

+ */ + public synchronized boolean onTick() { + if (!mPlaying || mRoute == null || mEndReached || mPaused) { + return false; + } + + List points = mRoute.points; + if (points == null || points.isEmpty()) { + return false; + } + + // 单点脚本:位置不动,只有速度 / 方向需要维持 + if (points.size() == 1) { + ScriptWaypoint only = points.get(0); + return updatePosition(only.lng, only.lat, only.alt, liveMode(only), + liveSpeed(only), mBearing, false); + } + + // 有效时间 = 从接入点开始的全局时钟 - 已消费的时间,得到“当前路段上已过的秒数” + double elapsed = (System.currentTimeMillis() - mStartRealtime) / 1000.0 - mConsumedSeconds; + if (elapsed < 0) { + elapsed = 0; + } + + double remainingTime = elapsed; + int guard = 0; + + // 每轮循环里的 remainingTime 都表示“从当前路段起点算起经过的秒数”, + // 每跨过一段就把该段消耗的时间记入 mConsumedSeconds,保证跨 tick 时仍然成立。 + while (guard++ < 100000) { + if (remainingTime < 0) { + remainingTime = 0; + } + + int next = mIndex + 1; + + if (next >= points.size()) { + // 已经到了最后一个点 + if (!mLoop) { + ScriptWaypoint last = points.get(points.size() - 1); + mEndReached = true; + mElapsedInSegment = remainingTime; + // 终点的停留已经在最后一段的预算里消耗完,这里只报“已停下” + updatePosition(last.lng, last.lat, last.alt, liveMode(last), 0, mBearing, true); + + if (mHandler != null) { + mHandler.onScriptFinish(mRoute); + } + return true; + } + + // 循环模式:补上“最后一点回到第一点”的闭合段 + ScriptWaypoint last = points.get(mIndex); + ScriptWaypoint first = points.get(0); + double closing = ScriptParser.distance(last, first); + double closingV0 = liveSpeed(last); + double closingV1 = liveSpeed(first); + + if (closing < MIN_STEP_METERS) { + mIndex = 0; + mElapsedInSegment = remainingTime; + mBearing = points.size() > 1 ? ScriptParser.bearing(first, points.get(1)) : 0; + if (mHandler != null) { + mHandler.onScriptSegment(mRoute, 0, liveMode(first), closingV1); + } + continue; + } + + double closingTravel = travelTime(closing, closingV0, closingV1); + // 走完闭合段就回到起点,所以这里消耗的是“起点”的停留时间 + // (最后一点自己的停留,早在进入这一点那一段里就已经消耗过了) + double closingWait = waitOf(first); + if (remainingTime < closingTravel + closingWait) { + double moving = Math.max(0, Math.min(closingTravel, remainingTime)); + double distance = distanceAt(closingV0, closingV1, moving, closingTravel, closing); + double speed = remainingTime < closingTravel ? speedAt(closingV0, closingV1, moving, closingTravel) : 0; + double bearing = ScriptParser.bearing(last, first); + double[] position = destination(last.lat, last.lng, bearing, distance); + mElapsedInSegment = remainingTime; + return updatePosition(position[0], position[1], last.alt, liveMode(last), speed, bearing, true); + } + + remainingTime -= closingTravel + closingWait; + mConsumedSeconds += closingTravel + closingWait; + mIndex = 0; + mElapsedInSegment = remainingTime; + mBearing = points.size() > 1 ? ScriptParser.bearing(first, points.get(1)) : 0; + if (mHandler != null) { + mHandler.onScriptSegment(mRoute, 0, liveMode(first), closingV1); + } + continue; + } + + ScriptWaypoint from = points.get(mIndex); + ScriptWaypoint to = points.get(next); + double segment = ScriptParser.distance(from, to); + + // 位置重合的相邻点:直接跨过去 + if (segment < MIN_STEP_METERS) { + mIndex = next; + mElapsedInSegment = remainingTime; + mBearing = next + 1 < points.size() + ? ScriptParser.bearing(to, points.get(next + 1)) + : ScriptParser.bearing(to, mLoop ? points.get(0) : to); + if (mHandler != null) { + mHandler.onScriptSegment(mRoute, mIndex, liveMode(to), liveSpeed(to)); + } + continue; + } + + // 段内速度从起点的状态速度线性过渡到终点的状态速度(走 / 跑 / 骑之间不再瞬变) + double v0 = liveSpeed(from); + double v1 = liveSpeed(to); + double travel = travelTime(segment, v0, v1); + double dwell = waitOf(to); + + if (remainingTime < travel + dwell) { + // 先在 travel 秒内走完这一段,然后停在终点消耗停留时间(速度报 0,更像真人) + boolean moving = remainingTime < travel; + double movingSeconds = moving ? remainingTime : travel; + double distance = distanceAt(v0, v1, movingSeconds, travel, segment); + double speed = moving ? speedAt(v0, v1, movingSeconds, travel) : 0; + double bearing = ScriptParser.bearing(from, to); + double[] position = destination(from.lat, from.lng, bearing, distance); + mElapsedInSegment = remainingTime; + return updatePosition(position[0], position[1], from.alt, liveMode(from), speed, bearing, true); + } + + // 这一段(含到站停留)已经走完,进入下一站 + remainingTime -= travel + dwell; + mConsumedSeconds += travel + dwell; + mIndex = next; + mElapsedInSegment = remainingTime; + mBearing = next + 1 < points.size() + ? ScriptParser.bearing(to, points.get(next + 1)) + : ScriptParser.bearing(to, mLoop ? points.get(0) : to); + + if (mHandler != null) { + mHandler.onScriptSegment(mRoute, mIndex, liveMode(to), liveSpeed(to)); + } + } + + return false; + } + + /** + * 更新位置,位置确实变化时回调。 + * + * @param force 强制回调(例如到达终点时,即使坐标没变也要刷新一次速度 / 方向) + * @return 是否产生了回调 + */ + private boolean updatePosition(double lng, double lat, double alt, ScriptWaypoint.Mode mode, + double speed, double bearing, boolean force) { + boolean moved = force || !mHasPosition + || distanceMeters(mLng, mLat, lng, lat) > MOVE_EPSILON_METERS; + + mLng = lng; + mLat = lat; + mAltitude = alt > 0 ? alt : mAltitude; + mMode = mode == null ? ScriptWaypoint.Mode.WALK : mode; + mSpeed = speed; + mBearing = (bearing + 360.0) % 360.0; + + if (moved) { + mHasPosition = true; + if (mHandler != null) { + mHandler.onScriptPosition(mLng, mLat, mAltitude, mMode, mSpeed, mBearing); + } + } + + return moved; + } + + private void notifyError(String message) { + mPlaying = false; + if (mHandler != null) { + mHandler.onScriptError(message); + } + } + + /** 播放中手动切换过状态时,用切换后的速度;否则用路点自己的速度 */ + private double liveSpeed(ScriptWaypoint point) { + return mOverrideSpeed > 0 ? mOverrideSpeed : speedOf(point); + } + + /** 播放中手动切换过状态时,回调里报切换后的状态 */ + private ScriptWaypoint.Mode liveMode(ScriptWaypoint point) { + if (mOverrideMode != null) { + return mOverrideMode; + } + return point == null || point.mode == null ? ScriptWaypoint.Mode.WALK : point.mode; + } + + /** 当前路段的长度(米);循环模式下的闭合段也算在内 */ + private double segmentLength() { + List points = mRoute == null ? null : mRoute.points; + if (points == null || points.size() < 2) { + return 0; + } + + if (mIndex + 1 < points.size()) { + return ScriptParser.distance(points.get(mIndex), points.get(mIndex + 1)); + } + return ScriptParser.distance(points.get(mIndex), points.get(0)); + } + + /** 按上一次 tick 留下的时间推算当前路段上已经走过的距离(米) */ + private double coveredDistanceInSegment() { + List points = mRoute == null ? null : mRoute.points; + if (points == null || points.size() < 2) { + return 0; + } + + ScriptWaypoint from = points.get(mIndex); + ScriptWaypoint to = mIndex + 1 < points.size() ? points.get(mIndex + 1) : points.get(0); + + double segment = ScriptParser.distance(from, to); + double v0 = liveSpeed(from); + double v1 = liveSpeed(to); + double travel = travelTime(segment, v0, v1); + double moving = Math.max(0.0, Math.min(travel, mElapsedInSegment)); + + return distanceAt(v0, v1, moving, travel, segment); + } + + /** 速度非法时回退到一个安全值,避免除零 */ + private static double speedOf(ScriptWaypoint point) { + if (point == null) { + return 1.2D; + } + return point.speed > 0 ? point.speed : 1.2D; + } + + private static int waitOf(ScriptWaypoint point) { + return point == null ? 0 : Math.max(0, point.waitSeconds); + } + + /** + * 走完一段所需的时间。 + * + *

段内速度按“起点速度 → 终点速度”线性变化,平均速度正好是两者的算术平均, + * 所以耗时 = 2 × 距离 / (v0 + v1)。两个速度相同时退化成原来的“距离 / 速度”, + * 因此只选一种状态(走 / 跑 / 骑)的脚本,行为与优化前完全一致。

+ */ + private static double travelTime(double distance, double v0, double v1) { + double sum = v0 + v1; + if (sum <= 0) { + return distance / Math.max(v0, MIN_SPEED); + } + return 2.0 * distance / sum; + } + + /** 段内已经走了 moving 秒时的瞬时速度(米/秒),用于注入给系统的 speed 字段 */ + private static double speedAt(double v0, double v1, double moving, double travel) { + if (travel <= 0) { + return v1; + } + double ratio = Math.max(0.0, Math.min(1.0, moving / travel)); + return v0 + (v1 - v0) * ratio; + } + + /** 段内已经走了 moving 秒时累计走过的距离(米),是 speedAt 的时间积分 */ + private static double distanceAt(double v0, double v1, double moving, double travel, double total) { + if (travel <= 0) { + return 0; + } + double ratio = Math.max(0.0, Math.min(1.0, moving / travel)); + double distance = (v0 * ratio + (v1 - v0) * ratio * ratio / 2.0) * travel; + return Math.max(0.0, Math.min(total, distance)); + } + + /** 起点锚点:脚本从轨迹上的哪一段、已经走了多远(换算成时间)开始 */ + private static class Anchor { + int index; + /** 在本段上已经走过的移动时间(秒) */ + double offsetSeconds; + } + + /** + * 把脚本对齐到离当前位置最近的轨迹点:找到投影最近的线段,并算出已经走过的比例。 + * 这样“人已经在路上”时启动脚本不会出现瞬移。 + */ + private static Anchor findAnchor(List points, double lng, double lat) { + Anchor anchor = new Anchor(); + anchor.index = 0; + anchor.offsetSeconds = 0; + + int segmentCount = points.size() - 1; + double best = Double.MAX_VALUE; + + for (int i = 0; i < segmentCount; i++) { + ScriptWaypoint from = points.get(i); + ScriptWaypoint to = points.get(i + 1); + double scale = Math.cos(Math.toRadians(lat)); + double ax = (to.lng - from.lng) * scale; + double ay = to.lat - from.lat; + double px = (lng - from.lng) * scale; + double py = lat - from.lat; + + double lengthSquared = ax * ax + ay * ay; + double t = 0; + if (lengthSquared > 0) { + t = (px * ax + py * ay) / lengthSquared; + t = Math.max(0.0, Math.min(1.0, t)); + } + + double dx = px - ax * t; + double dy = py - ay * t; + double distance = Math.sqrt((dx * 111320.0) * (dx * 111320.0) + (dy * 111320.0) * (dy * 111320.0)); + + if (distance < best) { + best = distance; + anchor.index = i; + anchor.offsetSeconds = t * ScriptParser.distance(from, to) / speedOf(from); + } + } + + return anchor; + } + + /** + * 已知起点、方位角和距离,求目的点(球面公式)。 + * + * @return {经度, 纬度} + */ + public static double[] destination(double lat, double lng, double bearingDegrees, double distanceMeters) { + double angular = distanceMeters / EARTH_RADIUS; + double bearing = Math.toRadians(bearingDegrees); + double lat1 = Math.toRadians(lat); + double lng1 = Math.toRadians(lng); + + double sinLat2 = Math.sin(lat1) * Math.cos(angular) + Math.cos(lat1) * Math.sin(angular) * Math.cos(bearing); + double lat2 = Math.asin(Math.max(-1.0, Math.min(1.0, sinLat2))); + + double lng2 = lng1 + Math.atan2(Math.sin(bearing) * Math.sin(angular) * Math.cos(lat1), + Math.cos(angular) - Math.sin(lat1) * Math.sin(lat2)); + + // 经度归一化到 -180 ~ 180 + double lngDegrees = Math.toDegrees(lng2); + lngDegrees = (lngDegrees + 540.0) % 360.0 - 180.0; + + return new double[] {lngDegrees, Math.toDegrees(lat2)}; + } + + /** 两点之间的距离(米,Haversine) */ + public static double distanceMeters(double lng1, double lat1, double lng2, double lat2) { + double radLng1 = Math.toRadians(lng1); + double radLat1 = Math.toRadians(lat1); + double radLng2 = Math.toRadians(lng2); + double radLat2 = Math.toRadians(lat2); + + double sinLat = Math.sin((radLat2 - radLat1) / 2.0); + double sinLng = Math.sin((radLng2 - radLng1) / 2.0); + double h = sinLat * sinLat + Math.cos(radLat1) * Math.cos(radLat2) * sinLng * sinLng; + + return 2.0 * EARTH_RADIUS * Math.asin(Math.min(1.0, Math.sqrt(h))); + } +} diff --git a/app/src/main/java/com/zcshou/script/ScriptRoute.java b/app/src/main/java/com/zcshou/script/ScriptRoute.java new file mode 100644 index 00000000..b03d8185 --- /dev/null +++ b/app/src/main/java/com/zcshou/script/ScriptRoute.java @@ -0,0 +1,49 @@ +package com.zcshou.script; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * 一条脚本路线:一组按顺序执行的路点。 + */ +public class ScriptRoute { + /** 路点顺序执行完之后的处理方式 */ + public static final int END_STOP = 0; + public static final int END_LOOP = 1; + + public String id; + public String name; + /** 路点顺序执行完之后是否从头继续跑 */ + public int endMode = END_STOP; + /** 用户编辑的脚本原文,列表页直接展示、编辑页直接回填 */ + public String text = ""; + /** + * 是否被用户在脚本编辑页手动改过。 + * + *

主界面“路线选点”每次开始移动都会重新生成一条地图路线;如果这条路线已经被 + * 用户改过(改名、改路点、改速度……),就不要再覆盖它,而是另存为新脚本。

+ */ + public boolean edited; + /** + * 这条脚本里的经纬度是否为 BD-09。 + * + *

null 表示“没有记录”(旧版本保存的脚本),此时沿用全局设置 + * {@link ScriptParser#KEY_SCRIPT_FROM_BD09};一旦保存过就固定跟着脚本走, + * 避免用户改一次全局单选就让历史脚本整体偏移几百米。

+ */ + public Boolean fromBd09; + public List points = new ArrayList<>(); + + public ScriptRoute() { + this.id = UUID.randomUUID().toString(); + } + + public boolean isLoop() { + return endMode == END_LOOP; + } + + public int size() { + return points == null ? 0 : points.size(); + } +} diff --git a/app/src/main/java/com/zcshou/script/ScriptStore.java b/app/src/main/java/com/zcshou/script/ScriptStore.java new file mode 100644 index 00000000..3d8e157f --- /dev/null +++ b/app/src/main/java/com/zcshou/script/ScriptStore.java @@ -0,0 +1,305 @@ +package com.zcshou.script; + +import android.content.Context; +import android.content.SharedPreferences; +import android.text.TextUtils; + +import androidx.preference.PreferenceManager; + +import com.elvishew.xlog.XLog; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * 脚本存储:使用 SharedPreferences 保存脚本列表。 + * + *

脚本数据量很小(几行到几百行文本),用 SharedPreferences 足够, + * 也能避免为它引入数据库的版本迁移。

+ */ +public class ScriptStore { + private static final String KEY_SCRIPTS = "script_route_list"; + /** 当前正在播放的脚本 id(服务重启后用来恢复状态) */ + private static final String KEY_RUNNING = "script_running_id"; + + /** 路点序列化时的字段分隔符 */ + private static final String POINT_SEPARATOR = "\n"; + private static final String FIELD_SEPARATOR = ";"; + + private final SharedPreferences mPreferences; + + public ScriptStore(Context context) { + mPreferences = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); + } + + /*============================== 读写 ==============================*/ + + /** 读取全部脚本(按保存顺序) */ + public List loadAll() { + List routes = new ArrayList<>(); + String raw = mPreferences.getString(KEY_SCRIPTS, null); + if (TextUtils.isEmpty(raw)) { + return routes; + } + + try { + JSONArray array = new JSONArray(raw); + for (int i = 0; i < array.length(); i++) { + JSONObject object = array.optJSONObject(i); + if (object == null) { + continue; + } + + ScriptRoute route = fromJson(object); + if (route != null) { + routes.add(route); + } + } + } catch (JSONException e) { + XLog.e("SCRIPT: load failed"); + } + + return routes; + } + + /** 按 id 读取单个脚本,不存在时返回 null */ + public ScriptRoute load(String id) { + if (TextUtils.isEmpty(id)) { + return null; + } + + for (ScriptRoute route : loadAll()) { + if (id.equals(route.id)) { + return route; + } + } + + return null; + } + + /** 新增或覆盖保存(按 id 匹配) */ + public void save(ScriptRoute route) { + if (route == null) { + return; + } + + List routes = loadAll(); + boolean replaced = false; + for (int i = 0; i < routes.size(); i++) { + if (routes.get(i).id.equals(route.id)) { + routes.set(i, route); + replaced = true; + break; + } + } + if (!replaced) { + routes.add(route); + } + + writeAll(routes); + } + + public void delete(String id) { + if (TextUtils.isEmpty(id)) { + return; + } + + List routes = loadAll(); + List remain = new ArrayList<>(); + for (ScriptRoute route : routes) { + if (!id.equals(route.id)) { + remain.add(route); + } + } + + writeAll(remain); + } + + public boolean isEmpty() { + return loadAll().isEmpty(); + } + + /*============================== 导入 / 导出 ==============================*/ + + /** 把全部脚本导出为 JSON 文本(与内部存储格式一致,可直接再导回) */ + public String exportJson() { + JSONArray array = new JSONArray(); + for (ScriptRoute route : loadAll()) { + try { + array.put(toJson(route)); + } catch (JSONException e) { + XLog.e("SCRIPT: export failed"); + } + } + + return array.toString(); + } + + /** + * 导入 JSON 文本里的脚本(数组或单个对象均可)。 + * + *

按 id 覆盖:同 id 视为同一条脚本的更新,重复导入不会产生重复条目。

+ * + * @return 成功导入的条数;格式非法返回 -1 + */ + public int importJson(String json) { + if (json == null || json.trim().isEmpty()) { + return -1; + } + + String trimmed = json.trim(); + if (trimmed.startsWith("{")) { + trimmed = "[" + trimmed + "]"; + } + + try { + JSONArray array = new JSONArray(trimmed); + int count = 0; + for (int i = 0; i < array.length(); i++) { + JSONObject object = array.optJSONObject(i); + if (object == null) { + continue; + } + + ScriptRoute route = fromJson(object); + if (route != null) { + save(route); + count++; + } + } + return count; + } catch (JSONException e) { + XLog.e("SCRIPT: import failed"); + return -1; + } + } + + private void writeAll(List routes) { + JSONArray array = new JSONArray(); + for (ScriptRoute route : routes) { + try { + array.put(toJson(route)); + } catch (JSONException e) { + XLog.e("SCRIPT: write failed"); + } + } + + mPreferences.edit().putString(KEY_SCRIPTS, array.toString()).apply(); + } + + /*============================== 序列化 ==============================*/ + + private static JSONObject toJson(ScriptRoute route) throws JSONException { + JSONObject object = new JSONObject(); + object.put("id", route.id); + object.put("name", route.name == null ? "" : route.name); + object.put("end", route.endMode); + object.put("text", route.text == null ? "" : route.text); + object.put("points", encodePoints(route.points)); + // 坐标系跟着脚本自己走:null(旧脚本)时不写字段,读取端再回落到全局设置 + if (route.fromBd09 != null) { + object.put("bd09", route.fromBd09.booleanValue()); + } + object.put("edited", route.edited); + return object; + } + + private static ScriptRoute fromJson(JSONObject object) { + try { + ScriptRoute route = new ScriptRoute(); + route.id = object.optString("id", route.id); + if (TextUtils.isEmpty(route.id)) { + route.id = java.util.UUID.randomUUID().toString(); + } + route.name = object.optString("name", ""); + route.endMode = object.optInt("end", ScriptRoute.END_STOP); + route.text = object.optString("text", ""); + route.points = decodePoints(object.optString("points", "")); + if (object.has("bd09")) { + route.fromBd09 = object.optBoolean("bd09", false); + } + route.edited = object.optBoolean("edited", false); + return route; + } catch (Exception e) { + XLog.e("SCRIPT: parse failed"); + return null; + } + } + + /** + * 路点编码:每行一个,字段用 ; 分隔(mode;lng;lat;alt;speed;wait)。 + * + *

公开是为了让界面在旋屏 / 重建时也能用它把“还没开始移动的选点” + * 塞进 savedInstanceState,避免用户白点一遍。

+ */ + public static String encodePoints(List points) { + if (points == null || points.isEmpty()) { + return ""; + } + + StringBuilder builder = new StringBuilder(); + for (ScriptWaypoint point : points) { + if (builder.length() > 0) { + builder.append(POINT_SEPARATOR); + } + builder.append(point.mode == null ? ScriptWaypoint.Mode.WALK.key : point.mode.key).append(FIELD_SEPARATOR) + .append(String.format(Locale.US, "%.7f", point.lng)).append(FIELD_SEPARATOR) + .append(String.format(Locale.US, "%.7f", point.lat)).append(FIELD_SEPARATOR) + .append(String.format(Locale.US, "%.2f", point.alt)).append(FIELD_SEPARATOR) + .append(String.format(Locale.US, "%.3f", point.speed)).append(FIELD_SEPARATOR) + .append(point.waitSeconds); + } + + return builder.toString(); + } + + /** 路点解码,与 {@link #encodePoints(List)} 配对(恢复选点时也会用到) */ + public static List decodePoints(String raw) { + List points = new ArrayList<>(); + if (TextUtils.isEmpty(raw)) { + return points; + } + + for (String line : raw.split(POINT_SEPARATOR)) { + if (TextUtils.isEmpty(line)) { + continue; + } + + String[] fields = line.split(FIELD_SEPARATOR); + if (fields.length < 6) { + continue; + } + + try { + ScriptWaypoint point = new ScriptWaypoint( + Double.parseDouble(fields[1]), + Double.parseDouble(fields[2]), + Double.parseDouble(fields[3]), + ScriptWaypoint.Mode.parse(fields[0]), + Double.parseDouble(fields[4]), + (int) Double.parseDouble(fields[5])); + points.add(point); + } catch (NumberFormatException e) { + XLog.e("SCRIPT: point parse failed"); + } + } + + return points; + } + + /*============================== 运行状态 ==============================*/ + + /** 当前正在播放的脚本 id(服务重启后用来恢复状态),没有在播放时返回 null */ + public String getRunningScriptId() { + return mPreferences.getString(KEY_RUNNING, null); + } + + public void setRunningScriptId(String id) { + mPreferences.edit().putString(KEY_RUNNING, id).apply(); + } +} diff --git a/app/src/main/java/com/zcshou/script/ScriptTextEditor.java b/app/src/main/java/com/zcshou/script/ScriptTextEditor.java new file mode 100644 index 00000000..6bd2dc0f --- /dev/null +++ b/app/src/main/java/com/zcshou/script/ScriptTextEditor.java @@ -0,0 +1,256 @@ +package com.zcshou.script; + +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 脚本原文的“外科手术式”编辑:只动需要动的那一行 / 那几个字符, + * 注释、空行、自定义写法(20km/h、停留标签……)全部原样保留。 + * + *

纯 Java 实现,不依赖 Android,方便直接在 JVM 上做单元测试; + * 地图选点里的“删除 / 改状态 / 调顺序 / 拖动挪点”都走这里。

+ */ +public final class ScriptTextEditor { + /** 与 {@link ScriptParser} 一致的数字写法 */ + private static final Pattern NUMBER_PATTERN = Pattern.compile("-?\\d+(?:\\.\\d+)?"); + /** 行首状态关键字(英文或中文) */ + private static final Pattern LEADING_MODE_PATTERN = Pattern.compile("^[A-Za-z\\u4e00-\\u9fa5]+"); + /** 与 String.split("\r\n|\r|\n") 一致的换行写法 */ + private static final Pattern LINE_PATTERN = Pattern.compile("\r\n|\r|\n"); + + private ScriptTextEditor() { + } + + /*============================== 行定位 ==============================*/ + + /** 行首偏移(0 起行号);行不存在返回 -1 */ + public static int lineStart(String text, int lineIndex) { + if (text == null || lineIndex < 0) { + return -1; + } + + int pos = 0; + for (int i = 0; i < lineIndex; i++) { + pos = nextLineStart(text, pos); + if (pos < 0) { + return -1; + } + } + + return pos > text.length() ? -1 : pos; + } + + /** 行尾偏移(不含换行符);行不存在返回 -1 */ + public static int lineEnd(String text, int lineIndex) { + int start = lineStart(text, lineIndex); + if (start < 0) { + return -1; + } + + int newline = findNewline(text, start); + return newline < 0 ? text.length() : newline; + } + + /** 从 pos 开始下一行的行首偏移;pos 之后没有换行(已是最后一行)返回 -1 */ + private static int nextLineStart(String text, int pos) { + if (pos >= text.length()) { + return -1; + } + + int newline = findNewline(text, pos); + if (newline < 0) { + return -1; + } + if (text.charAt(newline) == '\r' && newline + 1 < text.length() && text.charAt(newline + 1) == '\n') { + return newline + 2; + } + return newline + 1; + } + + /** 从 from 起第一个换行符位置;没有返回 -1 */ + private static int findNewline(String text, int from) { + for (int i = from; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '\n' || c == '\r') { + return i; + } + } + return -1; + } + + /*============================== 行操作 ==============================*/ + + /** 删除整行:优先吃掉它后面的换行(最后一行则吃掉前面的换行) */ + public static String deleteLine(String text, int lineIndex) { + int start = lineStart(text, lineIndex); + int end = lineEnd(text, lineIndex); + if (start < 0 || end < 0) { + return text; + } + + int cutStart = start; + int cutEnd = end; + if (cutEnd < text.length()) { + cutEnd++; + if (text.charAt(end) == '\r' && cutEnd < text.length() && text.charAt(cutEnd) == '\n') { + cutEnd++; + } + } else if (cutStart > 0) { + int prev = cutStart - 1; + if (text.charAt(prev) == '\n') { + prev--; + } + if (prev >= 0 && text.charAt(prev) == '\r') { + prev--; + } + cutStart = prev + 1; + } + + return text.substring(0, cutStart) + text.substring(cutEnd); + } + + /** 交换两行的行内容(行位置不动,注释 / 空行留在原地) */ + public static String swapLines(String text, int lineA, int lineB) { + if (lineA == lineB) { + return text; + } + + int startA = lineStart(text, lineA); + int endA = lineEnd(text, lineA); + int startB = lineStart(text, lineB); + int endB = lineEnd(text, lineB); + if (startA < 0 || endA < 0 || startB < 0 || endB < 0) { + return text; + } + + boolean aFirst = startA < startB; + int loStart = aFirst ? startA : startB; + int loEnd = aFirst ? endA : endB; + int hiStart = aFirst ? startB : startA; + int hiEnd = aFirst ? endB : endA; + String loContent = aFirst ? text.substring(startA, endA) : text.substring(startB, endB); + String hiContent = aFirst ? text.substring(startB, endB) : text.substring(startA, endA); + + return text.substring(0, loStart) + hiContent + + text.substring(loEnd, hiStart) + loContent + + text.substring(hiEnd); + } + + /** + * 替换行首的状态关键字(保留坐标、速度、停留、注释等其余内容)。 + * + * @param modeKey 新状态在脚本里的关键字(walk / run / bike) + */ + public static String replaceMode(String text, int lineIndex, String modeKey) { + String line = getLine(text, lineIndex); + if (line == null || modeKey == null || modeKey.isEmpty()) { + return text; + } + + Matcher matcher = LEADING_MODE_PATTERN.matcher(line); + String newLine = matcher.find() + ? modeKey + line.substring(matcher.end()) + : modeKey + " " + line; + + return replaceLine(text, lineIndex, newLine); + } + + /** + * 替换该行“第 3 个数字”(速度);这一行本来就没写速度(用状态默认值)时原样返回。 + */ + public static String replaceSpeed(String text, int lineIndex, double speed) { + String line = getLine(text, lineIndex); + if (line == null) { + return text; + } + + Matcher modeMatcher = LEADING_MODE_PATTERN.matcher(line); + int from = modeMatcher.find() ? modeMatcher.end() : 0; + + // 注意:find(int) 会把匹配位置重置回 from,只能在进入循环前调一次, + // 之后必须用无参 find() 续找,否则每次都命中同一个数字 + Matcher numberMatcher = NUMBER_PATTERN.matcher(line); + if (!numberMatcher.find(from)) { + return text; + } + + int count = 1; + while (count < 3 && numberMatcher.find()) { + count++; + } + if (count < 3) { + return text; + } + + String newLine = line.substring(0, numberMatcher.start()) + + formatSpeed(speed) + line.substring(numberMatcher.end()); + return replaceLine(text, lineIndex, newLine); + } + + /** + * 替换该行的经纬度(前两个数字),状态 / 速度 / 停留 / 注释原样保留。 + * + *

坐标按 {@code %.6f} 写回,与地图插点的精度一致。

+ */ + public static String replaceCoords(String text, int lineIndex, double lng, double lat) { + String line = getLine(text, lineIndex); + if (line == null) { + return text; + } + + Matcher modeMatcher = LEADING_MODE_PATTERN.matcher(line); + int from = modeMatcher.find() ? modeMatcher.end() : 0; + + Matcher numberMatcher = NUMBER_PATTERN.matcher(line); + if (!numberMatcher.find(from)) { + return text; + } + int firstStart = numberMatcher.start(); + int firstEnd = numberMatcher.end(); + + if (!numberMatcher.find()) { + return text; + } + int secondStart = numberMatcher.start(); + int secondEnd = numberMatcher.end(); + + String newLine = line.substring(0, firstStart) + + formatCoordinate(lng) + line.substring(firstEnd, secondStart) + + formatCoordinate(lat) + line.substring(secondEnd); + return replaceLine(text, lineIndex, newLine); + } + + /*============================== 内部工具 ==============================*/ + + private static String getLine(String text, int lineIndex) { + int start = lineStart(text, lineIndex); + int end = lineEnd(text, lineIndex); + if (start < 0 || end < 0) { + return null; + } + return text.substring(start, end); + } + + /** 用新内容整体替换某一行(表单保存路点时使用),行不存在时原样返回 */ + public static String setLine(String text, int lineIndex, String newLine) { + return replaceLine(text, lineIndex, newLine); + } + + private static String replaceLine(String text, int lineIndex, String newLine) { + int start = lineStart(text, lineIndex); + int end = lineEnd(text, lineIndex); + if (start < 0 || end < 0) { + return text; + } + return text.substring(0, start) + newLine + text.substring(end); + } + + private static String formatSpeed(double speed) { + return Double.toString(speed); + } + + private static String formatCoordinate(double value) { + return String.format(Locale.US, "%.6f", value); + } +} diff --git a/app/src/main/java/com/zcshou/script/ScriptWaypoint.java b/app/src/main/java/com/zcshou/script/ScriptWaypoint.java new file mode 100644 index 00000000..d14c3cba --- /dev/null +++ b/app/src/main/java/com/zcshou/script/ScriptWaypoint.java @@ -0,0 +1,85 @@ +package com.zcshou.script; + +/** + * 脚本路点:一个“带移动状态的位置”。 + * + *

坐标统一保存为服务层使用的 WGS-84(与 {@link com.zcshou.service.ServiceGo} 一致), + * 界面上需要时再通过 {@link com.zcshou.utils.MapUtils} 转换成 BD-09 展示。

+ */ +public class ScriptWaypoint { + /** 移动状态:步行 / 跑步 / 骑行 */ + public enum Mode { + WALK("walk", "步行", "setting_walk"), + RUN("run", "跑步", "setting_run"), + BIKE("bike", "骑行", "setting_bike"); + + /** 脚本文件里使用的英文关键字 */ + public final String key; + /** 界面上显示的中文名 */ + public final String label; + /** 对应“设置”里的速度项,脚本没有写速度时用它 */ + public final String settingKey; + + Mode(String key, String label, String settingKey) { + this.key = key; + this.label = label; + this.settingKey = settingKey; + } + + /** 脚本里的关键字 / 中文名 / 首字母都能识别 */ + public static Mode parse(String text) { + if (text == null) { + return null; + } + + String value = text.trim().toLowerCase(java.util.Locale.US); + if (value.isEmpty()) { + return null; + } + + for (Mode mode : values()) { + if (value.equals(mode.key) || value.equals(mode.name().toLowerCase(java.util.Locale.US))) { + return mode; + } + } + + // 中文关键字 + if (value.contains("步") || value.contains("走")) { + return WALK; + } + if (value.contains("跑")) { + return RUN; + } + if (value.contains("骑") || value.contains("车")) { + return BIKE; + } + + return null; + } + } + + /** 纬度(WGS-84) */ + public double lat; + /** 经度(WGS-84) */ + public double lng; + /** 海拔(米) */ + public double alt; + /** 移动状态 */ + public Mode mode; + /** + * 速度(米/秒)。小于等于 0 表示使用当前状态在“设置”里的默认速度, + * 也就是“只区分走 / 跑 / 骑”的简单写法。 + */ + public double speed; + /** 到达这个点之后停留的秒数(用于模拟原地等待) */ + public int waitSeconds; + + public ScriptWaypoint(double lng, double lat, double alt, Mode mode, double speed, int waitSeconds) { + this.lng = lng; + this.lat = lat; + this.alt = alt; + this.mode = mode == null ? Mode.WALK : mode; + this.speed = speed; + this.waitSeconds = waitSeconds; + } +} diff --git a/app/src/main/java/com/zcshou/service/ServiceGo.java b/app/src/main/java/com/zcshou/service/ServiceGo.java index 6218cac7..d1200de6 100644 --- a/app/src/main/java/com/zcshou/service/ServiceGo.java +++ b/app/src/main/java/com/zcshou/service/ServiceGo.java @@ -1,338 +1,1433 @@ -package com.zcshou.service; - -import android.annotation.SuppressLint; -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.app.Service; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.location.Criteria; -import android.location.Location; -import android.location.LocationManager; -import android.location.provider.ProviderProperties; -import android.os.Binder; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.HandlerThread; -import android.os.IBinder; -import android.os.Message; -import android.os.Process; -import android.os.SystemClock; - -import androidx.annotation.NonNull; -import androidx.core.app.NotificationCompat; - -import com.elvishew.xlog.XLog; -import com.zcshou.gogogo.MainActivity; -import com.zcshou.gogogo.R; -import com.zcshou.joystick.JoyStick; - -public class ServiceGo extends Service { - // 定位相关变量 - public static final double DEFAULT_LAT = 36.667662; - public static final double DEFAULT_LNG = 117.027707; - public static final double DEFAULT_ALT = 55.0D; - public static final float DEFAULT_BEA = 0.0F; - private double mCurLat = DEFAULT_LAT; - private double mCurLng = DEFAULT_LNG; - private double mCurAlt = DEFAULT_ALT; - private float mCurBea = DEFAULT_BEA; - private double mSpeed = 1.2; /* 默认的速度,单位 m/s */ - private static final int HANDLER_MSG_ID = 0; - private static final String SERVICE_GO_HANDLER_NAME = "ServiceGoLocation"; - private LocationManager mLocManager; - private HandlerThread mLocHandlerThread; - private Handler mLocHandler; - private boolean isStop = false; - // 通知栏消息 - private static final int SERVICE_GO_NOTE_ID = 1; - private static final String SERVICE_GO_NOTE_ACTION_JOYSTICK_SHOW = "ShowJoyStick"; - private static final String SERVICE_GO_NOTE_ACTION_JOYSTICK_HIDE = "HideJoyStick"; - private static final String SERVICE_GO_NOTE_CHANNEL_ID = "SERVICE_GO_NOTE"; - private static final String SERVICE_GO_NOTE_CHANNEL_NAME = "SERVICE_GO_NOTE"; - private NoteActionReceiver mActReceiver; - // 摇杆相关 - private JoyStick mJoyStick; - - private final ServiceGoBinder mBinder = new ServiceGoBinder(); - - @Override - public IBinder onBind(Intent intent) { - return mBinder; - } - - @Override - public void onCreate() { - super.onCreate(); - - mLocManager = (LocationManager) this.getSystemService(Context.LOCATION_SERVICE); - - removeTestProviderNetwork(); - addTestProviderNetwork(); - - removeTestProviderGPS(); - addTestProviderGPS(); - - initGoLocation(); - - initNotification(); - - initJoyStick(); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - mCurLng = intent.getDoubleExtra(MainActivity.LNG_MSG_ID, DEFAULT_LNG); - mCurLat = intent.getDoubleExtra(MainActivity.LAT_MSG_ID, DEFAULT_LAT); - mCurAlt = intent.getDoubleExtra(MainActivity.ALT_MSG_ID, DEFAULT_ALT); - - mJoyStick.setCurrentPosition(mCurLng, mCurLat, mCurAlt); - - return super.onStartCommand(intent, flags, startId); - } - - @Override - public void onDestroy() { - isStop = true; - mLocHandler.removeMessages(HANDLER_MSG_ID); - mLocHandlerThread.quit(); - - mJoyStick.destroy(); - - removeTestProviderNetwork(); - removeTestProviderGPS(); - - unregisterReceiver(mActReceiver); - stopForeground(STOP_FOREGROUND_REMOVE); - - super.onDestroy(); - } - - private void initNotification() { - mActReceiver = new NoteActionReceiver(); - IntentFilter filter = new IntentFilter(); - filter.addAction(SERVICE_GO_NOTE_ACTION_JOYSTICK_SHOW); - filter.addAction(SERVICE_GO_NOTE_ACTION_JOYSTICK_HIDE); - registerReceiver(mActReceiver, filter); - - NotificationChannel mChannel = new NotificationChannel(SERVICE_GO_NOTE_CHANNEL_ID, SERVICE_GO_NOTE_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT); - NotificationManager notificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); - - if (notificationManager != null) { - notificationManager.createNotificationChannel(mChannel); - } - - //准备intent - Intent clickIntent = new Intent(this, MainActivity.class); - PendingIntent clickPI = PendingIntent.getActivity(this, 1, clickIntent, PendingIntent.FLAG_IMMUTABLE); - Intent showIntent = new Intent(SERVICE_GO_NOTE_ACTION_JOYSTICK_SHOW); - PendingIntent showPendingPI = PendingIntent.getBroadcast(this, 0, showIntent, PendingIntent.FLAG_IMMUTABLE); - Intent hideIntent = new Intent(SERVICE_GO_NOTE_ACTION_JOYSTICK_HIDE); - PendingIntent hidePendingPI = PendingIntent.getBroadcast(this, 0, hideIntent, PendingIntent.FLAG_IMMUTABLE); - - Notification notification = new NotificationCompat.Builder(this, SERVICE_GO_NOTE_CHANNEL_ID) - .setChannelId(SERVICE_GO_NOTE_CHANNEL_ID) - .setContentTitle(getResources().getString(R.string.app_name)) - .setContentText(getResources().getString(R.string.app_service_tips)) - .setContentIntent(clickPI) - .addAction(new NotificationCompat.Action(null, getResources().getString(R.string.note_show), showPendingPI)) - .addAction(new NotificationCompat.Action(null, getResources().getString(R.string.note_hide), hidePendingPI)) - .setSmallIcon(R.mipmap.ic_launcher) - .build(); - - startForeground(SERVICE_GO_NOTE_ID, notification); - } - - private void initJoyStick() { - mJoyStick = new JoyStick(this); - mJoyStick.setListener(new JoyStick.JoyStickClickListener() { - @Override - public void onMoveInfo(double speed, double disLng, double disLat, double angle) { - mSpeed = speed; - // 根据当前的经纬度和距离,计算下一个经纬度 - // Latitude: 1 deg = 110.574 km // 纬度的每度的距离大约为 110.574km - // Longitude: 1 deg = 111.320*cos(latitude) km // 经度的每度的距离从0km到111km不等 - // 具体见:http://wp.mlab.tw/?p=2200 - mCurLng += disLng / (111.320 * Math.cos(Math.abs(mCurLat) * Math.PI / 180)); - mCurLat += disLat / 110.574; - mCurBea = (float) angle; - } - - @Override - public void onPositionInfo(double lng, double lat, double alt) { - mCurLng = lng; - mCurLat = lat; - mCurAlt = alt; - } - }); - mJoyStick.show(); - } - - private void initGoLocation() { - // 创建 HandlerThread 实例,第一个参数是线程的名字 - mLocHandlerThread = new HandlerThread(SERVICE_GO_HANDLER_NAME, Process.THREAD_PRIORITY_FOREGROUND); - // 启动 HandlerThread 线程 - mLocHandlerThread.start(); - // Handler 对象与 HandlerThread 的 Looper 对象的绑定 - mLocHandler = new Handler(mLocHandlerThread.getLooper()) { - // 这里的Handler对象可以看作是绑定在HandlerThread子线程中,所以handlerMessage里的操作是在子线程中运行的 - @Override - public void handleMessage(@NonNull Message msg) { - try { - Thread.sleep(100); - - if (!isStop) { - setLocationNetwork(); - setLocationGPS(); - - sendEmptyMessage(HANDLER_MSG_ID); - } - } catch (InterruptedException e) { - XLog.e("SERVICEGO: ERROR - handleMessage"); - Thread.currentThread().interrupt(); - } - } - }; - - mLocHandler.sendEmptyMessage(HANDLER_MSG_ID); - } - - private void removeTestProviderGPS() { - try { - if (mLocManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { - mLocManager.setTestProviderEnabled(LocationManager.GPS_PROVIDER, false); - mLocManager.removeTestProvider(LocationManager.GPS_PROVIDER); - } - } catch (Exception e) { - XLog.e("SERVICEGO: ERROR - removeTestProviderGPS"); - } - } - - // 注意下面临时添加 @SuppressLint("wrongconstant") 以处理 addTestProvider 参数值的 lint 错误 - @SuppressLint("wrongconstant") - private void addTestProviderGPS() { - try { - // 注意,由于 android api 问题,下面的参数会提示错误(以下参数是通过相关API获取的真实GPS参数,不是随便写的) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - mLocManager.addTestProvider(LocationManager.GPS_PROVIDER, false, true, false, - false, true, true, true, ProviderProperties.POWER_USAGE_HIGH, ProviderProperties.ACCURACY_FINE); - } else { - mLocManager.addTestProvider(LocationManager.GPS_PROVIDER, false, true, false, - false, true, true, true, Criteria.POWER_HIGH, Criteria.ACCURACY_FINE); - } - if (!mLocManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { - mLocManager.setTestProviderEnabled(LocationManager.GPS_PROVIDER, true); - } - } catch (Exception e) { - XLog.e("SERVICEGO: ERROR - addTestProviderGPS"); - } - } - - private void setLocationGPS() { - try { - // 尽可能模拟真实的 GPS 数据 - Location loc = new Location(LocationManager.GPS_PROVIDER); - loc.setAccuracy(Criteria.ACCURACY_FINE); // 设定此位置的估计水平精度,以米为单位。 - loc.setAltitude(mCurAlt); // 设置高度,在 WGS 84 参考坐标系中的米 - loc.setBearing(mCurBea); // 方向(度) - loc.setLatitude(mCurLat); // 纬度(度) - loc.setLongitude(mCurLng); // 经度(度) - loc.setTime(System.currentTimeMillis()); // 本地时间 - loc.setSpeed((float) mSpeed); - loc.setElapsedRealtimeNanos(SystemClock.elapsedRealtimeNanos()); - Bundle bundle = new Bundle(); - bundle.putInt("satellites", 7); - loc.setExtras(bundle); - - mLocManager.setTestProviderLocation(LocationManager.GPS_PROVIDER, loc); - } catch (Exception e) { - XLog.e("SERVICEGO: ERROR - setLocationGPS"); - } - } - - private void removeTestProviderNetwork() { - try { - if (mLocManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { - mLocManager.setTestProviderEnabled(LocationManager.NETWORK_PROVIDER, false); - mLocManager.removeTestProvider(LocationManager.NETWORK_PROVIDER); - } - } catch (Exception e) { - XLog.e("SERVICEGO: ERROR - removeTestProviderNetwork"); - } - } - - // 注意下面临时添加 @SuppressLint("wrongconstant") 以处理 addTestProvider 参数值的 lint 错误 - @SuppressLint("wrongconstant") - private void addTestProviderNetwork() { - try { - // 注意,由于 android api 问题,下面的参数会提示错误(以下参数是通过相关API获取的真实NETWORK参数,不是随便写的) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - mLocManager.addTestProvider(LocationManager.NETWORK_PROVIDER, true, false, - true, true, true, true, - true, ProviderProperties.POWER_USAGE_LOW, ProviderProperties.ACCURACY_COARSE); - } else { - mLocManager.addTestProvider(LocationManager.NETWORK_PROVIDER, true, false, - true, true, true, true, - true, Criteria.POWER_LOW, Criteria.ACCURACY_COARSE); - } - if (!mLocManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { - mLocManager.setTestProviderEnabled(LocationManager.NETWORK_PROVIDER, true); - } - } catch (SecurityException e) { - XLog.e("SERVICEGO: ERROR - addTestProviderNetwork"); - } - } - - private void setLocationNetwork() { - try { - // 尽可能模拟真实的 NETWORK 数据 - Location loc = new Location(LocationManager.NETWORK_PROVIDER); - loc.setAccuracy(Criteria.ACCURACY_COARSE); // 设定此位置的估计水平精度,以米为单位。 - loc.setAltitude(mCurAlt); // 设置高度,在 WGS 84 参考坐标系中的米 - loc.setBearing(mCurBea); // 方向(度) - loc.setLatitude(mCurLat); // 纬度(度) - loc.setLongitude(mCurLng); // 经度(度) - loc.setTime(System.currentTimeMillis()); // 本地时间 - loc.setSpeed((float) mSpeed); - loc.setElapsedRealtimeNanos(SystemClock.elapsedRealtimeNanos()); - - mLocManager.setTestProviderLocation(LocationManager.NETWORK_PROVIDER, loc); - } catch (Exception e) { - XLog.e("SERVICEGO: ERROR - setLocationNetwork"); - } - } - - public class NoteActionReceiver extends BroadcastReceiver { - @Override - public void onReceive(Context context, Intent intent) { - String action = intent.getAction(); - if (action != null) { - if (action.equals(SERVICE_GO_NOTE_ACTION_JOYSTICK_SHOW)) { - mJoyStick.show(); - } - - if (action.equals(SERVICE_GO_NOTE_ACTION_JOYSTICK_HIDE)) { - mJoyStick.hide(); - } - } - } - } - - public class ServiceGoBinder extends Binder { - public void setPosition(double lng, double lat, double alt) { - mLocHandler.removeMessages(HANDLER_MSG_ID); - mCurLng = lng; - mCurLat = lat; - mCurAlt = alt; - mLocHandler.sendEmptyMessage(HANDLER_MSG_ID); - mJoyStick.setCurrentPosition(mCurLng, mCurLat, mCurAlt); - } - } -} - - +package com.zcshou.service; + +import android.annotation.SuppressLint; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.location.Criteria; +import android.location.Location; +import android.location.LocationManager; +import android.location.provider.ProviderProperties; +import android.os.Binder; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; +import android.os.Message; +import android.os.Process; +import android.os.SystemClock; + +import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; +import androidx.preference.PreferenceManager; + +import com.elvishew.xlog.XLog; +import com.zcshou.gogogo.MainActivity; +import com.zcshou.gogogo.R; +import com.zcshou.joystick.JoyStick; +import com.zcshou.script.ScriptParser; +import com.zcshou.script.ScriptPlayer; +import com.zcshou.script.ScriptRoute; +import com.zcshou.script.ScriptStore; +import com.zcshou.script.ScriptWaypoint; +import com.zcshou.utils.GoUtils; + +public class ServiceGo extends Service { + // 定位相关变量 + public static final double DEFAULT_LAT = 36.667662; + public static final double DEFAULT_LNG = 117.027707; + public static final double DEFAULT_ALT = 55.0D; + public static final float DEFAULT_BEA = 0.0F; + /* 下面几个位置字段会同时被主线程(绑定调用 / 摇杆)和定位线程(脚本推进)读写,统一加 volatile */ + private volatile double mCurLat = DEFAULT_LAT; + private volatile double mCurLng = DEFAULT_LNG; + private volatile double mCurAlt = DEFAULT_ALT; + private volatile float mCurBea = DEFAULT_BEA; + private volatile double mSpeed = 1.2; /* 默认的速度,单位 m/s */ + /* + * 真实定位与模拟位置是两条独立的链路:真实定位只经由 applyRealLocation() 进来, + * 且只在“没有任何模拟位置在接管”时才允许写入 mCurLat / mCurLng。 + * 以前两者共用这两个字段,导致“切一次应用脚本就失灵、位置回到真实位置”。 + */ + private static final int HANDLER_MSG_ID = 0; + private static final String SERVICE_GO_HANDLER_NAME = "ServiceGoLocation"; + /* 服务被系统重新拉起(intent 为 null)时,用这里保存的上次位置恢复,避免拿不到位置 */ + private static final String KEY_LAST_LNG = "service_last_lng"; + private static final String KEY_LAST_LAT = "service_last_lat"; + private static final String KEY_LAST_ALT = "service_last_alt"; + /** 服务被系统重新拉起时,用来恢复正在播放的脚本 */ + private static final String KEY_LAST_SCRIPT_ID = "service_last_script_id"; + /* 脚本播放状态 */ + public static final int SCRIPT_STATE_IDLE = 0; + public static final int SCRIPT_STATE_PLAYING = 1; + public static final int SCRIPT_STATE_FINISHED = 2; + /** 暂停中:位置停在原地、进度保留,随时可以继续播放 */ + public static final int SCRIPT_STATE_PAUSED = 3; + private volatile int mScriptState = SCRIPT_STATE_IDLE; + private LocationManager mLocManager; + private HandlerThread mLocHandlerThread; + private Handler mLocHandler; + private boolean isStop = false; + // 通知栏消息 + private static final int SERVICE_GO_NOTE_ID = 1; + private static final String SERVICE_GO_NOTE_ACTION_JOYSTICK_SHOW = "ShowJoyStick"; + private static final String SERVICE_GO_NOTE_ACTION_JOYSTICK_HIDE = "HideJoyStick"; + /** 通知栏“暂停 / 继续”动作(脚本播放中才挂这个按钮) */ + private static final String SERVICE_GO_NOTE_ACTION_SCRIPT_PAUSE = "PauseScript"; + private static final String SERVICE_GO_NOTE_ACTION_SCRIPT_RESUME = "ResumeScript"; + private static final String SERVICE_GO_NOTE_CHANNEL_ID = "SERVICE_GO_NOTE"; + private NoteActionReceiver mActReceiver; + /** 通知栏管理器:状态变化时刷新文案与“暂停 / 继续”按钮 */ + private NotificationManager mNoteManager; + // 摇杆相关 + private JoyStick mJoyStick; + // 脚本相关 + private ScriptPlayer mScriptPlayer; + private ScriptRoute mScriptRoute; + private ScriptStore mScriptStore; + private ScriptListener mScriptListener; + private ClickMoveListener mClickMoveListener; + + /* 点击移动:主界面左下角开关控制,点地图朝目标点按“行走方式”持续走 */ + /** true = 点击移动生效(false = 摇杆移动);开启时摇杆方向输入暂停 */ + private volatile boolean mClickMoveEnabled; + /** 有一个未到达的行走目标 */ + private volatile boolean mHasClickTarget; + /** 点击行走是否暂停中:位置停在原地,目标保留,继续时朝同一目标接着走 */ + private volatile boolean mClickMovePaused; + private volatile double mClickTargetLng; + private volatile double mClickTargetLat; + /** 上一次朝目标推进的时刻(毫秒),按真实间隔步进 */ + private long mLastClickStepMs; + + /** 脚本播放状态回调,供界面展示“第几个点 / 当前状态” */ + public interface ScriptListener { + void onScriptSegment(ScriptRoute route, int index, ScriptWaypoint.Mode mode, double speed); + + void onScriptFinish(ScriptRoute route); + + void onScriptStopped(); + + /** 进入暂停(位置停在原地、进度保留),界面据此把“暂停 / 继续”切到另一侧 */ + void onScriptPaused(); + + /** 从暂停处继续播放 */ + void onScriptResumed(); + } + + /** + * 点击移动“到达一个目标”的回调。 + * + *

目标点列表由主界面维护(可以连着点好几个),服务端只负责“走到下一个”, + * 到站后通过本回调通知界面,由界面决定是否继续下发后续目标、或者宣告走完。 + * 回调在定位线程触发,实现方需要切主线程再碰界面。

+ */ + public interface ClickMoveListener { + /** 到达一个目标点(经纬度为 WGS-84),界面据此决定是否继续下发下一个 */ + void onClickTargetArrived(double lng, double lat); + } + + private final ServiceGoBinder mBinder = new ServiceGoBinder(); + + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } + + @Override + public void onCreate() { + super.onCreate(); + + XLog.i("SERVICEGO: onCreate"); + + mLocManager = (LocationManager) this.getSystemService(Context.LOCATION_SERVICE); + + mScriptStore = new ScriptStore(this); + + removeTestProviderNetwork(); + addTestProviderNetwork(); + + removeTestProviderGPS(); + addTestProviderGPS(); + + initGoLocation(); + + initNotification(); + + initJoyStick(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + double lng = DEFAULT_LNG; + double lat = DEFAULT_LAT; + double alt = DEFAULT_ALT; + String scriptId = null; + + if (intent != null) { + lng = intent.getDoubleExtra(MainActivity.LNG_MSG_ID, DEFAULT_LNG); + lat = intent.getDoubleExtra(MainActivity.LAT_MSG_ID, DEFAULT_LAT); + alt = intent.getDoubleExtra(MainActivity.ALT_MSG_ID, DEFAULT_ALT); + scriptId = intent.getStringExtra(MainActivity.SCRIPT_ROUTE_ID); + } else { + // 服务被系统重新拉起时 intent 为 null(原实现在这里会直接 NPE 闪退), + // 这里改用上次保存的位置,保证服务能正常恢复 + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + lng = parseDouble(preferences.getString(KEY_LAST_LNG, null), DEFAULT_LNG); + lat = parseDouble(preferences.getString(KEY_LAST_LAT, null), DEFAULT_LAT); + alt = parseDouble(preferences.getString(KEY_LAST_ALT, null), DEFAULT_ALT); + scriptId = preferences.getString(KEY_LAST_SCRIPT_ID, null); + XLog.i("SERVICEGO: restart with null intent"); + } + + mCurLng = lng; + mCurLat = lat; + mCurAlt = alt; + // 顺序很重要:先把 mCur 写好,再保存。 + // 这里刚刚从 intent / 上次位置恢复坐标,此时“脚本还没开始、服务已经活着”, + // 若 saveLastPosition 内部按“服务活着 = 模拟接管”提前 return,恢复用的坐标就存不下来了 + saveLastPosition(); + + if (mJoyStick != null) { + try { + mJoyStick.setCurrentPosition(mCurLng, mCurLat, mCurAlt); + } catch (Exception e) { + XLog.e("SERVICEGO: ERROR - setCurrentPosition", e); + } + } + + // 脚本模式:按脚本预设自动移动;没有脚本 id 时保持原来的“定点点位”行为 + if (!isBlank(scriptId)) { + startScript(scriptId); + } else if (intent != null) { + // 手动传送(带坐标、不带脚本 id)会打断正在播放的脚本: + // 否则脚本线程会继续覆盖位置,界面却提示“已传送到新位置”,两边打架 + if (mScriptPlayer != null && mScriptPlayer.isPlaying()) { + XLog.i("SERVICEGO: manual teleport stops running script"); + stopScript(); + } + } + + return START_STICKY; + } + + @Override + public void onDestroy() { + isStop = true; + + if (mScriptPlayer != null) { + mScriptPlayer.stop(); + mScriptPlayer = null; + } + mScriptRoute = null; + mScriptState = SCRIPT_STATE_IDLE; + mScriptListener = null; + + // 服务销毁 = 脚本播放彻底结束,把“正在播放”的持久状态一并清掉。 + // 之前只在 stopScript() 里清理,而“没绑定上服务时点停止”走的是 stopService() 路径, + // 存储里的运行 id 会残留:下次 onResume 读到过期状态,界面把“开始移动”显示成 + // “停止移动”,点开始又走成了停止,表现为“停止后再开始不移动”。 + // 系统杀进程(SIGKILL)不会走到 onDestroy,服务重启恢复脚本的逻辑不受影响。 + try { + if (mScriptStore != null) { + mScriptStore.setRunningScriptId(null); + } + PreferenceManager.getDefaultSharedPreferences(this).edit() + .remove(KEY_LAST_SCRIPT_ID) + .apply(); + } catch (Exception e) { + XLog.e("SERVICEGO: ERROR - clear script state on destroy"); + } + + try { + if (mLocHandler != null) { + mLocHandler.removeMessages(HANDLER_MSG_ID); + } + if (mLocHandlerThread != null) { + mLocHandlerThread.quit(); + } + } catch (Exception e) { + XLog.e("SERVICEGO: ERROR - stop location thread", e); + } + mLocHandler = null; + mLocHandlerThread = null; + + if (mJoyStick != null) { + try { + mJoyStick.destroy(); + } catch (Throwable e) { + XLog.e("SERVICEGO: ERROR - destroy joystick", e); + } + mJoyStick = null; + } + + removeTestProviderNetwork(); + removeTestProviderGPS(); + + if (mActReceiver != null) { + try { + unregisterReceiver(mActReceiver); + } catch (Exception e) { + XLog.e("SERVICEGO: ERROR - unregisterReceiver", e); + } + mActReceiver = null; + } + + try { + stopForeground(STOP_FOREGROUND_REMOVE); + } catch (Exception e) { + XLog.e("SERVICEGO: ERROR - stopForeground", e); + } + + super.onDestroy(); + } + + private void initNotification() { + try { + mActReceiver = new NoteActionReceiver(); + IntentFilter filter = new IntentFilter(); + filter.addAction(SERVICE_GO_NOTE_ACTION_JOYSTICK_SHOW); + filter.addAction(SERVICE_GO_NOTE_ACTION_JOYSTICK_HIDE); + filter.addAction(SERVICE_GO_NOTE_ACTION_SCRIPT_PAUSE); + filter.addAction(SERVICE_GO_NOTE_ACTION_SCRIPT_RESUME); + registerReceiver(mActReceiver, filter); + + // 渠道名会原样显示在系统“通知设置”里,必须是给用户看的文字 + String channelName = getResources().getString(R.string.app_note_channel_name); + NotificationChannel mChannel = new NotificationChannel(SERVICE_GO_NOTE_CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_DEFAULT); + mNoteManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + + if (mNoteManager != null) { + mNoteManager.createNotificationChannel(mChannel); + } + + startForeground(SERVICE_GO_NOTE_ID, buildNotification()); + } catch (Throwable e) { + // 前台服务起不来时,系统会在 5 秒后直接杀掉进程,这里主动结束服务更安全 + XLog.e("SERVICEGO: ERROR - initNotification", e); + stopSelf(); + } + } + + /** + * 按当前脚本状态构建通知。 + * + *

脚本播放中会多挂一个“暂停”按钮,暂停后同一个按钮变成“继续”、 + * 文案也换成“已暂停”——通知栏是脚本在后台跑时唯一够得着的控制入口。

+ */ + private Notification buildNotification() { + //准备intent(广播都限定在本应用内,避免同名 action 被别的应用抢先接收) + Intent clickIntent = new Intent(this, MainActivity.class); + PendingIntent clickPI = PendingIntent.getActivity(this, 1, clickIntent, PendingIntent.FLAG_IMMUTABLE); + Intent showIntent = new Intent(SERVICE_GO_NOTE_ACTION_JOYSTICK_SHOW).setPackage(getPackageName()); + PendingIntent showPendingPI = PendingIntent.getBroadcast(this, 0, showIntent, PendingIntent.FLAG_IMMUTABLE); + Intent hideIntent = new Intent(SERVICE_GO_NOTE_ACTION_JOYSTICK_HIDE).setPackage(getPackageName()); + PendingIntent hidePendingPI = PendingIntent.getBroadcast(this, 0, hideIntent, PendingIntent.FLAG_IMMUTABLE); + + boolean scriptActive = mScriptState == SCRIPT_STATE_PLAYING || mScriptState == SCRIPT_STATE_PAUSED; + boolean scriptPaused = mScriptState == SCRIPT_STATE_PAUSED; + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, SERVICE_GO_NOTE_CHANNEL_ID) + .setChannelId(SERVICE_GO_NOTE_CHANNEL_ID) + .setContentTitle(getResources().getString(R.string.app_name)) + .setContentText(getResources().getString( + scriptPaused ? R.string.note_script_paused : R.string.app_service_tips)) + .setContentIntent(clickPI) + .addAction(new NotificationCompat.Action(R.drawable.ic_position, getResources().getString(R.string.note_show), showPendingPI)) + .addAction(new NotificationCompat.Action(R.drawable.ic_fly, getResources().getString(R.string.note_hide), hidePendingPI)) + // 通知小图标不能再用 R.mipmap.ic_launcher:自适应图标在通知栏只取前景层, + // 而新图标的前景是透明的(整张图在背景层),会显示成空白。 + // 这里直接用图钉矢量,和原版通知图标的轮廓保持一致。 + .setSmallIcon(R.drawable.ic_launcher_foreground); + + if (scriptActive) { + // 同一个按钮按状态切换动作与图标:播放中 = 暂停,暂停中 = 继续 + Intent pauseIntent = new Intent(scriptPaused + ? SERVICE_GO_NOTE_ACTION_SCRIPT_RESUME : SERVICE_GO_NOTE_ACTION_SCRIPT_PAUSE) + .setPackage(getPackageName()); + PendingIntent pausePendingPI = PendingIntent.getBroadcast(this, 2, pauseIntent, PendingIntent.FLAG_IMMUTABLE); + builder.addAction(new NotificationCompat.Action( + scriptPaused ? R.drawable.ic_play : R.drawable.ic_pause, + getResources().getString(scriptPaused ? R.string.note_script_resume : R.string.note_script_pause), + pausePendingPI)); + } + + return builder.build(); + } + + /** 状态变化后刷新通知(文案 + “暂停 / 继续”按钮),失败只记日志不影响播放 */ + private void refreshNotification() { + try { + if (mNoteManager == null) { + mNoteManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + } + if (mNoteManager != null) { + mNoteManager.notify(SERVICE_GO_NOTE_ID, buildNotification()); + } + } catch (Throwable e) { + XLog.e("SERVICEGO: ERROR - refreshNotification", e); + } + } + + private void initJoyStick() { + try { + mJoyStick = new JoyStick(this); + mJoyStick.setListener(new JoyStick.JoyStickClickListener() { + @Override + public void onMoveInfo(double speed, double disLng, double disLat, double angle) { + // 位置的两种接管者:脚本播放(否则位移不到 100ms 就被覆盖)、 + // 或点击移动开启中(点击与摇杆是切换关系,此时摇杆方向输入暂停) + if (isScriptDrivingPosition() || mClickMoveEnabled) { + return; + } + + mSpeed = speed; + // 根据当前的经纬度和距离,计算下一个经纬度 + // Latitude: 1 deg = 110.574 km // 纬度的每度的距离大约为 110.574km + // Longitude: 1 deg = 111.320*cos(latitude) km // 经度的每度的距离从0km到111km不等 + // 具体见:http://wp.mlab.tw/?p=2200 + mCurLng += disLng / (111.320 * Math.cos(Math.abs(mCurLat) * Math.PI / 180)); + mCurLat += disLat / 110.574; + mCurBea = (float) angle; + } + + @Override + public void onPositionInfo(double lng, double lat, double alt) { + // 脚本正在接管位置:忽略这次选点传送并给出提示。 + // 以前会直接改位置——表现就是“位置被拉回选点处、脚本移动被打断” + if (isScriptDrivingPosition()) { + GoUtils.DisplayToast(ServiceGo.this, + getString(R.string.joystick_ignore_teleport)); + return; + } + + mCurLng = lng; + mCurLat = lat; + mCurAlt = alt; + // 与 binder.setPosition 的手动传送保持一致:速度、方向复位, + // 否则“跑完脚本(speed=0)再手动选点”会一直注入 speed=0,上层应用会判定为没在移动 + mSpeed = 1.2; + mCurBea = DEFAULT_BEA; + // 传送到了新位置,进行中的点击移动目标作废 + mHasClickTarget = false; + mClickMovePaused = false; + saveLastPosition(); + } + }); + mJoyStick.show(); + } catch (Throwable e) { + // 悬浮窗(摇杆)创建失败不应该让整个进程崩溃:模拟位置本身仍然可用 + mJoyStick = null; + XLog.e("SERVICEGO: ERROR - initJoyStick", e); + } + } + + private void initGoLocation() { + if (mLocManager == null) { + XLog.e("SERVICEGO: ERROR - LocationManager is null"); + return; + } + + // 创建 HandlerThread 实例,第一个参数是线程的名字 + mLocHandlerThread = new HandlerThread(SERVICE_GO_HANDLER_NAME, Process.THREAD_PRIORITY_FOREGROUND); + // 启动 HandlerThread 线程 + mLocHandlerThread.start(); + // Handler 对象与 HandlerThread 的 Looper 对象的绑定 + mLocHandler = new Handler(mLocHandlerThread.getLooper()) { + // 这里的Handler对象可以看作是绑定在HandlerThread子线程中,所以handlerMessage里的操作是在子线程中运行的 + @Override + public void handleMessage(@NonNull Message msg) { + if (isStop) { + return; + } + + try { + Thread.sleep(100); + + if (!isStop) { + // 顺序:先把位置推进到“本拍”,再注入。 + // + // 以前点击移动排在注入前、脚本 onTick 排在注入后, + // 结果脚本每轮注入的都是上一拍算出的坐标,恒定滞后一个 tick(约 100ms), + // 而点击移动不滞后——两者表现不一致。现在统一“先推进、后注入”。 + // + // 两者互斥:appendClickTarget 在脚本播放中会直接拒绝, + // startScript 也会作废进行中的点击目标,所以用 else 表达这个互斥。 + if (isScriptDrivingPosition()) { + mScriptPlayer.onTick(); + } else { + advanceClickTarget(); + } + + setLocationNetwork(); + setLocationGPS(); + } + } catch (InterruptedException e) { + XLog.e("SERVICEGO: ERROR - handleMessage", e); + Thread.currentThread().interrupt(); + return; + } catch (Throwable e) { + // 注入模拟位置失败不能让进程崩溃,下一轮继续重试 + XLog.e("SERVICEGO: ERROR - handleMessage", e); + } + + if (!isStop) { + sendEmptyMessage(HANDLER_MSG_ID); + } + } + }; + + mLocHandler.sendEmptyMessage(HANDLER_MSG_ID); + } + + private static double parseDouble(String value, double defaultValue) { + if (value == null) { + return defaultValue; + } + + try { + return Double.parseDouble(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + /** + * 注入用的朝向:统一到 0~360 并避开 0。 + * + *

Location.setBearing(0) 会把“有朝向”标记清掉(hasBearing() 变成 false), + * 地图定位图层就会改画小人而不是方向箭头;脚本到达终点段的 bearing(点,点)、 + * 服务默认朝向都会算出 0,这里统一挡掉,保证人物箭头始终显示。

+ */ + private float injectionBearing() { + float bearing = (mCurBea + 360.0f) % 360.0f; + return bearing == 0.0f ? 0.01f : bearing; + } + + /*============================== 点击移动 ==============================*/ + + /** 切换“点击移动 ⇄ 摇杆移动”(主界面左下角开关);切换即作废进行中的行走目标 */ + private void setClickMoveMode(boolean enabled) { + mClickMoveEnabled = enabled; + mHasClickTarget = false; + // 目标都作废了,“暂停中”自然也无从谈起,一并复位避免残留 + mClickMovePaused = false; + + if (mJoyStick != null) { + try { + // 收起 / 恢复方向摇杆(点击与摇杆为切换关系,行走方式按钮保留) + mJoyStick.setClickMoveEnabled(enabled); + } catch (Exception e) { + XLog.e("SERVICEGO: ERROR - setClickMoveEnabled", e); + } + } + } + + /** + * 暂停点击行走:位置停在原地,当前目标与朝向都保留。 + * + *

暂停只是“不再朝目标推进”,与“停止行走”(放弃当前目标)不同: + * 继续时从原地朝同一个目标接着走。同时把注入速度记为 0, + * 避免上层应用看到“位置不动却还在移动”。

+ * + * @return 是否进入暂停(没在朝目标走时返回 false) + */ + public boolean pauseClickMove() { + if (!mClickMoveEnabled || !mHasClickTarget) { + return false; + } + if (mClickMovePaused) { + return true; + } + + mClickMovePaused = true; + mSpeed = 0; + + // 刚好在这一瞬间走到了目标(目标已作废):没有可暂停的行走。 + // 回退暂停标记,避免留下“暂停中但没有目标”的残留状态 + if (!mHasClickTarget) { + mClickMovePaused = false; + return false; + } + return true; + } + + /** 继续点击行走,从暂停处朝同一目标接着走;没有暂停中的行走时返回 false */ + public boolean resumeClickMove() { + if (!mClickMovePaused) { + return false; + } + + mClickMovePaused = false; + return true; + } + + /** 点击行走是否处于暂停中(界面据此把“暂停 / 继续”按钮切到正确一侧) */ + public boolean isClickMovePaused() { + return mClickMovePaused; + } + + /** + * 点击移动当前使用的速度(米/秒):跟随悬浮摇杆上的走 / 跑 / 骑选择。 + * + *

悬浮窗创建失败时 mJoyStick 为空,这里回落到设置里的步行速度, + * 否则点击移动会直接不动。

+ */ + private double clickMoveSpeed() { + double speed = mJoyStick != null ? mJoyStick.getCurrentSpeed() : 0; + if (speed <= 0) { + speed = getModeSpeeds()[0]; + } + return speed <= 0 ? 1.2D : speed; + } + + /** + * 点击移动:沿线朝当前目标持续前进;到站后由界面接上下一个目标。 + * + *

目标由主界面维护(可以连着点好几个),这里只负责“走到它”, + * 到达后回调 {@link ClickMoveListener#onClickTargetArrived}, + * 由界面决定把下一个点发下来还是宣告走完。这样即使用户在行走途中 + * 继续点地图,也只是往队列尾部追加,不会打断当前这一段。

+ */ + private void advanceClickTarget() { + if (!mClickMoveEnabled || !mHasClickTarget) { + return; + } + // 播放中与暂停中都由脚本占有位置:暂停只是“不推进”,点击移动同样不能插队 + if (isScriptDrivingPosition()) { + return; + } + + long now = System.currentTimeMillis(); + long last = mLastClickStepMs; + mLastClickStepMs = now; + + // 暂停中:位置停在原地,只把步进计时往前挪——否则恢复那一拍会按“暂停的时长” + // 算 dt,一步跨出去老远(骑行能一次跳 10 米)。目标与朝向都保留, + // 继续时从原地朝同一个目标接着走 + if (mClickMovePaused) { + return; + } + + double dt = Math.min(1.0D, Math.max(0.0D, (now - last) / 1000.0D)); + if (dt <= 0) { + return; + } + + double speed = clickMoveSpeed(); + double step = speed * dt; + + double distance = ScriptPlayer.distanceMeters(mCurLng, mCurLat, mClickTargetLng, mClickTargetLat); + float bearing = bearingBetween(mCurLng, mCurLat, mClickTargetLng, mClickTargetLat); + mCurBea = bearing; + mSpeed = speed; + + boolean arrived = distance <= Math.max(step, 0.05D); + if (arrived) { + // 到达:落点精确对齐目标 + mCurLng = mClickTargetLng; + mCurLat = mClickTargetLat; + mHasClickTarget = false; + // 没有目标可暂停了(也顺带消掉“暂停与到达同时发生”的竞态残留) + mClickMovePaused = false; + } else { + // 一步的位移很小,平面近似的误差可以忽略 + double ratio = step / distance; + mCurLng += (mClickTargetLng - mCurLng) * ratio; + mCurLat += (mClickTargetLat - mCurLat) * ratio; + } + + if (mJoyStick != null) { + try { + mJoyStick.setCurrentPosition(mCurLng, mCurLat, mCurAlt); + } catch (Exception e) { + XLog.e("SERVICEGO: ERROR - click setCurrentPosition", e); + } + } + + // 到达一个目标:交给界面决定“接着走下一个”还是“走完了” + if (arrived) { + ClickMoveListener listener = mClickMoveListener; + if (listener != null) { + try { + listener.onClickTargetArrived(mCurLng, mCurLat); + } catch (Exception e) { + XLog.e("SERVICEGO: ERROR - onClickTargetArrived", e); + } + } + } + } + + /** + * 两点间方位角(0~360,正北为 0)。 + * + *

公式统一走 {@link ScriptParser#bearing(double, double, double, double)}, + * 这里只保留注入端特有的处理:避开恰好 0 的朝向(见 {@link #injectionBearing()})。

+ */ + private static float bearingBetween(double lng1, double lat1, double lng2, double lat2) { + float bearing = (float) ScriptParser.bearing(lng1, lat1, lng2, lat2); + return bearing == 0.0f ? 0.01f : bearing; + } + + /*============================== 脚本模式 ==============================*/ + + /** 创建一个绑定到本服务的脚本播放器(只创建一次) */ + private void initScriptPlayer() { + mScriptPlayer = new ScriptPlayer(); + mScriptPlayer.setHandler(new ScriptPlayer.Handler() { + @Override + public void onScriptStart(ScriptRoute route, double lng, double lat, double alt) { + XLog.i("SERVICEGO: script start - " + route.name); + } + + @Override + public synchronized void onScriptPosition(double lng, double lat, double alt, ScriptWaypoint.Mode mode, + double speed, double bearing) { + // 注意:这里运行在定位线程上,写的是 volatile 字段,随后由定位循环注入给系统 + mCurLng = lng; + mCurLat = lat; + mCurAlt = alt; + mSpeed = speed; + mCurBea = (float) bearing; + + if (mJoyStick != null) { + try { + mJoyStick.setCurrentPosition(lng, lat, alt); + } catch (Exception e) { + XLog.e("SERVICEGO: ERROR - script setCurrentPosition", e); + } + } + } + + @Override + public synchronized void onScriptSegment(ScriptRoute route, int index, ScriptWaypoint.Mode mode, double speed) { + XLog.i("SERVICEGO: script segment " + index + " - " + mode.key + " " + speed + "m/s"); + if (mScriptListener != null) { + mScriptListener.onScriptSegment(route, index, mode, speed); + } + } + + @Override + public synchronized void onScriptFinish(ScriptRoute route) { + mScriptState = SCRIPT_STATE_FINISHED; + XLog.i("SERVICEGO: script finished - " + route.name); + // 跑完只“不再推进”,坐标保持在终点:服务还活着,注入循环会继续维持这个位置 + // (这里若交还真实定位,表现就是“脚本跑完位置突然跳回手机所在地”)。 + // 同时立刻清掉“正在播放”的持久状态与重启恢复标记,否则界面会一直按“播放中” + // 处理——路线条显示“停止”、要先停一次才能再模拟、点击移动被拒、 + // 单点传送被要求确认,表现为“脚本结束后模拟位置不能立刻实现” + try { + if (mScriptStore != null) { + mScriptStore.setRunningScriptId(null); + } + // 注意:这里在匿名 Handler 内部,this 是匿名对象不是 Service,必须写 ServiceGo.this + PreferenceManager.getDefaultSharedPreferences(ServiceGo.this).edit() + .remove(KEY_LAST_SCRIPT_ID) + .apply(); + } catch (Exception e) { + XLog.e("SERVICEGO: ERROR - clear script state on finish"); + } + // 跑完后通知栏撤掉“暂停”按钮,文案回到默认 + refreshNotification(); + if (mScriptListener != null) { + mScriptListener.onScriptFinish(route); + } + } + + @Override + public synchronized void onScriptError(String message) { + mScriptState = SCRIPT_STATE_IDLE; + XLog.e("SERVICEGO: script error - " + message); + refreshNotification(); + if (mScriptListener != null) { + mScriptListener.onScriptStopped(); + } + } + }); + } + + /** 读取设置里各状态的默认速度,顺序为 步行 / 跑步 / 骑行 */ + private double[] getModeSpeeds() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + + double walk = parseDouble(preferences.getString("setting_walk", "1.2"), 1.2); + double run = parseDouble(preferences.getString("setting_run", "3.6"), 3.6); + double bike = parseDouble(preferences.getString("setting_bike", "10.0"), 10.0); + + return ScriptParser.modeSpeeds(walk, run, bike); + } + + private double getSettingAltitude() { + return parseDouble( + PreferenceManager.getDefaultSharedPreferences(this).getString("setting_altitude", "55.0"), DEFAULT_ALT); + } + + /** + * 脚本此刻是否真正在接管位置:状态与播放器双确认。 + * + *

只看 mScriptState 会被“状态残留”骗到(状态是 PLAYING、播放器其实早已停了), + * 表现为摇杆怎么推都没反应;双确认后,残留状态既锁不住摇杆,也会在下次启动时被纠正。

+ * + *

暂停中也算接管:脚本只是不推进,位置仍归它保持,恢复时才能原地继续。

+ */ + private boolean isScriptDrivingPosition() { + return (mScriptState == SCRIPT_STATE_PLAYING || mScriptState == SCRIPT_STATE_PAUSED) + && mScriptPlayer != null + && mScriptPlayer.isPlaying(); + } + + /** + * 当前是否由“模拟位置”接管坐标。判定刻意做得宽松(宁可多拦,不可漏拦): + * + *
    + *
  • 脚本:状态 + 播放器双确认,状态残留时不会误锁摇杆;
  • + *
  • 点击移动:目标还没走完就一直算接管;
  • + *
  • 单点模拟:没有“正在进行中”的标记,就用“服务还活着”兜底—— + * 注入循环一直在跑,真实定位此时绝不能插进来。
  • + *
+ * + *

只看前两条会漏掉单点模拟(表现:模拟位置刚启动就被真实定位拉回手机所在地), + * 所以必须带上 {@code !isStop} 这一层。

+ */ + private boolean isMockDrivingPosition() { + if (isScriptDrivingPosition()) { + return true; + } + if (mClickMoveEnabled && mHasClickTarget) { + return true; + } + // 服务活着就认为模拟位置在接管:mLocHandler 一直在循环注入坐标 + return !isStop; + } + + /** + * 接收一次真实定位(主界面 / 摇杆从系统定位拿到坐标时回调)。 + * + *

模拟位置在接管时直接丢弃,不写坐标也不打日志——这是每秒都会来好几次的 + * 正常路径,刷日志只会把有用信息淹掉。切换应用时系统会补发一次真实定位, + * 若这里直接覆盖,就会出现“切一次应用,模拟位置回到手机真实位置、脚本失灵”。

+ * + *

反过来说,真的走到“没有模拟在接管却仍收到定位”这条分支才算异常, + * 这时才记一条日志,方便排查。

+ * + * @return 是否把当前位置也更新了 + */ + public boolean applyRealLocation(double lng, double lat) { + if (lng == 0 && lat == 0) { + return false; + } + + if (isMockDrivingPosition()) { + // 模拟位置在接管:丢弃真实定位(不写坐标) + return false; + } + + XLog.i("SERVICEGO: mock is not driving, adopt real location " + lng + "," + lat); + mCurLng = lng; + mCurLat = lat; + return true; + } + + /** + * 开始播放指定 id 的脚本。 + * + *

位置对齐、状态切换、循环都由 {@link ScriptPlayer} 负责,这里只做加载与启动。

+ */ + private void startScript(String scriptId) { + if (mScriptStore == null) { + mScriptStore = new ScriptStore(this); + } + + ScriptRoute route = mScriptStore.load(scriptId); + if (route == null || route.points.isEmpty()) { + XLog.e("SERVICEGO: script not found - " + scriptId); + // 加载失败也把状态拉回 IDLE:否则“状态停在 PLAYING 但播放器没在跑”的残留 + // 会同时锁死摇杆(被当成播放中忽略)并让界面的兜底启动误判“已在播”而跳过 + mScriptState = SCRIPT_STATE_IDLE; + refreshNotification(); + if (mScriptListener != null) { + mScriptListener.onScriptStopped(); + } + return; + } + + // 每次启动都按最新文本重新解析,保证编辑过的脚本立刻生效 + if (!isBlank(route.text)) { + // 坐标系优先跟着脚本自己走;旧脚本没有记录时才回落到全局设置, + // 避免用户改一次全局单选就让这条脚本整体偏移几百米 + boolean fromBd09 = route.fromBd09 != null + ? route.fromBd09 + : PreferenceManager.getDefaultSharedPreferences(this) + .getBoolean(ScriptParser.KEY_SCRIPT_FROM_BD09, false); + ScriptParser.ParseResult parseResult = ScriptParser.parse(route.text, fromBd09, getModeSpeeds()); + if (parseResult.isOk()) { + route.points = parseResult.route.points; + // 脚本里不写海拔时,用设置里的海拔补齐 + double alt = getSettingAltitude(); + for (ScriptWaypoint point : route.points) { + point.alt = alt; + } + } else { + XLog.e("SERVICEGO: script parse failed - " + parseResult.error); + } + } + + if (mScriptPlayer == null) { + initScriptPlayer(); + } + + // 先把测试提供者 / 注入线程准备好,再启动脚本: + // 否则脚本在算坐标,但没人往系统里注入,表现就是“脚本不好用” + ensureMockEnvironment(); + + mScriptRoute = route; + mScriptState = SCRIPT_STATE_PLAYING; + mScriptStore.setRunningScriptId(route.id); + // 脚本开始接管位置:作废进行中的点击移动目标 + mHasClickTarget = false; + mClickMovePaused = false; + + PreferenceManager.getDefaultSharedPreferences(this).edit() + .putString(KEY_LAST_SCRIPT_ID, route.id) + .apply(); + + mScriptPlayer.start(route, mCurLng, mCurLat); + + // 脚本起点也记一份,服务重启后能回到轨迹附近 + saveLastPosition(); + + // 通知栏挂上“暂停”按钮 + refreshNotification(); + } + + /** 停止脚本播放,回到手动控制(当前坐标保持不变) */ + public void stopScript() { + if (mScriptPlayer != null) { + mScriptPlayer.stop(); + } + + boolean hadScript = mScriptRoute != null || mScriptState != SCRIPT_STATE_IDLE; + mScriptRoute = null; + mScriptState = SCRIPT_STATE_IDLE; + // 当前坐标保持不变(与方法契约一致):服务还活着,注入循环继续维持这个位置。 + // 这里绝不能把坐标“交还”给真实定位,否则“停止脚本”会变成 + // “位置跳回手机所在地”,那正是要修的现象 + + if (mScriptStore != null) { + mScriptStore.setRunningScriptId(null); + } + + PreferenceManager.getDefaultSharedPreferences(this).edit() + .remove(KEY_LAST_SCRIPT_ID) + .apply(); + + // 通知栏撤掉“暂停”按钮、文案回到默认 + refreshNotification(); + + if (hadScript && mScriptListener != null) { + mScriptListener.onScriptStopped(); + } + } + + /** + * 暂停脚本播放:位置停在原地,时间轴冻结,进度保留。 + * + *

暂停期间脚本仍占有位置({@link #isScriptDrivingPosition()} 同样把暂停算作接管), + * 摇杆 / 传送 / 点击移动保持被拦,恢复播放时才能从暂停处原地接着走。 + * 同时把注入速度记为 0,避免上层应用看到“位置不动却还在移动”。

+ * + * @return 是否进入暂停(没在播放脚本时返回 false) + */ + public boolean pauseScript() { + if (mScriptState != SCRIPT_STATE_PLAYING || mScriptPlayer == null || !mScriptPlayer.isPlaying()) { + return false; + } + + // 没能真正进入暂停 = 这一拍刚好跑到终点(或已在暂停): + // 不能顺手把 FINISHED / 残留状态改写成“暂停中”,否则位置会被永久锁住 + if (!mScriptPlayer.pause() && !mScriptPlayer.isPaused()) { + return false; + } + + mScriptState = SCRIPT_STATE_PAUSED; + mSpeed = 0; + refreshNotification(); + XLog.i("SERVICEGO: script paused"); + + if (mScriptListener != null) { + mScriptListener.onScriptPaused(); + } + return true; + } + + /** + * 继续播放暂停中的脚本,从暂停的位置原地接着走。 + * + * @return 是否恢复成功(没有脚本在暂停时返回 false) + */ + public boolean resumeScript() { + if (mScriptState != SCRIPT_STATE_PAUSED || mScriptPlayer == null) { + return false; + } + + // 恢复前照启动时一样把测试提供者 / 注入线程补齐(暂停期间可能被别的流程收走过) + ensureMockEnvironment(); + if (!mScriptPlayer.resume()) { + // 播放器已不在暂停态(例如刚被 stop 过):把残留的“暂停”状态纠正回 IDLE + XLog.e("SERVICEGO: resume requested but player is not paused"); + mScriptState = SCRIPT_STATE_IDLE; + refreshNotification(); + if (mScriptListener != null) { + mScriptListener.onScriptStopped(); + } + return false; + } + + mScriptState = SCRIPT_STATE_PLAYING; + refreshNotification(); + XLog.i("SERVICEGO: script resumed"); + + if (mScriptListener != null) { + mScriptListener.onScriptResumed(); + } + return true; + } + + /** + * 脚本启动前的准备:确保测试提供者存在,并且后台注入线程正在跑。 + * + *

“进入模拟位置之后再起用脚本就不好用”的最可能原因:单点模拟那条路径 + * 结束时会 stopService / 解绑,服务一旦被系统收走,测试提供者和注入线程 + * 就都没了;此时脚本即使被主界面兜底启动,注入端也不存在。 + * 这里在真正 start 之前自检一次,缺什么补什么。

+ */ + private void ensureMockEnvironment() { + try { + // 必须最先复位:注入线程的 handleMessage 一进来就检查 isStop, + // 若这里放最后,新线程的第一条消息可能先看到 true,直接退出循环不再重排, + // 表现就是“脚本在算坐标,但坐标永远注入不进去” + isStop = false; + + if (mLocManager == null) { + mLocManager = (LocationManager) getSystemService(Context.LOCATION_SERVICE); + } + + // 测试提供者可能被别的流程移除过(removeTestProvider 是先删后加),这里补建 + addTestProviderGPS(); + addTestProviderNetwork(); + + // 注入线程必须活着,否则脚本算出来的坐标没人往系统里写 + if (mLocHandler == null || mLocHandlerThread == null || !mLocHandlerThread.isAlive()) { + XLog.i("SERVICEGO: location thread is gone, restart it for script"); + initGoLocation(); + } else { + mLocHandler.removeMessages(HANDLER_MSG_ID); + mLocHandler.sendEmptyMessage(HANDLER_MSG_ID); + } + } catch (Exception e) { + XLog.e("SERVICEGO: ERROR - ensureMockEnvironment", e); + } + } + + private void saveLastPosition() { + try { + // 注:脚本运行期间这里不写盘。保存的坐标是“服务被系统重启后恢复用”的, + // 若每 tick 都把脚本行进中的坐标写进去,服务重启后会从脚本半途接上, + // 反而让“重启恢复”变得不可预期;脚本起点在 startScript 里已经存过一次。 + // + // 这里只判“脚本是否在播”,不要用 isMockDrivingPosition():后者把 + // “服务活着”也算作接管,会让 onStartCommand 里刚恢复的坐标存不下去。 + if (isScriptDrivingPosition()) { + return; + } + + PreferenceManager.getDefaultSharedPreferences(this).edit() + .putString(KEY_LAST_LNG, Double.toString(mCurLng)) + .putString(KEY_LAST_LAT, Double.toString(mCurLat)) + .putString(KEY_LAST_ALT, Double.toString(mCurAlt)) + .apply(); + } catch (Exception e) { + XLog.e("SERVICEGO: ERROR - saveLastPosition", e); + } + } + + private void removeTestProviderGPS() { + if (mLocManager == null) { + return; + } + + try { + if (mLocManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { + mLocManager.setTestProviderEnabled(LocationManager.GPS_PROVIDER, false); + mLocManager.removeTestProvider(LocationManager.GPS_PROVIDER); + } + } catch (Throwable e) { + // 某些 ROM 在没有测试提供者时就会抛异常,属于预期内的情况 + XLog.e("SERVICEGO: ERROR - removeTestProviderGPS"); + } + } + + // 注意下面临时添加 @SuppressLint("wrongconstant") 以处理 addTestProvider 参数值的 lint 错误 + @SuppressLint("wrongconstant") + private void addTestProviderGPS() { + if (mLocManager == null) { + return; + } + + try { + // 注意,由于 android api 问题,下面的参数会提示错误(以下参数是通过相关API获取的真实GPS参数,不是随便写的) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + mLocManager.addTestProvider(LocationManager.GPS_PROVIDER, false, true, false, + false, true, true, true, ProviderProperties.POWER_USAGE_HIGH, ProviderProperties.ACCURACY_FINE); + } else { + mLocManager.addTestProvider(LocationManager.GPS_PROVIDER, false, true, false, + false, true, true, true, Criteria.POWER_HIGH, Criteria.ACCURACY_FINE); + } + if (!mLocManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { + mLocManager.setTestProviderEnabled(LocationManager.GPS_PROVIDER, true); + } + } catch (Throwable e) { + XLog.e("SERVICEGO: ERROR - addTestProviderGPS"); + } + } + + private void setLocationGPS() { + if (mLocManager == null) { + return; + } + + try { + // 尽可能模拟真实的 GPS 数据 + Location loc = new Location(LocationManager.GPS_PROVIDER); + loc.setAccuracy(Criteria.ACCURACY_FINE); // 设定此位置的估计水平精度,以米为单位。 + loc.setAltitude(mCurAlt); // 设置高度,在 WGS 84 参考坐标系中的米 + loc.setBearing(injectionBearing()); // 方向(度;避开 0,见 injectionBearing) + loc.setLatitude(mCurLat); // 纬度(度) + loc.setLongitude(mCurLng); // 经度(度) + loc.setTime(System.currentTimeMillis()); // 本地时间 + loc.setSpeed((float) mSpeed); + loc.setElapsedRealtimeNanos(SystemClock.elapsedRealtimeNanos()); + Bundle bundle = new Bundle(); + bundle.putInt("satellites", 7); + loc.setExtras(bundle); + + mLocManager.setTestProviderLocation(LocationManager.GPS_PROVIDER, loc); + } catch (Throwable e) { + XLog.e("SERVICEGO: ERROR - setLocationGPS"); + } + } + + private void removeTestProviderNetwork() { + if (mLocManager == null) { + return; + } + + try { + if (mLocManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { + mLocManager.setTestProviderEnabled(LocationManager.NETWORK_PROVIDER, false); + mLocManager.removeTestProvider(LocationManager.NETWORK_PROVIDER); + } + } catch (Throwable e) { + // 某些 ROM 在没有测试提供者时就会抛异常,属于预期内的情况 + XLog.e("SERVICEGO: ERROR - removeTestProviderNetwork"); + } + } + + // 注意下面临时添加 @SuppressLint("wrongconstant") 以处理 addTestProvider 参数值的 lint 错误 + @SuppressLint("wrongconstant") + private void addTestProviderNetwork() { + if (mLocManager == null) { + return; + } + + try { + // 注意,由于 android api 问题,下面的参数会提示错误(以下参数是通过相关API获取的真实NETWORK参数,不是随便写的) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + mLocManager.addTestProvider(LocationManager.NETWORK_PROVIDER, true, false, + true, true, true, true, + true, ProviderProperties.POWER_USAGE_LOW, ProviderProperties.ACCURACY_COARSE); + } else { + mLocManager.addTestProvider(LocationManager.NETWORK_PROVIDER, true, false, + true, true, true, true, + true, Criteria.POWER_LOW, Criteria.ACCURACY_COARSE); + } + if (!mLocManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { + mLocManager.setTestProviderEnabled(LocationManager.NETWORK_PROVIDER, true); + } + } catch (Throwable e) { + // 这里原来只捕获 SecurityException,一旦 ROM 抛出 IllegalArgumentException + // 就会直接把整个进程带崩(点“模拟位置”后闪退的元凶之一),统一改为捕获 Throwable + XLog.e("SERVICEGO: ERROR - addTestProviderNetwork"); + } + } + + private void setLocationNetwork() { + if (mLocManager == null) { + return; + } + + try { + // 尽可能模拟真实的 NETWORK 数据 + Location loc = new Location(LocationManager.NETWORK_PROVIDER); + loc.setAccuracy(Criteria.ACCURACY_COARSE); // 设定此位置的估计水平精度,以米为单位。 + loc.setAltitude(mCurAlt); // 设置高度,在 WGS 84 参考坐标系中的米 + loc.setBearing(injectionBearing()); // 方向(度;避开 0,见 injectionBearing) + loc.setLatitude(mCurLat); // 纬度(度) + loc.setLongitude(mCurLng); // 经度(度) + loc.setTime(System.currentTimeMillis()); // 本地时间 + loc.setSpeed((float) mSpeed); + loc.setElapsedRealtimeNanos(SystemClock.elapsedRealtimeNanos()); + + mLocManager.setTestProviderLocation(LocationManager.NETWORK_PROVIDER, loc); + } catch (Throwable e) { + XLog.e("SERVICEGO: ERROR - setLocationNetwork"); + } + } + + public class NoteActionReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (action != null) { + if (action.equals(SERVICE_GO_NOTE_ACTION_JOYSTICK_SHOW)) { + if (mJoyStick != null) { + mJoyStick.show(); + } + } + + if (action.equals(SERVICE_GO_NOTE_ACTION_JOYSTICK_HIDE)) { + if (mJoyStick != null) { + mJoyStick.hide(); + } + } + + // 通知栏“暂停 / 继续”:后台跑脚本时唯一够得着的控制入口 + if (action.equals(SERVICE_GO_NOTE_ACTION_SCRIPT_PAUSE)) { + pauseScript(); + } + + if (action.equals(SERVICE_GO_NOTE_ACTION_SCRIPT_RESUME)) { + resumeScript(); + } + } + } + } + + public class ServiceGoBinder extends Binder { + public void setPosition(double lng, double lat, double alt) { + if (mLocHandler == null) { + XLog.e("SERVICEGO: ERROR - setPosition before handler ready"); + return; + } + + // 手动传送会打断脚本播放,也会作废进行中的点击移动目标 + if (mScriptPlayer != null && mScriptPlayer.isPlaying()) { + stopScript(); + } + mHasClickTarget = false; + mClickMovePaused = false; + + mLocHandler.removeMessages(HANDLER_MSG_ID); + mCurLng = lng; + mCurLat = lat; + mCurAlt = alt; + mSpeed = 1.2; + mCurBea = DEFAULT_BEA; + saveLastPosition(); + mLocHandler.sendEmptyMessage(HANDLER_MSG_ID); + + if (mJoyStick != null) { + mJoyStick.setCurrentPosition(mCurLng, mCurLat, mCurAlt); + } + } + + /** 开始播放脚本(重复调用会切换到新的脚本) */ + public boolean startScript(String scriptId) { + if (isBlank(scriptId)) { + return false; + } + + ServiceGo.this.startScript(scriptId); + return mScriptState == SCRIPT_STATE_PLAYING; + } + + /** 停止脚本播放 */ + public void stopScript() { + ServiceGo.this.stopScript(); + } + + /** 暂停脚本播放(位置停在原地,进度保留);没在播放时返回 false */ + public boolean pauseScript() { + return ServiceGo.this.pauseScript(); + } + + /** 继续播放暂停中的脚本;没有脚本在暂停时返回 false */ + public boolean resumeScript() { + return ServiceGo.this.resumeScript(); + } + + /** 脚本是否处于暂停中(用于界面把“暂停 / 继续”按钮切到正确一侧) */ + public boolean isScriptPaused() { + return mScriptState == SCRIPT_STATE_PAUSED; + } + + /** 切换“点击移动 ⇄ 摇杆移动”(主界面左下角开关) */ + public void setClickMoveMode(boolean enabled) { + ServiceGo.this.setClickMoveMode(enabled); + } + + /** 暂停点击行走(位置停在原地,目标保留);没在朝目标走时返回 false */ + public boolean pauseClickMove() { + return ServiceGo.this.pauseClickMove(); + } + + /** 继续点击行走,从暂停处朝同一目标接着走;没有暂停中的行走时返回 false */ + public boolean resumeClickMove() { + return ServiceGo.this.resumeClickMove(); + } + + /** 点击行走是否处于暂停中 */ + public boolean isClickMovePaused() { + return mClickMovePaused; + } + + /** 注册点击移动“到达一个目标”的回调(定位线程触发) */ + public void setClickMoveListener(ClickMoveListener listener) { + mClickMoveListener = listener; + } + + public void clearClickMoveListener() { + mClickMoveListener = null; + } + + /** + * 追加一个行进目标:立刻朝它走,走到后回调界面继续下发下一个。 + * + *

与脚本互斥:脚本真正播放中由脚本接管位置,这里直接拒绝。 + * 判定用 {@link ServiceGo#isScriptDrivingPosition()}(状态 + 播放器双确认), + * 只看 mScriptState 会被“状态停在 PLAYING、播放器早停了”的残留挡掉, + * 表现为“点地图加目标没反应”。

+ * + * @param lng 目标经度(WGS-84) + * @param lat 目标纬度(WGS-84) + * @return 未开启点击移动或脚本正在播放时返回 false + */ + public boolean appendClickTarget(double lng, double lat) { + if (!mClickMoveEnabled || isScriptDrivingPosition()) { + return false; + } + + mClickTargetLng = lng; + mClickTargetLat = lat; + mLastClickStepMs = System.currentTimeMillis(); + mHasClickTarget = true; + return true; + } + + /** 清空所有还未到达的目标(撤回 / 清空 / 停止时用) */ + public void clearClickTargets() { + mHasClickTarget = false; + mClickMovePaused = false; + } + + /** + * 播放过程中切换移动状态(走 / 跑 / 骑),立即对剩余路段生效。 + * + * @param mode 新的移动状态 + * @return 是否切换成功(没在播放脚本时返回 false) + */ + public boolean switchLiveMode(ScriptWaypoint.Mode mode) { + if (mScriptPlayer == null || !mScriptPlayer.isPlaying() || mode == null) { + return false; + } + + double[] speeds = getModeSpeeds(); + double speed = speeds[Math.max(0, Math.min(speeds.length - 1, mode.ordinal()))]; + return mScriptPlayer.setLiveMode(mode, speed); + } + + /** 当前播放中手动切换过的状态,没有切换过或没在播放时返回 null */ + public ScriptWaypoint.Mode getLiveMode() { + return mScriptPlayer == null ? null : mScriptPlayer.getLiveMode(); + } + + /** 当前正在生效的移动状态(手动切换过返回切换值,否则是脚本当前段的状态) */ + public ScriptWaypoint.Mode getCurrentMode() { + return mScriptPlayer == null ? null : mScriptPlayer.getMode(); + } + + public int getScriptState() { + return mScriptState; + } + + /** 脚本是否真正在接管位置(用于界面避免重复 startScript 重置轨迹) */ + public boolean isScriptPlaying() { + return isScriptDrivingPosition(); + } + + /** + * 上报一次真实定位(主界面 / 摇杆从系统定位拿到坐标时调用)。 + * + *

服务内部会判断当前是否由脚本或点击移动接管:接管期间只记录不覆盖, + * 避免切换应用时真实定位把模拟位置拉回去。

+ */ + public void onRealLocation(double lng, double lat) { + applyRealLocation(lng, lat); + } + + /** 当前正在播放的脚本 id;没在播放(含已跑完、已停止)时返回 null,暂停中照常返回 */ + public String getRunningScriptId() { + if ((mScriptState != SCRIPT_STATE_PLAYING && mScriptState != SCRIPT_STATE_PAUSED) + || mScriptRoute == null) { + return null; + } + return mScriptRoute.id; + } + + public String getRunningScriptName() { + return mScriptRoute == null ? null : mScriptRoute.name; + } + + public int getRunningScriptPointCount() { + return mScriptRoute == null ? 0 : mScriptRoute.size(); + } + + public void setScriptListener(ScriptListener listener) { + mScriptListener = listener; + } + + public void clearScriptListener() { + mScriptListener = null; + } + + /** 把当前位置同步给摇杆(例如脚本运行时界面重新绑定) */ + public void syncJoyStick() { + if (mJoyStick != null) { + mJoyStick.setCurrentPosition(mCurLng, mCurLat, mCurAlt); + } + } + + /** + * 当前模拟位置(经度、纬度,WGS-84)。 + * + *

界面用它估算点击行走的“预计里程”:注入端每拍都在推进这个坐标, + * 所以读到的就是人物此刻真正所在的位置。

+ */ + public double[] getCurrentPosition() { + return new double[] {mCurLng, mCurLat}; + } + } +} + diff --git a/app/src/main/java/com/zcshou/utils/GoUtils.java b/app/src/main/java/com/zcshou/utils/GoUtils.java index 623518a9..583d0682 100644 --- a/app/src/main/java/com/zcshou/utils/GoUtils.java +++ b/app/src/main/java/com/zcshou/utils/GoUtils.java @@ -22,6 +22,8 @@ import androidx.appcompat.app.AlertDialog; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; @@ -49,8 +51,14 @@ public static boolean isWifiConnected(Context context) { } public static boolean isWifiEnabled(Context context) { - WifiManager wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); - return wifiManager.isWifiEnabled(); + try { + WifiManager wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); + return wifiManager != null && wifiManager.isWifiEnabled(); + } catch (Exception e) { + // 个别 ROM 上取 Wi-Fi 状态会抛 SecurityException,这里不应影响主流程 + e.printStackTrace(); + return false; + } } //MOBILE网络是否可用 @@ -82,27 +90,28 @@ public static boolean isNetworkAvailable(Context context) { //判断GPS是否打开 public static boolean isGpsOpened(Context context) { - LocationManager locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE); - return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER); + try { + LocationManager locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE); + return locationManager != null && locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER); + } catch (Exception e) { + e.printStackTrace(); + return false; + } } // 判断是否已在开发者选项中开启模拟位置权限(注意下面临时添加 @SuppressLint("wrongconstant") 以处理 addTestProvider 参数值的 lint 错误) @SuppressLint("wrongconstant") public static boolean isAllowMockLocation(Context context) { boolean canMockPosition = false; - int index; - - try { - LocationManager locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);//获得LocationManager引用 - List list = locationManager.getAllProviders(); - for (index = 0; index < list.size(); index++) { - if (list.get(index).equals(LocationManager.GPS_PROVIDER)) { - break; - } - } + LocationManager locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);//获得LocationManager引用 + if (locationManager == null) { + return false; + } - if (index < list.size()) { + try { + if (locationManager.getAllProviders() != null + && locationManager.getAllProviders().contains(LocationManager.GPS_PROVIDER)) { // 注意,由于 android api 问题,下面的参数会提示错误(以下参数是通过相关API获取的真实GPS参数,不是随便写的) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { locationManager.addTestProvider(LocationManager.GPS_PROVIDER, false, true, false, @@ -113,15 +122,21 @@ public static boolean isAllowMockLocation(Context context) { } canMockPosition = true; } + } catch (Exception e) { + // 没有勾选“模拟位置信息应用”时 addTestProvider 会抛 SecurityException + e.printStackTrace(); + canMockPosition = false; + } - // 模拟位置可用 - if (canMockPosition) { - // remove test provider + // 模拟位置可用:无论清理测试提供者是否成功,“可用”这个结论都不应该被推翻 + if (canMockPosition) { + try { locationManager.setTestProviderEnabled(LocationManager.GPS_PROVIDER, false); locationManager.removeTestProvider(LocationManager.GPS_PROVIDER); + } catch (Exception e) { + // 部分 ROM 在这里会抛异常,忽略即可 + e.printStackTrace(); } - } catch (SecurityException e) { - e.printStackTrace(); } return canMockPosition; @@ -180,7 +195,7 @@ public static String timeStamp2Date(String seconds) { //提醒开启位置模拟的弹框 public static void showEnableMockLocationDialog(Context context) { - new AlertDialog.Builder(context) + new MaterialAlertDialogBuilder(context) .setTitle("启用位置模拟")//这里是表头的内容 .setMessage("请在\"开发者选项→选择模拟位置信息应用\"中进行设置")//这里是中间显示的具体信息 .setPositiveButton("设置",(dialog, which) -> { @@ -199,7 +214,7 @@ public static void showEnableMockLocationDialog(Context context) { //提醒开启悬浮窗的弹框 public static void showEnableFloatWindowDialog(Context context) { - new AlertDialog.Builder(context) + new MaterialAlertDialogBuilder(context) .setTitle("启用悬浮窗")//这里是表头的内容 .setMessage("为了模拟定位的稳定性,建议开启\"显示悬浮窗\"选项")//这里是中间显示的具体信息 .setPositiveButton("设置",(dialog, which) -> { @@ -217,9 +232,29 @@ public static void showEnableFloatWindowDialog(Context context) { .show(); } + // 权限被拒绝后引导用户去应用详情页手动开启 + public static void showPermissionSettingsDialog(Context context, String message) { + new MaterialAlertDialogBuilder(context) + .setTitle("权限不足") + .setMessage(message) + .setPositiveButton("去设置", (dialog, which) -> { + try { + Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:" + context.getPackageName())); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } catch (Exception e) { + e.printStackTrace(); + } + }) + .setNegativeButton("取消", (dialog, which) -> { + }) + .show(); + } + //显示开启GPS的提示 public static void showEnableGpsDialog(Context context) { - new AlertDialog.Builder(context) + new MaterialAlertDialogBuilder(context) .setTitle("启用定位服务")//这里是表头的内容 .setMessage("是否开启 GPS 定位服务?")//这里是中间显示的具体信息 .setPositiveButton("确定",(dialog, which) -> { @@ -238,7 +273,7 @@ public static void showEnableGpsDialog(Context context) { // 提醒开启位置模拟的弹框 public static void showDisableWifiDialog(Context context) { - new AlertDialog.Builder(context) + new MaterialAlertDialogBuilder(context) .setTitle("警告") .setMessage("开启 WIFI 后(即使没有连接热点)将导致定位闪回真实位置。建议关闭 WIFI,使用移动流量进行游戏!") .setPositiveButton("去关闭",(dialog, which) -> { diff --git a/app/src/main/java/com/zcshou/utils/MapUtils.java b/app/src/main/java/com/zcshou/utils/MapUtils.java index 129a5206..a778b7d8 100644 --- a/app/src/main/java/com/zcshou/utils/MapUtils.java +++ b/app/src/main/java/com/zcshou/utils/MapUtils.java @@ -1,5 +1,11 @@ package com.zcshou.utils; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + public class MapUtils { // public final static String COORDINATE_TYPE_GCJ02 = "gcj02"; // public final static String COORDINATE_TYPE_BD09LL = "bd09ll"; @@ -87,6 +93,113 @@ private static double transformLon(double lat, double lon) { return ret; } + /*============================== 经纬度文本解析 ==============================*/ + /** 匹配整数 / 小数坐标 */ + private static final Pattern NUMBER_PATTERN = Pattern.compile("-?\\d+(?:\\.\\d+)?"); + private static final String[] LNG_LABELS = {"经度", "lng", "lon", "longitude"}; + private static final String[] LAT_LABELS = {"纬度", "lat", "latitude"}; + + /** + * 解析用户粘贴或输入的经纬度文本,统一按“经度,纬度”返回。 + * + *

支持的写法:

+ *
    + *
  • {@code 116.397,39.908} / {@code 116.397 39.908} / {@code 116.397,39.908}
  • + *
  • {@code 经度:116.397 纬度:39.908} / {@code lat=39.908&lng=116.397}
  • + *
  • {@code 39.908,116.397}(其中一个数值绝对值大于 90 时自动识别为经度)
  • + *
+ * + * @return {经度, 纬度};解析不出来(例如是一段地址)时返回 null + */ + public static double[] parseLngLat(String text) { + if (text == null) { + return null; + } + + // 全角字符统一成半角,并去掉常见的包裹字符 + String normalized = text.trim() + .replace(',', ',') + .replace(':', ':') + .replace(';', ',') + .replace('(', ' ') + .replace(')', ' ') + .replace('【', ' ') + .replace('】', ' '); + if (normalized.isEmpty()) { + return null; + } + + Double labelLng = findNumberAfterLabel(normalized, LNG_LABELS); + Double labelLat = findNumberAfterLabel(normalized, LAT_LABELS); + if (labelLng != null && labelLat != null && isValidLngLat(labelLng, labelLat)) { + return new double[] {labelLng, labelLat}; + } + + List numbers = new ArrayList<>(); + Matcher matcher = NUMBER_PATTERN.matcher(normalized); + while (numbers.size() < 2 && matcher.find()) { + try { + numbers.add(Double.parseDouble(matcher.group())); + } catch (NumberFormatException e) { + return null; + } + } + if (numbers.size() < 2) { + return null; + } + + double first = numbers.get(0); + double second = numbers.get(1); + + // 纬度的绝对值不会超过 90,所以超过 90 的那个一定是经度 + if (Math.abs(first) > 90.0 && Math.abs(second) <= 90.0) { + return new double[] {first, second}; + } + if (Math.abs(second) > 90.0 && Math.abs(first) <= 90.0) { + return new double[] {second, first}; + } + + // 默认按“经度,纬度”,不合法时再反过来试一次 + if (isValidLngLat(first, second)) { + return new double[] {first, second}; + } + if (isValidLngLat(second, first)) { + return new double[] {second, first}; + } + + return null; + } + + private static boolean isValidLngLat(double lng, double lat) { + return !Double.isNaN(lng) && !Double.isNaN(lat) + && Math.abs(lng) <= 180.0 && Math.abs(lat) <= 90.0; + } + + /** 取标签(经度 / lng / 纬度 / lat ...)之后出现的第一个数值 */ + private static Double findNumberAfterLabel(String text, String[] labels) { + String lower = text.toLowerCase(Locale.US); + + for (String label : labels) { + int index = lower.indexOf(label); + if (index < 0) { + continue; + } + + Matcher matcher = NUMBER_PATTERN.matcher(text); + while (matcher.find()) { + if (matcher.start() >= index + label.length()) { + try { + return Double.parseDouble(matcher.group()); + } catch (NumberFormatException e) { + return null; + } + } + } + } + + return null; + } + // private static boolean out_of_china(double lng, double lat) { // return (lng < 72.004 || lng > 137.8347) || ((lat < 0.8293 || lat > 55.8271)); // } diff --git a/app/src/main/java/com/zcshou/utils/OsmGeocoder.java b/app/src/main/java/com/zcshou/utils/OsmGeocoder.java new file mode 100644 index 00000000..08ac5ae1 --- /dev/null +++ b/app/src/main/java/com/zcshou/utils/OsmGeocoder.java @@ -0,0 +1,195 @@ +package com.zcshou.utils; + +import android.os.Handler; +import android.os.Looper; +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import com.elvishew.xlog.XLog; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +/** + * 基于 OpenStreetMap Nominatim 的地点检索 / 逆地理编码,用于替代原工程中的 + * 百度 {@code SuggestionSearch} 与 {@code GeoCoder}。 + * + *

本类返回的经纬度一律是 WGS-84,调用方需要按原有 BD-09 语义使用 + * {@link MapUtils#wgs2bd09(double, double)} / {@link MapUtils#bd2wgs(double, double)} 自行转换。

+ * + *

所有回调都在主线程执行,可以直接操作 UI。

+ */ +public class OsmGeocoder { + + private static final String SEARCH_URL = "https://nominatim.openstreetmap.org/search"; + private static final String REVERSE_URL = "https://nominatim.openstreetmap.org/reverse"; + /* Nominatim 的使用条款要求带上可识别的 User-Agent,否则会直接返回 403 */ + private static final String USER_AGENT = "GoGoGo-Android/1.12.3 (mock location tool)"; + + private final OkHttpClient mHttpClient; + private final Handler mMainHandler = new Handler(Looper.getMainLooper()); + + /** 一条地点信息,经纬度为 WGS-84 */ + public static class Poi { + public String name = ""; + public String address = ""; + public double longitude = 0.0; + public double latitude = 0.0; + } + + public interface OnSearchListener { + void onSearchResult(List pois); + } + + public interface OnReverseListener { + void onReverseResult(Poi poi); + } + + public OsmGeocoder(@NonNull OkHttpClient httpClient) { + this.mHttpClient = httpClient; + } + + /** + * 按关键字检索地点 + * + * @param keyword 关键字 + * @param listener 结果回调(主线程),失败时回传空列表 + */ + public void search(final String keyword, @NonNull final OnSearchListener listener) { + if (TextUtils.isEmpty(keyword)) { + listener.onSearchResult(Collections.emptyList()); + return; + } + + String url; + try { + url = SEARCH_URL + "?format=jsonv2&limit=10&accept-language=zh-CN&q=" + + URLEncoder.encode(keyword, "UTF-8"); + } catch (Exception e) { + XLog.e("OSM: encode search keyword failed"); + listener.onSearchResult(Collections.emptyList()); + return; + } + + Request request = new Request.Builder() + .url(url) + .header("User-Agent", USER_AGENT) + .get() + .build(); + + mHttpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(@NonNull Call call, @NonNull IOException e) { + XLog.e("OSM: search failed - " + e.getMessage()); + post(() -> listener.onSearchResult(Collections.emptyList())); + } + + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + final List result = new ArrayList<>(); + ResponseBody body = response.body(); + if (body != null) { + try { + JSONArray array = new JSONArray(body.string()); + for (int i = 0; i < array.length(); i++) { + Poi poi = parsePoi(array.optJSONObject(i)); + if (poi != null) { + result.add(poi); + } + } + } catch (Exception e) { + XLog.e("OSM: parse search result failed"); + } + } + post(() -> listener.onSearchResult(result)); + } + }); + } + + /** + * 逆地理编码(由坐标查地址) + * + * @param latitude WGS-84 纬度 + * @param longitude WGS-84 经度 + * @param listener 结果回调(主线程),失败时回传 null + */ + public void reverse(final double latitude, final double longitude, + @NonNull final OnReverseListener listener) { + final String url = REVERSE_URL + "?format=jsonv2&accept-language=zh-CN&zoom=18" + + "&lat=" + latitude + "&lon=" + longitude; + + Request request = new Request.Builder() + .url(url) + .header("User-Agent", USER_AGENT) + .get() + .build(); + + mHttpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(@NonNull Call call, @NonNull IOException e) { + XLog.e("OSM: reverse failed - " + e.getMessage()); + post(() -> listener.onReverseResult(null)); + } + + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + Poi poi = null; + ResponseBody body = response.body(); + if (body != null) { + try { + poi = parsePoi(new JSONObject(body.string())); + } catch (Exception e) { + XLog.e("OSM: parse reverse result failed"); + } + } + // 有些点位 Nominatim 不会回传坐标,这里兜底用请求坐标 + if (poi != null && (poi.latitude == 0.0 && poi.longitude == 0.0)) { + poi.latitude = latitude; + poi.longitude = longitude; + } + final Poi finalPoi = poi; + post(() -> listener.onReverseResult(finalPoi)); + } + }); + } + + private static Poi parsePoi(JSONObject obj) { + if (obj == null) { + return null; + } + + double lat = obj.optDouble("lat", Double.NaN); + double lon = obj.optDouble("lon", Double.NaN); + if (Double.isNaN(lat) || Double.isNaN(lon)) { + return null; + } + + Poi poi = new Poi(); + poi.latitude = lat; + poi.longitude = lon; + poi.address = obj.optString("display_name", ""); + poi.name = obj.optString("name", ""); + if (TextUtils.isEmpty(poi.name)) { + poi.name = poi.address; + } + return poi; + } + + private void post(Runnable runnable) { + mMainHandler.post(runnable); + } +} diff --git a/app/src/main/java/com/zcshou/utils/TileSourceUtils.java b/app/src/main/java/com/zcshou/utils/TileSourceUtils.java new file mode 100644 index 00000000..773c3d17 --- /dev/null +++ b/app/src/main/java/com/zcshou/utils/TileSourceUtils.java @@ -0,0 +1,80 @@ +package com.zcshou.utils; + +import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase; +import org.osmdroid.tileprovider.tilesource.XYTileSource; +import org.osmdroid.util.MapTileIndex; + +/** + * 瓦片地图源定义。 + * + *

坐标顺序(很重要):不同瓦片服务的 URL 顺序并不一样,用错会表现为"地图能出来但瓦片错位拼贴"。

+ *
    + *
  • osmdroid 内置的 {@link XYTileSource#getTileURLString(long)} 拼的是 + * {@code baseUrl + z + "/" + x + "/" + y + 扩展名},也就是 Z/X/Y; + * 这正好符合 OpenStreetMap 的规则,所以 OSM 直接用 {@link XYTileSource} 即可。
  • + *
  • Esri ArcGIS REST 的规则是 Z/Y/X,与上面相反,因此必须像 + * {@link ZyxTileSource} 那样重写 {@code getTileURLString}。
  • + *
+ * + *

坐标系:OSM 与 Esri World Imagery 的瓦片都基于 WGS-84(Web Mercator / EPSG:3857), + * 而原工程内部沿用的是 BD-09,转换在显示层完成,参见 {@link MapUtils}。

+ */ +public final class TileSourceUtils { + + /** Esri World Imagery 卫星影像瓦片服务(瓦片顺序 Z/Y/X) */ + public static final String ESRI_WORLD_IMAGERY_URL = + "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/"; + + /** + * OpenStreetMap 标准地图瓦片服务。 + * 这里用的是 OSM 德国镜像(全球覆盖、标准 OSM Carto 样式),因为官方主站 + * {@code https://tile.openstreetmap.org/} 在国内多数网络下不可达。 + * 如果你的网络能访问官方主站,把下面这行换成 {@link #OSM_TILE_URL_OFFICIAL} 即可。 + */ + public static final String OSM_TILE_URL = "https://tile.openstreetmap.de/"; + + /** OpenStreetMap 官方主站瓦片地址(国内多数网络超时) */ + public static final String OSM_TILE_URL_OFFICIAL = "https://tile.openstreetmap.org/"; + + /** OpenStreetMap 标准地图瓦片源(Z/X/Y,与 osmdroid 的 XYTileSource 一致) */ + public static final XYTileSource OSM_STANDARD = new XYTileSource( + "OSMStandard", + 0, 19, 256, ".png", + new String[]{OSM_TILE_URL}, + "© OpenStreetMap contributors"); + + /** Esri World Imagery 卫星影像瓦片源(Z/Y/X,影像瓦片为 jpg) */ + public static final OnlineTileSourceBase ESRI_WORLD_IMAGERY = new ZyxTileSource( + "EsriWorldImagery", + 0, 19, 256, ".jpg", + new String[]{ESRI_WORLD_IMAGERY_URL}, + "Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community"); + + /** + * ArcGIS REST 风格的在线瓦片源:URL 顺序为 {@code Z/Y/X}。 + * + *

osmdroid 自带的 {@link XYTileSource} 拼的是 {@code Z/X/Y},直接拿它去请求 + * Esri 会把经纬度方向弄反,出现"瓦片错位"的现象,所以这里单独重写。

+ */ + public static class ZyxTileSource extends OnlineTileSourceBase { + + public ZyxTileSource(String aName, int aZoomMinLevel, int aZoomMaxLevel, + int aTileSizePixels, String aImageFilenameEnding, + String[] aBaseUrl, String copyright) { + super(aName, aZoomMinLevel, aZoomMaxLevel, aTileSizePixels, + aImageFilenameEnding, aBaseUrl, copyright); + } + + @Override + public String getTileURLString(long pMapTileIndex) { + return getBaseUrl() + + MapTileIndex.getZoom(pMapTileIndex) + "/" + + MapTileIndex.getY(pMapTileIndex) + "/" + + MapTileIndex.getX(pMapTileIndex) + + mImageFilenameEnding; + } + } + + private TileSourceUtils() { + } +} diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 00000000..11e6a3a3 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml new file mode 100644 index 00000000..70253461 --- /dev/null +++ b/app/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/ic_play.xml new file mode 100644 index 00000000..8c05f4e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_play.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_route.xml b/app/src/main/res/drawable/ic_route.xml new file mode 100644 index 00000000..2f1decc9 --- /dev/null +++ b/app/src/main/res/drawable/ic_route.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_script.xml b/app/src/main/res/drawable/ic_script.xml new file mode 100644 index 00000000..9bb4f6eb --- /dev/null +++ b/app/src/main/res/drawable/ic_script.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_script_add.xml b/app/src/main/res/drawable/ic_script_add.xml new file mode 100644 index 00000000..bd639f2e --- /dev/null +++ b/app/src/main/res/drawable/ic_script_add.xml @@ -0,0 +1,18 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_script_play.xml b/app/src/main/res/drawable/ic_script_play.xml new file mode 100644 index 00000000..8820c25f --- /dev/null +++ b/app/src/main/res/drawable/ic_script_play.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/app/src/main/res/drawable/round_badge_accent.xml b/app/src/main/res/drawable/round_badge_accent.xml new file mode 100644 index 00000000..295d9507 --- /dev/null +++ b/app/src/main/res/drawable/round_badge_accent.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/round_badge_gray.xml b/app/src/main/res/drawable/round_badge_gray.xml new file mode 100644 index 00000000..c11eed33 --- /dev/null +++ b/app/src/main/res/drawable/round_badge_gray.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/layout/activity_script.xml b/app/src/main/res/layout/activity_script.xml new file mode 100644 index 00000000..30bab14e --- /dev/null +++ b/app/src/main/res/layout/activity_script.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_script_edit.xml b/app/src/main/res/layout/activity_script_edit.xml new file mode 100644 index 00000000..1d2e5517 --- /dev/null +++ b/app/src/main/res/layout/activity_script_edit.xml @@ -0,0 +1,264 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +