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

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>