init: 河南女子职业学院智慧学工考勤签到 Android 应用

功能包含:学生登录、微信授权、自定义GPS签到、预设管理、历史记录

开发者:凡笙

Made-with: Cursor
This commit is contained in:
2026-03-14 23:20:43 +08:00
commit b4b190e23c
34 changed files with 2521 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
/app/build
/app/release
*.apk
*.aab
*.keystore
*.jks

39
README.md Normal file
View File

@@ -0,0 +1,39 @@
# GPSPunchApp
河南女子职业学院智慧学工考勤签到 Android 应用
## 功能
- 学生身份登录(姓名 + 学号 + 验证码)
- 微信扫码授权登录
- 登录状态持久化,下次打开无需重复登录
- 自定义 GPS 经纬度进行考勤签到
- GPS 预设位置管理(添加 / 删除)
- 课程列表与打卡记录查看
- 签到历史记录本地保存
- 退出登录
## 技术栈
- Kotlin
- OkHttp3网络请求 + Cookie 管理)
- JsoupHTML 解析)
- Material3UI 组件)
- SharedPreferences本地持久化
- Coroutines异步任务
## 构建
使用 Android Studio 打开 `GPSPunchApp` 目录,或命令行执行:
```
./gradlew assembleDebug
```
生成的 APK 位于 `app/build/outputs/apk/debug/app-debug.apk`
## 声明
本项目仅供参考学习使用,未加入任何第三方接口,未收集用户个人信息。
开发者:凡笙

48
app/build.gradle Normal file
View File

@@ -0,0 +1,48 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'com.gpspunch.app'
compileSdk 34
defaultConfig {
applicationId "com.gpspunch.app"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
buildFeatures {
viewBinding true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'org.jsoup:jsoup:1.17.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
}

7
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,7 @@
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class okhttp3.** { *; }
# Jsoup
-keep class org.jsoup.** { *; }

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.GPSPunch"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,521 @@
package com.gpspunch.app
import android.graphics.Color
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.chip.Chip
import com.google.android.material.snackbar.Snackbar
import com.gpspunch.app.databinding.ActivityMainBinding
import kotlinx.coroutines.*
import java.text.SimpleDateFormat
import java.util.*
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val api = PunchApi()
private lateinit var prefs: PrefsManager
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
prefs = PrefsManager(this)
setupLogin()
setupPunch()
setupGps()
setupHistory()
setupNav()
showDisclaimerIfNeeded()
}
private fun showDisclaimerIfNeeded() {
if (prefs.isDisclaimerAccepted()) {
checkSavedLogin()
return
}
AlertDialog.Builder(this)
.setTitle("安全提示")
.setMessage(
"本应用仅供参考学习使用。\n\n" +
"如果您使用本应用的相关功能,即代表您自愿放弃河南女子职业学院相关规章制度所赋予的权益," +
"并自行承担一切后果。\n\n" +
"本 App 未加入任何第三方接口,也未收集您的任何个人信息。\n" +
"全部使用的接口如下:\n" +
"• https://wx703c2206450720dd.g8n.cn\n" +
"• https://login.b8n.cn\n\n" +
"开发者:凡笙\n\n" +
"请确认您已知晓以上内容。"
)
.setCancelable(false)
.setPositiveButton("我同意并知晓") { _, _ ->
prefs.setDisclaimerAccepted()
checkSavedLogin()
}
.setNegativeButton("我拒绝") { _, _ ->
finish()
}
.show()
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
// ── Navigation ──
private fun setupNav() {
binding.bottomNav.setOnItemSelectedListener { item ->
when (item.itemId) {
R.id.nav_punch -> showView(binding.punchView)
R.id.nav_gps -> showView(binding.gpsView)
R.id.nav_history -> {
showView(binding.historyView)
renderHistory()
}
}
true
}
}
private fun showView(target: View) {
binding.punchView.visibility = View.GONE
binding.gpsView.visibility = View.GONE
binding.historyView.visibility = View.GONE
target.visibility = View.VISIBLE
}
private fun showLogin() {
binding.loginView.visibility = View.VISIBLE
binding.guideView.visibility = View.GONE
binding.mainContent.visibility = View.GONE
binding.bottomNav.visibility = View.GONE
binding.headerUserBar.visibility = View.GONE
}
private fun showGuide(qrUrl: String) {
binding.loginView.visibility = View.GONE
binding.guideView.visibility = View.VISIBLE
binding.mainContent.visibility = View.GONE
binding.bottomNav.visibility = View.GONE
binding.headerUserBar.visibility = View.GONE
binding.tvWeixinLink.text = qrUrl
}
private fun showApp() {
binding.loginView.visibility = View.GONE
binding.guideView.visibility = View.GONE
binding.mainContent.visibility = View.VISIBLE
binding.bottomNav.visibility = View.VISIBLE
binding.headerUserBar.visibility = View.VISIBLE
binding.bottomNav.selectedItemId = R.id.nav_punch
showView(binding.punchView)
loadGpsPresets()
loadCurrentGps()
}
// ── Login ──
private var pollingJob: Job? = null
private var captchaInited = false
private fun setupLogin() {
binding.captchaImg.setOnClickListener { refreshCaptcha() }
binding.btnLogin.setOnClickListener { doLogin() }
binding.btnCookieLogin.setOnClickListener { doCookieLogin() }
binding.btnCopyLink.setOnClickListener { copyWeixinLink() }
binding.btnBackToLogin.setOnClickListener {
pollingJob?.cancel()
showLogin()
refreshCaptcha()
}
refreshCaptcha()
}
private fun checkSavedLogin() {
val s = prefs.getCookieS()
val remember = prefs.getCookieRemember()
if (s.isNotEmpty()) {
scope.launch {
val r = withContext(Dispatchers.IO) { api.cookieLogin(s, remember) }
if (r.ok) {
binding.tvUser.text = r.userName
prefs.saveUser(r.userName)
showApp()
} else {
showLogin()
}
}
} else {
showLogin()
}
}
private fun refreshCaptcha() {
scope.launch {
try {
val bytes = withContext(Dispatchers.IO) {
if (captchaInited) api.refreshCaptchaOnly() else api.getCaptchaBytes()
}
captchaInited = true
val bmp = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
if (bmp != null) binding.captchaImg.setImageBitmap(bmp)
} catch (_: Exception) {
toast("验证码加载失败")
}
}
}
private fun doLogin() {
val name = binding.etName.text.toString().trim()
val no = binding.etNo.text.toString().trim()
val code = binding.etCode.text.toString().trim()
if (name.isEmpty() || no.isEmpty() || code.isEmpty()) {
toast("请填写完整信息")
return
}
binding.btnLogin.isEnabled = false
binding.btnLogin.text = "登录中…"
scope.launch {
val r = withContext(Dispatchers.IO) { api.loginAndGetQr(name, no, code) }
binding.btnLogin.isEnabled = true
binding.btnLogin.text = "登 录"
if (r.ok) {
toast("请复制链接发送到微信")
showGuide(r.qrUrl)
startPolling()
} else {
toast(r.msg)
refreshCaptcha()
}
}
}
private fun copyWeixinLink() {
val link = binding.tvWeixinLink.text.toString()
if (link.isEmpty()) return
val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
cm.setPrimaryClip(android.content.ClipData.newPlainText("login_link", link))
toast("已复制!请发送到微信「文件传输助手」并打开")
}
private fun startPolling() {
pollingJob?.cancel()
binding.tvPollingStatus.visibility = View.VISIBLE
binding.tvPollingStatus.text = "等待微信确认登录…"
pollingJob = scope.launch {
var attempts = 0
while (attempts < 30) {
delay(2000)
attempts++
binding.tvPollingStatus.text = "等待微信确认登录… ($attempts)"
try {
val r = withContext(Dispatchers.IO) { api.checkWeixinLogin() }
if (r.ok) {
binding.tvPollingStatus.text = "登录成功,正在跳转…"
val loginResult = withContext(Dispatchers.IO) {
api.completeWeixinLogin(r.redirectUrl)
}
if (loginResult.ok) {
toast("登录成功")
prefs.saveCookies(api.getSavedCookieS(), api.getSavedCookieRemember())
prefs.saveUser(loginResult.userName)
binding.tvUser.text = loginResult.userName
showApp()
} else {
toast("登录跳转失败: ${loginResult.msg}")
}
return@launch
}
} catch (_: Exception) { }
}
binding.tvPollingStatus.text = "链接已过期,请重新登录"
toast("登录超时")
}
}
private fun doCookieLogin() {
val s = binding.etCookieS.text.toString().trim()
if (s.isEmpty()) {
toast("请填写 s cookie")
return
}
val remember = binding.etCookieRemember.text.toString().trim()
binding.btnCookieLogin.isEnabled = false
scope.launch {
val r = withContext(Dispatchers.IO) { api.cookieLogin(s, remember) }
binding.btnCookieLogin.isEnabled = true
if (r.ok) {
toast("登录成功")
prefs.saveCookies(s, remember)
prefs.saveUser(r.userName)
binding.tvUser.text = r.userName
showApp()
} else {
toast(r.msg)
}
}
}
// ── Punch ──
private fun setupPunch() {
binding.btnRefresh.setOnClickListener { refreshPunchs() }
binding.btnLogout.setOnClickListener { doLogout() }
}
private fun doLogout() {
pollingJob?.cancel()
api.clearCookies()
prefs.clearLogin()
captchaInited = false
showLogin()
refreshCaptcha()
toast("已退出")
}
private fun refreshPunchs() {
binding.punchListContainer.removeAllViews()
addLoadingView(binding.punchListContainer)
scope.launch {
try {
val courses = withContext(Dispatchers.IO) { api.getCourses() }
binding.punchListContainer.removeAllViews()
if (courses.isEmpty()) {
addEmptyView(binding.punchListContainer, "未找到课程")
return@launch
}
var hasPunch = false
for (course in courses) {
val punchs = withContext(Dispatchers.IO) { api.getPunchs(course.id) }
if (punchs.isEmpty()) continue
hasPunch = true
for (p in punchs) {
addPunchCard(binding.punchListContainer, course, p)
}
}
if (!hasPunch) {
addEmptyView(binding.punchListContainer, "暂无进行中的签到")
}
} catch (e: Exception) {
binding.punchListContainer.removeAllViews()
addEmptyView(binding.punchListContainer, "加载失败: ${e.message}")
}
}
}
private fun addPunchCard(container: ViewGroup, course: CourseInfo, punch: PunchInfo) {
val card = LayoutInflater.from(this).inflate(R.layout.item_punch, container, false)
card.findViewById<TextView>(R.id.tvPunchTitle).text = punch.title
card.findViewById<TextView>(R.id.tvPunchMeta).text = "${course.title} · ${punch.meta}"
val tvStatus = card.findViewById<TextView>(R.id.tvPunchStatus)
tvStatus.text = punch.status
when {
"已签" in punch.status || "已结束" in punch.status -> {
tvStatus.setBackgroundResource(R.drawable.bg_pill_success)
tvStatus.setTextColor(Color.parseColor("#21b26f"))
}
"进行" in punch.status || "已开始" in punch.status -> {
tvStatus.setBackgroundResource(R.drawable.bg_pill_primary)
tvStatus.setTextColor(Color.parseColor("#4f6ef7"))
}
else -> {
tvStatus.setBackgroundResource(R.drawable.bg_pill_muted)
tvStatus.setTextColor(Color.parseColor("#8a94a6"))
}
}
card.setOnClickListener {
if (punch.id.isNullOrEmpty()) {
toast("签到未开始或无法获取 ID")
return@setOnClickListener
}
confirmPunch(course.id, punch.id, punch.title)
}
container.addView(card)
}
private fun confirmPunch(courseId: String, punchId: String, title: String) {
val lat = binding.etLat.text.toString().toDoubleOrNull()
val lng = binding.etLng.text.toString().toDoubleOrNull()
if (lat == null || lng == null) {
toast("请先设置 GPS 坐标")
return
}
AlertDialog.Builder(this)
.setTitle("确认签到")
.setMessage("签到「$title\n坐标: ($lat, $lng)")
.setPositiveButton("签到") { _, _ ->
executePunch(courseId, punchId, title, lat, lng)
}
.setNegativeButton("取消", null)
.show()
}
private fun executePunch(courseId: String, punchId: String, title: String, lat: Double, lng: Double) {
scope.launch {
try {
val r = withContext(Dispatchers.IO) { api.doPunch(courseId, punchId, lat, lng) }
if (r.ok) {
toast("${r.msg}")
} else {
toast("${r.msg}")
}
val now = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CHINA).format(Date())
prefs.addHistory(PunchRecord(title, lat, lng, r.ok, r.msg, now))
refreshPunchs()
} catch (e: Exception) {
toast("网络错误: ${e.message}")
}
}
}
// ── GPS Settings ──
private fun setupGps() {
binding.btnAddPreset.setOnClickListener { addGpsPreset() }
}
private fun loadCurrentGps() {
val presets = prefs.getPresets()
if (presets.isNotEmpty()) {
binding.etLat.setText(presets[0].lat.toString())
binding.etLng.setText(presets[0].lng.toString())
}
}
private fun loadGpsPresets() {
val presets = prefs.getPresets()
// Chips in punch view
binding.chipGroupPresets.removeAllViews()
for ((i, p) in presets.withIndex()) {
val chip = Chip(this).apply {
text = p.name
isCheckable = true
setOnClickListener {
binding.etLat.setText(p.lat.toString())
binding.etLng.setText(p.lng.toString())
toast("已切换: ${p.name}")
}
}
binding.chipGroupPresets.addView(chip)
}
// List in GPS view
binding.presetListContainer.removeAllViews()
for ((i, p) in presets.withIndex()) {
val row = LayoutInflater.from(this).inflate(R.layout.item_preset, binding.presetListContainer, false)
row.findViewById<TextView>(R.id.tvPresetName).text = p.name
row.findViewById<TextView>(R.id.tvPresetCoord).text = "${p.lat}, ${p.lng}"
row.findViewById<View>(R.id.btnDeletePreset).setOnClickListener {
prefs.removePreset(i)
loadGpsPresets()
toast("已删除")
}
binding.presetListContainer.addView(row)
}
}
private fun addGpsPreset() {
val name = binding.etPresetName.text.toString().trim()
val lat = binding.etPresetLat.text.toString().toDoubleOrNull()
val lng = binding.etPresetLng.text.toString().toDoubleOrNull()
if (name.isEmpty()) { toast("请输入位置名称"); return }
if (lat == null || lng == null) { toast("请输入有效坐标"); return }
prefs.addPreset(GpsPreset(name, lat, lng))
loadGpsPresets()
binding.etPresetName.text?.clear()
binding.etPresetLat.text?.clear()
binding.etPresetLng.text?.clear()
toast("位置已保存")
}
// ── History ──
private fun setupHistory() {}
private fun renderHistory() {
binding.historyListContainer.removeAllViews()
val list = prefs.getHistory()
if (list.isEmpty()) {
addEmptyView(binding.historyListContainer, "暂无签到记录")
return
}
for (r in list) {
val card = LayoutInflater.from(this).inflate(R.layout.item_history, binding.historyListContainer, false)
card.findViewById<TextView>(R.id.tvHistoryTitle).text = r.title
card.findViewById<TextView>(R.id.tvHistoryTime).text = "${r.time} · (${r.lat}, ${r.lng})"
val tvResult = card.findViewById<TextView>(R.id.tvHistoryResult)
tvResult.text = r.msg
if (r.ok) {
tvResult.setBackgroundResource(R.drawable.bg_pill_success)
tvResult.setTextColor(Color.parseColor("#21b26f"))
} else {
tvResult.setBackgroundResource(R.drawable.bg_pill_danger)
tvResult.setTextColor(Color.parseColor("#ff4d4f"))
}
binding.historyListContainer.addView(card)
}
}
// ── Helpers ──
private fun toast(msg: String) {
Snackbar.make(binding.root, msg, Snackbar.LENGTH_SHORT).show()
}
private fun addLoadingView(container: ViewGroup) {
val pb = ProgressBar(this).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
if (this is LinearLayout.LayoutParams) gravity = android.view.Gravity.CENTER_HORIZONTAL
}
}
val wrapper = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = android.view.Gravity.CENTER
setPadding(0, 64, 0, 64)
addView(pb)
}
container.addView(wrapper)
}
private fun addEmptyView(container: ViewGroup, text: String) {
val tv = TextView(this).apply {
this.text = text
setTextColor(Color.parseColor("#8a94a6"))
textSize = 14f
gravity = android.view.Gravity.CENTER
setPadding(32, 64, 32, 64)
}
container.addView(tv)
}
}

View File

@@ -0,0 +1,109 @@
package com.gpspunch.app
import android.content.Context
import android.content.SharedPreferences
import org.json.JSONArray
import org.json.JSONObject
data class GpsPreset(val name: String, val lat: Double, val lng: Double)
data class PunchRecord(
val title: String, val lat: Double, val lng: Double,
val ok: Boolean, val msg: String, val time: String
)
class PrefsManager(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("gps_punch", Context.MODE_PRIVATE)
fun saveCookies(s: String, remember: String) {
prefs.edit().putString("cookie_s", s).putString("cookie_remember", remember).apply()
}
fun getCookieS(): String = prefs.getString("cookie_s", "") ?: ""
fun getCookieRemember(): String = prefs.getString("cookie_remember", "") ?: ""
fun saveUser(name: String) {
prefs.edit().putString("user_name", name).apply()
}
fun getUserName(): String = prefs.getString("user_name", "") ?: ""
fun clearLogin() {
prefs.edit()
.remove("cookie_s").remove("cookie_remember").remove("user_name")
.apply()
}
fun isDisclaimerAccepted(): Boolean = prefs.getBoolean("disclaimer_accepted", false)
fun setDisclaimerAccepted() {
prefs.edit().putBoolean("disclaimer_accepted", true).apply()
}
fun getPresets(): MutableList<GpsPreset> {
val json = prefs.getString("gps_presets", null) ?: return mutableListOf(
GpsPreset("默认位置", 0.0, 0.0)
)
val arr = JSONArray(json)
val list = mutableListOf<GpsPreset>()
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
list.add(GpsPreset(o.getString("name"), o.getDouble("lat"), o.getDouble("lng")))
}
return list
}
fun savePresets(list: List<GpsPreset>) {
val arr = JSONArray()
for (p in list) {
arr.put(JSONObject().put("name", p.name).put("lat", p.lat).put("lng", p.lng))
}
prefs.edit().putString("gps_presets", arr.toString()).apply()
}
fun addPreset(preset: GpsPreset) {
val list = getPresets()
list.add(preset)
savePresets(list)
}
fun removePreset(index: Int) {
val list = getPresets()
if (index in list.indices) {
list.removeAt(index)
savePresets(list)
}
}
fun getHistory(): MutableList<PunchRecord> {
val json = prefs.getString("punch_history", null) ?: return mutableListOf()
val arr = JSONArray(json)
val list = mutableListOf<PunchRecord>()
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
list.add(
PunchRecord(
o.getString("title"), o.getDouble("lat"), o.getDouble("lng"),
o.getBoolean("ok"), o.getString("msg"), o.getString("time")
)
)
}
return list
}
fun addHistory(record: PunchRecord) {
val list = getHistory()
list.add(0, record)
if (list.size > 50) list.subList(50, list.size).clear()
val arr = JSONArray()
for (r in list) {
arr.put(
JSONObject()
.put("title", r.title).put("lat", r.lat).put("lng", r.lng)
.put("ok", r.ok).put("msg", r.msg).put("time", r.time)
)
}
prefs.edit().putString("punch_history", arr.toString()).apply()
}
}

View File

@@ -0,0 +1,390 @@
package com.gpspunch.app
import okhttp3.*
import org.jsoup.Jsoup
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
data class LoginResult(val ok: Boolean, val msg: String, val userName: String = "")
data class CourseInfo(val id: String, val title: String)
data class PunchInfo(
val id: String?,
val title: String,
val status: String,
val meta: String,
val needsGps: Boolean
)
data class PunchResult(val ok: Boolean, val msg: String)
class PunchApi {
companion object {
private const val APP_DOMAIN = "wx703c2206450720dd.g8n.cn"
private const val BASE_URL = "https://$APP_DOMAIN"
private const val LOGIN_DOMAIN = "login.b8n.cn"
private const val LOGIN_URL = "https://$LOGIN_DOMAIN"
private const val SCHOOL_ID = 45
private const val UA =
"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) " +
"AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/145.0.0.0 Mobile Safari/537.36"
}
private val cookieStore = ConcurrentHashMap<String, MutableList<Cookie>>()
private val cookieJar = object : CookieJar {
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val host = url.host
cookieStore.getOrPut(host) { mutableListOf() }.apply {
for (c in cookies) {
removeAll { it.name == c.name }
add(c)
}
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
return cookieStore[url.host] ?: emptyList()
}
}
private val client = OkHttpClient.Builder()
.cookieJar(cookieJar)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.followRedirects(true)
.build()
private val clientNoRedirect = OkHttpClient.Builder()
.cookieJar(cookieJar)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.followRedirects(false)
.followSslRedirects(false)
.build()
var currentUser: String = ""
private set
private fun getUid(): String? {
val allCookies = cookieStore[APP_DOMAIN] ?: return null
for (c in allCookies) {
val m = Regex("(\\d+)%7C").find(c.value)
if (m != null) return m.groupValues[1]
}
return null
}
data class WeixinQrResult(val ok: Boolean, val qrUrl: String = "", val msg: String = "")
data class CheckLoginResult(val ok: Boolean, val redirectUrl: String = "")
fun initLoginSession() {
val req = Request.Builder()
.url("$BASE_URL/student/login")
.header("User-Agent", UA)
.header("Accept", "text/html,*/*")
.build()
client.newCall(req).execute().close()
android.util.Log.d("PunchApi", "login session init done")
}
fun getCaptchaBytes(): ByteArray {
initLoginSession()
val req = Request.Builder()
.url("$LOGIN_URL/captcha/default?t=${System.currentTimeMillis()}")
.header("User-Agent", UA)
.header("Referer", "$LOGIN_URL/fields/login/student/$SCHOOL_ID")
.build()
client.newCall(req).execute().use { return it.body?.bytes() ?: ByteArray(0) }
}
fun refreshCaptchaOnly(): ByteArray {
val req = Request.Builder()
.url("$LOGIN_URL/captcha/default?t=${System.currentTimeMillis()}")
.header("User-Agent", UA)
.header("Referer", "$LOGIN_URL/fields/login/student/$SCHOOL_ID")
.build()
client.newCall(req).execute().use { return it.body?.bytes() ?: ByteArray(0) }
}
fun loginAndGetQr(name: String, no: String, checkcode: String): WeixinQrResult {
val formBody = FormBody.Builder()
.add("name", name)
.add("no", no)
.add("checkcode", checkcode)
.build()
val req = Request.Builder()
.url("$LOGIN_URL/fields/login/student/$SCHOOL_ID")
.header("User-Agent", UA)
.header("Referer", "$LOGIN_URL/fields/login/student/$SCHOOL_ID")
.header("Origin", LOGIN_URL)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "zh-CN,zh;q=0.9")
.header("Upgrade-Insecure-Requests", "1")
.post(formBody)
.build()
val resp = client.newCall(req).execute()
val body = resp.body?.string() ?: ""
resp.close()
android.util.Log.d("PunchApi", "login POST → HTTP ${resp.code}, title match=${("<title>stop</title>" in body)}")
if ("验证码" in body && ("错误" in body || "不正确" in body))
return WeixinQrResult(false, msg = "验证码错误,请刷新重试")
if ("不匹配" in body)
return WeixinQrResult(false, msg = "姓名与学号不匹配")
if ("不存在" in body)
return WeixinQrResult(false, msg = "学号不存在")
if ("<title>stop</title>" in body || "选择其他登录方式" in body || "/qr/weixin/" in body) {
android.util.Log.d("PunchApi", "got stop page, fetching weixin QR...")
return fetchWeixinQr()
}
val doc = Jsoup.parse(body)
val err = doc.selectFirst(".alert-danger, .text-danger, .error, .invalid-feedback")
return WeixinQrResult(false, msg = err?.text()?.ifEmpty { "登录失败" } ?: "登录失败")
}
private fun fetchWeixinQr(): WeixinQrResult {
val qrReq = Request.Builder()
.url("$LOGIN_URL/qr/weixin/student/$SCHOOL_ID")
.header("User-Agent", UA)
.header("Accept", "text/html,*/*")
.header("Referer", "$LOGIN_URL/fields/login/student/$SCHOOL_ID")
.build()
val resp = client.newCall(qrReq).execute()
val body = resp.body?.string() ?: ""
resp.close()
val m = Regex("""var\s+qrurl\s*=\s*"([^"]+)"""").find(body)
if (m != null) {
val url = m.groupValues[1]
android.util.Log.d("PunchApi", "got qrurl: $url")
return WeixinQrResult(true, url)
}
android.util.Log.d("PunchApi", "qr page fail: ${body.take(400)}")
return WeixinQrResult(false, msg = "无法获取微信登录链接")
}
fun checkWeixinLogin(): CheckLoginResult {
val req = Request.Builder()
.url("$LOGIN_URL/qr/weixin/student/$SCHOOL_ID?op=checklogin")
.header("User-Agent", UA)
.header("Accept", "application/json, */*")
.header("X-Requested-With", "XMLHttpRequest")
.header("Referer", "$LOGIN_URL/qr/weixin/student/$SCHOOL_ID")
.build()
val resp = client.newCall(req).execute()
val body = resp.body?.string() ?: ""
resp.close()
android.util.Log.d("PunchApi", "checklogin: $body")
if ("\"status\":true" in body || "\"status\": true" in body) {
val um = Regex("""\"url\"\s*:\s*\"([^\"]+)\"""").find(body)
val redirectUrl = um?.groupValues?.get(1) ?: ""
return CheckLoginResult(true, redirectUrl)
}
return CheckLoginResult(false)
}
fun completeWeixinLogin(redirectUrl: String): LoginResult {
val cleaned = redirectUrl.replace("\\/", "/")
val fullUrl = if (cleaned.startsWith("http")) cleaned
else "$LOGIN_URL$cleaned"
android.util.Log.d("PunchApi", "completing login, redirect: $fullUrl")
val req = Request.Builder()
.url(fullUrl)
.header("User-Agent", UA)
.header("Accept", "text/html,*/*")
.build()
val resp = client.newCall(req).execute()
val body = resp.body?.string() ?: ""
resp.close()
android.util.Log.d("PunchApi", "complete login final url: ${resp.request.url}")
val m = Regex("uname['\"]?\\s*[:=]\\s*['\"]([^'\"]+)").find(body)
currentUser = m?.groupValues?.get(1) ?: ""
val appCookies = cookieStore[APP_DOMAIN]
if (appCookies?.any { it.name == "s" } == true) {
return LoginResult(true, "登录成功", currentUser.ifEmpty { "已登录" })
}
if ("课程" in body || "考勤" in body || "uname" in body) {
return LoginResult(true, "登录成功", currentUser.ifEmpty { "已登录" })
}
return LoginResult(false, "登录跳转失败")
}
fun cookieLogin(s: String, remember: String = ""): LoginResult {
val sCookie = Cookie.Builder()
.domain(APP_DOMAIN).name("s").value(s).path("/").build()
cookieStore.getOrPut(APP_DOMAIN) { mutableListOf() }.apply {
removeAll { it.name == "s" }
add(sCookie)
}
if (remember.isNotEmpty()) {
val rCookie = Cookie.Builder()
.domain(APP_DOMAIN)
.name("remember_student_59ba36addc2b2f9401580f014c7f58ea4e30989d")
.value(remember).path("/").build()
cookieStore.getOrPut(APP_DOMAIN) { mutableListOf() }.apply {
removeAll { it.name.startsWith("remember_student") }
add(rCookie)
}
}
val req = Request.Builder()
.url("$BASE_URL/student")
.header("User-Agent", UA)
.build()
val resp = client.newCall(req).execute()
val body = resp.body?.string() ?: ""
resp.close()
if ("uname" in body || "课程" in body || "考勤" in body) {
val m = Regex("uname['\"]?\\s*[:=]\\s*['\"]([^'\"]+)").find(body)
currentUser = m?.groupValues?.get(1) ?: "已登录"
return LoginResult(true, "Cookie 登录成功", currentUser)
}
return LoginResult(false, "Cookie 无效或已过期")
}
fun getCourses(): List<CourseInfo> {
val req = Request.Builder()
.url("$BASE_URL/student")
.header("User-Agent", UA)
.build()
val resp = client.newCall(req).execute()
val body = resp.body?.string() ?: ""
resp.close()
val doc = Jsoup.parse(body)
val courses = mutableListOf<CourseInfo>()
val seen = mutableSetOf<String>()
for (a in doc.select("a[href~=/student/course/\\d+]")) {
val m = Regex("/student/course/(\\d+)").find(a.attr("href")) ?: continue
val cid = m.groupValues[1]
if (cid in seen) continue
seen.add(cid)
val title = a.text().trim().ifEmpty { "课程#$cid" }
courses.add(CourseInfo(cid, title))
}
return courses
}
fun getPunchs(courseId: String): List<PunchInfo> {
val req = Request.Builder()
.url("$BASE_URL/student/course/$courseId/punchs?op=ing")
.header("User-Agent", UA)
.build()
val resp = client.newCall(req).execute()
val html = resp.body?.string() ?: ""
resp.close()
val doc = Jsoup.parse(html)
val punchs = mutableListOf<PunchInfo>()
for (card in doc.select(".punch-card")) {
val titleEl = card.selectFirst(".font-weight-bold")
val statusEl = card.selectFirst(".punch-status")
val title = titleEl?.text()?.trim() ?: "未知"
val status = statusEl?.text()?.trim() ?: "未知"
var punchId: String? = null
for (a in card.select("a[href]")) {
val m = Regex("/punchs?/(?:course/)?(?:\\d+/)?(\\d{5,})").find(a.attr("href"))
if (m != null) {
punchId = m.groupValues[1]
break
}
}
if (punchId == null) {
for (tag in card.allElements) {
for ((_, v) in tag.attributes()) {
val m = Regex("(\\d{7,})").find(v)
if (m != null) {
punchId = m.groupValues[1]
break
}
}
if (punchId != null) break
}
}
val metaEl = card.selectFirst(".punch-meta")
val meta = metaEl?.text()?.trim() ?: ""
val needsGps = "GPS" in meta || "gps" in meta.lowercase()
punchs.add(PunchInfo(punchId, title, status, meta, needsGps))
}
return punchs
}
fun doPunch(courseId: String, punchId: String, lat: Double, lng: Double, acc: Int = 20): PunchResult {
val uid = getUid()
var url = "$BASE_URL/student/punchs/course/$courseId/$punchId"
if (uid != null) url += "?sid=$uid"
val formBody = FormBody.Builder()
.add("lat", lat.toString())
.add("lng", lng.toString())
.add("acc", acc.toString())
.add("res", "")
.build()
val req = Request.Builder()
.url(url)
.header("User-Agent", UA)
.header("Origin", BASE_URL)
.header("Referer", url)
.post(formBody)
.build()
val resp = client.newCall(req).execute()
val body = resp.body?.string() ?: ""
resp.close()
return when {
"已签到" in body || "签到过" in body || "已签" in body ->
PunchResult(true, "签到成功!")
"成功" in body || "success" in body.lowercase() ->
PunchResult(true, "签到成功!")
"未开始" in body ->
PunchResult(false, "签到尚未开始")
"已结束" in body ->
PunchResult(false, "签到已结束")
else ->
PunchResult(true, "已提交,请确认状态")
}
}
fun isLoggedIn(): Boolean {
return cookieStore[APP_DOMAIN]?.any { it.name == "s" } == true
}
fun getSavedCookieS(): String {
return cookieStore[APP_DOMAIN]?.find { it.name == "s" }?.value ?: ""
}
fun getSavedCookieRemember(): String {
return cookieStore[APP_DOMAIN]
?.find { it.name.startsWith("remember_student") }?.value ?: ""
}
fun clearCookies() {
cookieStore.clear()
currentUser = ""
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<stroke android:width="1dp" android:color="#e0e0e0" />
<corners android:radius="8dp" />
</shape>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:startColor="#f6f8fb"
android:endColor="#e8edff"
android:angle="135" />
<corners android:radius="14dp" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:startColor="#3a5ef7"
android:endColor="#6b8cff"
android:angle="135" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#1eff4d4f" />
<corners android:radius="999dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#1e8a94a6" />
<corners android:radius="999dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#1e4f6ef7" />
<corners android:radius="999dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#1e21b26f" />
<corners android:radius="999dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#33ffffff" />
<corners android:radius="10dp" />
</shape>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M54,30 C40.745,30 30,40.745 30,54 C30,67.255 40.745,78 54,78 C67.255,78 78,67.255 78,54 C78,40.745 67.255,30 54,30ZM54,34 C65.046,34 74,42.954 74,54 C74,65.046 65.046,74 54,74 C42.954,74 34,65.046 34,54 C34,42.954 42.954,34 54,34ZM54,44 C48.477,44 44,48.477 44,54 C44,59.523 48.477,64 54,64 C59.523,64 64,59.523 64,54 C64,48.477 59.523,44 54,44ZM54,48 C57.314,48 60,50.686 60,54 C60,57.314 57.314,60 54,60 C50.686,60 48,57.314 48,54 C48,50.686 50.686,48 54,48Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M53,24 L55,24 L55,32 L53,32Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M53,76 L55,76 L55,84 L53,84Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M24,53 L32,53 L32,55 L24,55Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M76,53 L84,53 L84,55 L76,55Z" />
</vector>

View File

@@ -0,0 +1,729 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#f0f2f5">
<!-- Header -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/bg_header"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="GPS 签到助手"
android:textColor="#ffffff"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="自定义位置 · 一键打卡"
android:textColor="#ccddff"
android:textSize="13sp"
android:layout_marginTop="2dp" />
<!-- User bar -->
<LinearLayout
android:id="@+id/headerUserBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="@drawable/bg_user_bar"
android:padding="10dp"
android:layout_marginTop="12dp"
android:visibility="gone">
<TextView
android:id="@+id/tvUser"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="#ffffff"
android:textSize="14sp" />
<Button
android:id="@+id/btnLogout"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:text="退出"
android:textColor="#ffffff"
android:textSize="12sp"
android:minWidth="0dp"
android:paddingHorizontal="12dp" />
</LinearLayout>
</LinearLayout>
<!-- ===== Login View ===== -->
<ScrollView
android:id="@+id/loginView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Login Form Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="14dp"
app:cardElevation="4dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="学生身份验证"
android:textSize="17sp"
android:textStyle="bold"
android:layout_marginBottom="16dp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="姓名"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_marginBottom="12dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="学号"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_marginBottom="12dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etNo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="验证码(点击图片刷新)"
android:textSize="12sp"
android:textColor="#666"
android:layout_marginBottom="6dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="4位数字"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_marginEnd="12dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etCode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:maxLength="6" />
</com.google.android.material.textfield.TextInputLayout>
<ImageView
android:id="@+id/captchaImg"
android:layout_width="120dp"
android:layout_height="48dp"
android:scaleType="fitXY"
android:background="@drawable/bg_captcha"
android:contentDescription="验证码" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnLogin"
android:layout_width="match_parent"
android:layout_height="52dp"
android:text="登 录"
android:textSize="16sp"
app:cornerRadius="12dp" />
<!-- removed, moved to guideView -->
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Divider -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal"
android:layout_marginVertical="8dp">
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1"
android:background="#e0e0e0" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=" 或 "
android:textColor="#999"
android:textSize="12sp" />
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1"
android:background="#e0e0e0" />
</LinearLayout>
<!-- Cookie Login Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="14dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Cookie 登录"
android:textSize="17sp"
android:textStyle="bold"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="从微信浏览器抓包获取 Cookie"
android:textSize="12sp"
android:textColor="#999"
android:layout_marginBottom="14dp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="s (session cookie)"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_marginBottom="12dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etCookieS"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="remember_student可选"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_marginBottom="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etCookieRemember"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnCookieLogin"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="52dp"
android:text="Cookie 登录"
android:textSize="15sp"
app:cornerRadius="12dp" />
</LinearLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>
</ScrollView>
<!-- ===== WeChat Guide View ===== -->
<ScrollView
android:id="@+id/guideView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp"
android:gravity="center_horizontal">
<!-- Success Icon -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="微信登录"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@color/text_primary"
android:layout_marginTop="10dp"
android:layout_marginBottom="20dp" />
<!-- Steps Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="14dp"
app:cardElevation="4dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="操作步骤"
android:textSize="16sp"
android:textStyle="bold"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="① 点击下方按钮复制登录链接"
android:textSize="15sp"
android:textColor="@color/text_primary"
android:drawablePadding="8dp"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="② 打开微信,发送给「文件传输助手」"
android:textSize="15sp"
android:textColor="@color/text_primary"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="③ 在微信中点击该链接"
android:textSize="15sp"
android:textColor="@color/text_primary"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="④ 返回本 App自动登录完成"
android:textSize="15sp"
android:textColor="@color/primary"
android:textStyle="bold" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Link Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="14dp"
app:cardElevation="4dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="登录链接"
android:textSize="13sp"
android:textColor="#666"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/tvWeixinLink"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_gps_box"
android:padding="14dp"
android:textSize="12sp"
android:textColor="@color/primary"
android:fontFamily="monospace"
android:textIsSelectable="true" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnCopyLink"
android:layout_width="match_parent"
android:layout_height="54dp"
android:text="复制链接到剪贴板"
android:textSize="16sp"
android:textStyle="bold"
app:cornerRadius="12dp"
android:layout_marginTop="14dp" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Polling Status -->
<TextView
android:id="@+id/tvPollingStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textSize="16sp"
android:textColor="@color/primary"
android:textStyle="bold"
android:padding="16dp"
android:visibility="gone" />
<!-- Back button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnBackToLogin"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="返回重新登录"
android:textSize="14sp"
android:layout_marginTop="8dp" />
</LinearLayout>
</ScrollView>
<!-- ===== Main Content ===== -->
<FrameLayout
android:id="@+id/mainContent"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:visibility="gone">
<!-- Punch View -->
<ScrollView
android:id="@+id/punchView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="visible">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- GPS Quick Box -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/bg_gps_box"
android:padding="16dp"
android:layout_marginBottom="14dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="📍 当前定位"
android:textSize="15sp"
android:textStyle="bold"
android:layout_marginBottom="12dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="纬度 (lat)"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_marginEnd="8dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etLat"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal|numberSigned"
android:hint="输入纬度" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="经度 (lng)"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etLng"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal|numberSigned"
android:hint="输入经度" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
<com.google.android.material.chip.ChipGroup
android:id="@+id/chipGroupPresets"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
app:singleSelection="true" />
</LinearLayout>
<!-- Punch list header -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:layout_marginBottom="10dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="签到列表"
android:textSize="16sp"
android:textStyle="bold" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnRefresh"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:text="刷新"
android:textSize="13sp"
app:cornerRadius="10dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/punchListContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="点击「刷新」获取签到列表"
android:textColor="#8a94a6"
android:textSize="14sp"
android:gravity="center"
android:padding="48dp" />
</LinearLayout>
</LinearLayout>
</ScrollView>
<!-- GPS View -->
<ScrollView
android:id="@+id/gpsView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="14dp"
app:cardElevation="4dp"
android:layout_marginBottom="14dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="添加常用位置"
android:textSize="17sp"
android:textStyle="bold"
android:layout_marginBottom="14dp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="位置名称"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_marginBottom="12dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPresetName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="14dp">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="纬度"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_marginEnd="8dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPresetLat"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal|numberSigned" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="经度"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPresetLng"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal|numberSigned" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAddPreset"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="添加到常用位置"
app:cornerRadius="12dp" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Saved presets list -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="已保存的位置"
android:textSize="15sp"
android:textStyle="bold"
android:layout_marginBottom="10dp"
android:layout_marginTop="4dp" />
<LinearLayout
android:id="@+id/presetListContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
<!-- Guide -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="14dp"
app:cardElevation="4dp"
android:layout_marginTop="14dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="如何获取坐标?"
android:textSize="15sp"
android:textStyle="bold"
android:layout_marginBottom="10dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1. 打开手机地图 App高德/百度/腾讯)\n2. 长按目标位置,弹出坐标信息\n3. 复制经纬度填入上方\n\n注意使用 GCJ-02 坐标系(高德/腾讯地图默认即是)"
android:textSize="13sp"
android:textColor="#666"
android:lineSpacingExtra="4dp" />
</LinearLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>
</ScrollView>
<!-- History View -->
<ScrollView
android:id="@+id/historyView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone">
<LinearLayout
android:id="@+id/historyListContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp" />
</ScrollView>
</FrameLayout>
<!-- Bottom Navigation -->
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottomNav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
app:menu="@menu/bottom_nav"
app:labelVisibilityMode="labeled"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="14dp"
app:cardElevation="4dp"
android:layout_marginBottom="10dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvHistoryTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvHistoryResult"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="10dp"
android:paddingVertical="3dp"
android:textSize="11sp"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:id="@+id/tvHistoryTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="#8a94a6"
android:layout_marginTop="6dp" />
</LinearLayout>
</androidx.cardview.widget.CardView>

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
android:layout_marginBottom="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="14dp"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvPresetName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvPresetCoord"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="#8a94a6"
android:layout_marginTop="2dp" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDeletePreset"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:text="删除"
android:textColor="#ff4d4f"
android:textSize="12sp"
android:minWidth="0dp" />
</LinearLayout>
</androidx.cardview.widget.CardView>

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="14dp"
app:cardElevation="4dp"
android:layout_marginBottom="10dp"
android:foreground="?android:attr/selectableItemBackground"
android:clickable="true"
android:focusable="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvPunchTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="15sp"
android:textStyle="bold"
android:textColor="#1a1a2e" />
<TextView
android:id="@+id/tvPunchMeta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="#8a94a6"
android:layout_marginTop="4dp" />
</LinearLayout>
<TextView
android:id="@+id/tvPunchStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="12dp"
android:paddingVertical="4dp"
android:textSize="12sp"
android:textStyle="bold" />
</LinearLayout>
</androidx.cardview.widget.CardView>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/nav_punch"
android:icon="@android:drawable/ic_menu_mylocation"
android:title="签到" />
<item
android:id="@+id/nav_gps"
android:icon="@android:drawable/ic_menu_mapmode"
android:title="GPS设置" />
<item
android:id="@+id/nav_history"
android:icon="@android:drawable/ic_menu_recent_history"
android:title="历史" />
</menu>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 B

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#4f6ef7</color>
<color name="primary_dark">#3a5ef7</color>
<color name="primary_light">#e8edff</color>
<color name="success">#21b26f</color>
<color name="danger">#ff4d4f</color>
<color name="warn">#ff9f1a</color>
<color name="muted">#8a94a6</color>
<color name="bg">#f0f2f5</color>
<color name="white">#ffffff</color>
<color name="text_primary">#1a1a2e</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">GPS签到助手</string>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.GPSPunch" parent="Theme.Material3.Light.NoActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryDark">@color/primary_dark</item>
<item name="colorOnPrimary">@color/white</item>
<item name="android:statusBarColor">@color/primary_dark</item>
<item name="android:windowBackground">@color/bg</item>
</style>
</resources>

4
build.gradle Normal file
View File

@@ -0,0 +1,4 @@
plugins {
id 'com.android.application' version '8.2.2' apply false
id 'org.jetbrains.kotlin.android' version '1.9.22' apply false
}

4
gradle.properties Normal file
View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

249
gradlew vendored Executable file
View File

@@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

92
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

16
settings.gradle Normal file
View File

@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "GPSPunchApp"
include ':app'