Compare commits

..

No commits in common. "main" and "redesign" have entirely different histories.

34 changed files with 366 additions and 2097 deletions

View File

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

View File

@ -1,50 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
</profile>
</component>

View File

@ -1,71 +0,0 @@
# Changelog
Semua perubahan penting pada proyek Aplikasi Absensi Akademik didokumentasikan dalam file ini.
## [Unreleased] - Versi Terbaru (Redesign & Backend Integration)
Perubahan besar-besaran dilakukan pada arsitektur aplikasi, beralih dari aplikasi *single-screen* sederhana menjadi aplikasi manajemen akademik yang komprehensif dengan autentikasi dan validasi keamanan.
### ✨ Fitur Baru (Added)
- **Sistem Autentikasi**:
- Menambahkan layar **Login** dan **Register**.
- Integrasi **JWT Authentication** (penyimpanan token via `SharedPreferences`).
- Manajemen sesi pengguna (Auto-login jika token tersimpan).
- **Manajemen Jadwal & Mata Kuliah**:
- Menambahkan fitur pengambilan data **Jadwal Kuliah** hari ini dari database.
- Dropdown pemilihan mata kuliah saat melakukan absensi.
- **Validasi Keamanan Tingkat Lanjut**:
- **Geofencing**: Validasi lokasi dalam radius 500m dari koordinat kampus.
- **Anti-Fake GPS**: Logika deteksi aplikasi *Mock Location* untuk mencegah kecurangan.
- **Face Detection**: Integrasi **CameraX + ML Kit** (tersirat dalam logika kamera baru) untuk mewajibkan deteksi wajah saat status "HADIR".
- **Fitur Kamera Kustom**:
- Mengganti intent kamera bawaan (`MediaStore`) dengan **CameraX** tertanam dalam UI.
- Fitur overlay kamera untuk panduan posisi wajah/dokumen.
- Dukungan kamera depan (selfie) dan belakang (dokumen).
- **Riwayat & Profil**:
- Menambahkan tab **Riwayat Absensi** untuk melihat log kehadiran dan bukti foto.
- Menambahkan tab **Profil** yang menampilkan data mahasiswa dan fitur Logout.
- **Multi-Status Absensi**:
- Dukungan untuk status **HADIR**, **SAKIT**, dan **IZIN**.
- Logika validasi berbeda untuk setiap status (Sakit/Izin tidak butuh radius lokasi).
### 🎨 Antarmuka & UX (Changed)
- **UI Overhaul (Material3)**:
- Redesign total menggunakan **Jetpack Compose Material3**.
- Penerapan tema identitas kampus (**Gold & Maroon**).
- Penggunaan komponen UI modern: `Card`, `NavigationBar` (Bottom Nav), `Gradient Button`.
- **Navigasi**:
- Implementasi **Bottom Navigation Bar** dengan 4 menu utama (Absensi, Kelas, Riwayat, Profil).
- Transisi antar layar (Login <-> Main <-> Register).
- **Feedback User**:
- Loading indicator saat proses API berjalan.
- Dialog error dan sukses yang lebih informatif dibandingkan `Toast` sederhana.
### ⚙️ Teknis & Backend (Changed)
- **Migrasi API**:
- **Sebelumnya**: Mengirim data hardcoded langsung ke Webhook N8N via `HttpURLConnection`.
- **Sekarang**: Berkomunikasi dengan **Backend Python Flask** (`/api/auth`, `/api/absensi`, `/api/jadwal`). Backend yang kemudian meneruskan data ke N8N.
- **Struktur Kode**:
- Refactoring dari *Single Activity Monolith* menjadi struktur modular.
- Pemisahan logic ke dalam:
- `AppConstants` (Konfigurasi URL & Koordinat).
- `UserPreferences` (Manajemen sesi lokal).
- `Data Classes` (Mahasiswa, Jadwal, Riwayat).
- Helper functions (Bitmap Converter, Distance Calculation).
### 🔥 Dihapus (Removed)
- Menghapus pengiriman data hardcoded (NPM "12345") pada fungsi `kirimKeN8n`.
- Menghapus penggunaan `MediaStore.ACTION_IMAGE_CAPTURE` (Intent kamera eksternal).
- Menghapus tampilan *single-screen* sederhana tanpa navigasi.
---
## [Legacy] - Versi Awal (Sebelum Redesign)
Versi purwarupa (prototype) untuk pengujian fungsionalitas dasar.
### Fitur
- Pengambilan titik koordinat GPS sederhana (`FusedLocationProvider`).
- Pengambilan foto menggunakan aplikasi kamera bawaan HP (`Intent`).
- Pengiriman data dummy langsung ke Webhook N8N.
- Tampilan UI dasar menggunakan `Column` dan `Button` standar.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 715 KiB

196
README.md
View File

@ -1,169 +1,95 @@
# 📱 Aplikasi Absensi Akademik UBHARA Jaya
[📋 Changelog](./CHANGELOG.md)
# 📱 Aplikasi Absensi Akademik Berbasis Koordinat dan Foto (Mobile)
## 📌 Deskripsi Proyek
Proyek ini merupakan **Tugas Project Akhir Mata Kuliah Pemrograman Mobile** yang mengimplementasikan sistem absensi akademik berbasis **Client-Server**. Aplikasi mobile dibangun menggunakan **Kotlin (Jetpack Compose)**, sedangkan sisi server menggunakan **Python Flask** dengan database **MySQL**.
Proyek ini merupakan **Tugas Project Akhir Mata Kuliah Pemrograman Mobile** yang bertujuan untuk membangun **aplikasi akademik berbasis mobile** dengan fokus pada **fitur absensi menggunakan data koordinat (GPS) dan pengambilan foto mahasiswa**.
Sistem ini dirancang dengan validasi ketat berlapis menggunakan **Geofencing (GPS)**, **Location Token**, **Device Binding**, dan **Deteksi Wajah (Face Detection)** untuk status kehadiran "Hadir", serta mendukung pengiriman bukti dokumen untuk status "Sakit/Izin". Seluruh data terintegrasi secara real-time ke **N8N Webhook** untuk pelaporan otomatis.
Aplikasi ini dirancang untuk meningkatkan **validitas kehadiran mahasiswa**, dengan memastikan bahwa absensi hanya dapat dilakukan apabila mahasiswa:
1. Berada pada **lokasi yang telah ditentukan**, dan
2. Melakukan **pengambilan foto (selfie) secara langsung saat absensi**
---
## 🎯 Tujuan Proyek
- Mengimplementasikan arsitektur **REST API** antara Android dan Python Flask.
- Menerapkan **Face Detection** menggunakan CameraX untuk memvalidasi kehadiran fisik mahasiswa.
- Mencegah kecurangan absensi dengan sistem keamanan berlapis (**8 Security Fix**).
- Mengelola status kehadiran otomatis (**Auto-Alfa**) pada server jika mahasiswa lupa absen.
- Melatih kemampuan Fullstack Mobile Development (Backend, Database, & Mobile UI).
- Mengimplementasikan **Location-Based Service (LBS)** pada aplikasi mobile
- Mengintegrasikan **kamera perangkat** untuk dokumentasi absensi
- Mencegah kecurangan absensi (titip absen)
- Mengembangkan aplikasi mobile akademik berbasis Android
- Melatih kemampuan perancangan dan implementasi aplikasi mobile
---
## 🚀 Fitur Utama
### 📱 Sisi Mobile (Android - Jetpack Compose)
- **Modern UI**: Antarmuka modern menggunakan **Material3** & **Jetpack Compose**.
- **Smart Attendance**:
- **Hadir**: Wajib berada di radius kampus & wajib deteksi wajah (Real-time).
- **Sakit/Izin**: Wajib upload foto surat dokter/bukti (Tanpa validasi radius).
- **Anti-Fraud Berlapis**:
- Deteksi dan penolakan **Fake GPS / Mock Location** di sisi client.
- Validasi koordinat GPS di **sisi server** (tidak bisa di-bypass via Postman).
- **Location Token** — token sekali pakai valid 2 menit, diterbitkan server setelah GPS diverifikasi.
- **Device Binding** — akun terikat ke satu perangkat, token tidak bisa dibagikan ke HP lain.
- **Riwayat & Bukti**: Mahasiswa dapat melihat riwayat kehadiran beserta foto bukti yang tersimpan di server.
- **Profil Mahasiswa**: Menampilkan data akademik dan fitur logout.
### 💻 Sisi Backend (Python Flask)
- **JWT Authentication**: Keamanan berbasis token + device binding untuk setiap request.
- **8 Lapis Keamanan**: Validasi koordinat server-side, foto server-side, rate limiting, kepemilikan jadwal, jam kelas aktif, location token, dan deteksi anomali koordinat.
- **Auto-Alfa Trigger**: Sistem otomatis menandai "TIDAK HADIR" jika jam kuliah berakhir dan mahasiswa belum absen.
- **N8N Integration**: Data absensi yang valid dikirim otomatis ke webhook N8N untuk notifikasi/reporting.
- **Image Handling**: Kompresi foto (Base64) otomatis untuk efisiensi penyimpanan database.
- 🔐 **Login Pengguna (Mahasiswa)**
- 📍 **Pengambilan Koordinat Lokasi (Latitude & Longitude)**
- 🏫 **Validasi Lokasi Absensi (Radius Area)**
- 📸 **Pengambilan Foto Mahasiswa Saat Absensi**
- 🕒 **Pencatatan Waktu Absensi**
- 📄 **Riwayat Kehadiran Mahasiswa**
- ⚠️ **Notifikasi Absensi Ditolak jika Tidak Valid**
---
## 🗺️ Mekanisme Absensi
### 1. Absensi Status "HADIR"
1. Mahasiswa memilih Jadwal Kelas yang aktif.
2. Sistem mengecek **GPS** di sisi client:
- Apakah di dalam radius **500m** dari kampus?
- Apakah terdeteksi aplikasi **Fake GPS / Mock Location**?
3. App meminta **Location Token** ke server `/api/absensi/request-location-token`:
- Server memvalidasi ulang koordinat (server-side GPS validation).
- Server mendeteksi anomali koordinat (presisi rendah, koordinat copy-paste, luar Indonesia).
- Server menerbitkan **Location Token** sekali pakai, valid **2 menit**.
4. Sistem membuka **Kamera Deteksi Wajah**:
- Tombol shutter hanya aktif jika wajah terdeteksi.
5. Data (Location Token + Foto Wajah) dikirim ke server `/api/absensi/submit`:
- Server memvalidasi Location Token (keaslian, kepemilikan, masa berlaku, one-time use).
- Server memvalidasi foto (format, ukuran minimum).
- Server memvalidasi kepemilikan jadwal & jam kelas aktif.
### 2. Absensi Status "SAKIT / IZIN"
1. Mahasiswa memilih status Sakit atau Izin.
2. Validasi lokasi dilewati (bisa absen dari rumah/RS).
3. Sistem membuka **Kamera Dokumen** (Tanpa deteksi wajah).
4. Mahasiswa memfoto surat keterangan.
5. Data dikirim ke server.
### 3. Logika Auto-Alfa (Server)
- Setiap kali jadwal dimuat, server mengecek jadwal yang `jam_selesai < jam_sekarang`.
- Jika mahasiswa belum ada record absensi pada jam tersebut, server otomatis menginput status **"TIDAK HADIR"**.
## 🗺️ Mekanisme Absensi Berbasis Lokasi dan Foto
1. Mahasiswa melakukan **login**
2. Memilih menu **Absensi**
3. Sistem meminta:
- Izin **akses lokasi**
- Izin **akses kamera**
4. Aplikasi mengambil:
- 📍 **Koordinat lokasi mahasiswa**
- 📸 **Foto mahasiswa secara real-time**
5. Sistem melakukan validasi:
- Lokasi berada dalam **radius absensi**
- Foto berhasil diambil
6. Jika valid → **Absensi berhasil**
7. Jika tidak valid → **Absensi ditolak**
---
## 🔐 Security Architecture
Sistem mengimplementasikan **8 lapis keamanan** untuk mencegah kecurangan absensi:
| # | Fix | Deskripsi |
|---|---|---|
| 1 | **Server-side GPS Validation** | Koordinat divalidasi ulang di server, tidak hanya di client Android |
| 2 | **Server-side Foto Validation** | Foto divalidasi format (JPG/PNG/WEBP) & ukuran minimum di server |
| 3 | **JWT Device Binding** | Token JWT terikat ke `device_id` unik HP — tidak bisa dibagikan |
| 4 | **Rate Limiting** | Maks 5x login/menit, maks 3x submit absensi/menit per user |
| 5 | **Jadwal Ownership Validation** | Mahasiswa hanya bisa absen di jadwal jurusan & semesternya sendiri |
| 6 | **Jam Kelas Aktif Validation** | Absensi hanya diterima selama jam kelas berlangsung |
| 7 | **Location Token** | Token sekali pakai valid 2 menit — cegah bypass koordinat via Postman/curl |
| 8 | **Anomali Koordinat Detection** | Koordinat 0,0 / terlalu bulat / identik titik kampus / luar Indonesia ditolak |
## 📸 Pengambilan Foto Saat Absensi
- Foto diambil menggunakan **kamera depan (selfie)**
- Foto hanya dapat diambil **saat proses absensi**
- Foto disimpan sebagai **bukti kehadiran**
- Foto dapat digunakan untuk:
- Verifikasi manual oleh dosen
- Dokumentasi akademik
---
## 🛠️ Teknologi yang Digunakan
- **Mobile Platform**: Android (Kotlin)
- **UI Framework**: Jetpack Compose + Material3
- **Camera Engine**: CameraX + ML Kit (Face Analysis)
- **Backend Framework**: Python Flask
- **Database**: MySQL
- **Security**: JWT + Device Binding + Location Token + Rate Limiting
- **Integrasi**: N8N Webhook
- **Protocol**: HTTP/REST (JSON)
- **AI**: Gemini & Claude (Sebagai Tools dalam Membantu Pengembangan)
---
## 📡 API Endpoints
| Method | Endpoint | Auth | Fungsi |
|---|---|---|---|
| GET | `/api/health` | ✗ | Cek server aktif |
| POST | `/api/auth/register` | ✗ | Daftar mahasiswa + device binding |
| POST | `/api/auth/login` | ✗ | Login + verifikasi device |
| GET | `/api/mahasiswa/profile` | ✓ | Profil mahasiswa |
| GET | `/api/jadwal/today` | ✓ | Jadwal hari ini + trigger auto-alfa |
| POST | `/api/absensi/request-location-token` | ✓ | Minta location token (wajib sebelum submit) |
| POST | `/api/absensi/submit` | ✓ | Kirim absensi + location token |
| GET | `/api/absensi/history` | ✓ | Riwayat absensi |
| GET | `/api/absensi/photo/<id>` | ✓ | Ambil foto absensi |
| POST | `/api/admin/reset-device` | Admin Key | Reset device binding mahasiswa |
> Semua endpoint `✓` butuh header: `Authorization: Bearer <token>`
- **Platform** : Android
- **Bahasa Pemrograman** : Kotlin / Java
- **Location Service** :
- Google Maps API
- Fused Location Provider
- **Camera API** : CameraX / Camera2
- **Database** : Firebase / SQLite / MySQL
- **Storage** : Firebase Storage / Local Storage
- **IDE** : Android Studio
---
## 🔐 Izin Aplikasi (Permissions)
Aplikasi memerlukan izin berikut:
- `INTERNET`: Koneksi ke Server API.
- `ACCESS_FINE_LOCATION`: Validasi koordinat presisi tinggi.
- `ACCESS_COARSE_LOCATION`: Validasi koordinat jaringan.
- `CAMERA`: Pengambilan foto bukti kehadiran & deteksi wajah.
- `ACCESS_FINE_LOCATION`
- `ACCESS_COARSE_LOCATION`
- `CAMERA`
- `INTERNET`
- `WRITE_EXTERNAL_STORAGE` (jika diperlukan)
---
## 📂 Mockup
![mockup](Mockup.png)
---
## Catatan:
- Kembangkan project dari starter yang sudah disediakan, tidak membuat dari awal.
- Untuk koordinat bisa ditambah/kurangi angka tertentu agar tidak memunculkan koordinat rumah masing-masing, data awal tetap dari GPS.
## ⚙️ Konfigurasi
## Pengecekan:
- https://ntfy.ubharajaya.ac.id/EAS
- https://docs.google.com/spreadsheets/d/1jH15MfnNgpPGuGeid0hYfY7fFUHCEFbCmg8afTyyLZs/edit?gid=0#gid=0
### Backend
1. Install dependensi:
```bash
pip install flask flask-cors mysql-connector-python PyJWT bcrypt requests
```
2. Sesuaikan `DB_CONFIG` di `app_secured_v2.py`:
```python
DB_CONFIG = {
'host': 'localhost',
'user': 'root',
'password': '', # sesuaikan
'database': 'db_absensi_akademik'
}
```
3. Jalankan server:
```bash
python app_secured_v2.py
```
### Android
Sesuaikan `BASE_URL` pada `MainActivity.kt` (Object `AppConstants`) dengan IP laptop:
```kotlin
const val BASE_URL = "http://192.168.x.x:5000"
```
> ⚠️ HP Android & laptop harus terhubung ke **WiFi yang sama**.
### Fake GPS Testing
Pastikan mematikan aplikasi Mock Location saat pengujian fitur Hadir. Koordinat yang berasal dari Mock Location akan ditolak baik di sisi client maupun server.
## Webhook:
- test: https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254
- production: https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254

View File

@ -1,7 +1,3 @@
import org.gradle.kotlin.dsl.androidTestImplementation
import org.gradle.kotlin.dsl.debugImplementation
import org.gradle.kotlin.dsl.testImplementation
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@ -72,13 +68,6 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
testImplementation("junit:junit:4.13.2")
testImplementation("org.mockito:mockito-core:5.4.0")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
// CameraX (Pastikan Anda sudah punya ini)
implementation("androidx.camera:camera-core:1.3.0")
implementation("androidx.camera:camera-camera2:1.3.0")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

View File

@ -1,299 +0,0 @@
package id.ac.ubharajaya.sistemakademik
import android.util.Base64
import kotlin.math.*
// ================================================================
// AbsensiUtils.kt
// Letakkan file ini di:
// app/src/main/java/com/ubhara/absensi/AbsensiUtils.kt
//
// Berisi semua fungsi murni (pure functions) yang bisa diuji
// tanpa memerlukan Android Context / Network.
// ================================================================
object AbsensiUtils {
// ==================== KONSTANTA ====================
const val KAMPUS_LATITUDE = -6.223325
const val KAMPUS_LONGITUDE = 107.009406
const val RADIUS_METER = 500.0
const val LOCATION_TOKEN_TTL_MS = 120_000L // 2 menit dalam millisecond
const val MAX_LOGIN_ATTEMPTS = 5
const val RATE_LIMIT_WINDOW_MS = 60_000L // 1 menit
// ==================== GPS — KALKULASI JARAK ====================
/**
* Hitung jarak dua titik koordinat GPS menggunakan Haversine formula.
* Mengembalikan jarak dalam satuan meter.
*/
fun hitungJarakMeter(
lat1: Double, lon1: Double,
lat2: Double, lon2: Double
): Double {
val R = 6371000.0
val phi1 = Math.toRadians(lat1)
val phi2 = Math.toRadians(lat2)
val dPhi = Math.toRadians(lat2 - lat1)
val dLambda = Math.toRadians(lon2 - lon1)
val a = sin(dPhi / 2).pow(2) +
cos(phi1) * cos(phi2) * sin(dLambda / 2).pow(2)
return R * 2 * atan2(sqrt(a), sqrt(1 - a))
}
/**
* Cek apakah koordinat berada dalam radius kampus.
*/
fun dalamRadiusKampus(lat: Double, lon: Double): Boolean {
return hitungJarakMeter(lat, lon, KAMPUS_LATITUDE, KAMPUS_LONGITUDE) <= RADIUS_METER
}
// ==================== GPS — DETEKSI ANOMALI (FIX 8) ====================
/**
* Deteksi koordinat yang mencurigakan / kemungkinan dimanipulasi.
* Return: Pair(isMencurigakan, alasan)
*/
fun deteksiAnomaliKoordinat(lat: Double, lon: Double): Pair<Boolean, String> {
// 1. Koordinat null island (GPS tidak aktif)
if (lat == 0.0 && lon == 0.0)
return Pair(true, "GPS tidak aktif atau koordinat tidak valid (0,0)")
// 2. Koordinat persis sama dengan konstanta titik kampus
if (lat == KAMPUS_LATITUDE && lon == KAMPUS_LONGITUDE)
return Pair(true, "Koordinat identik dengan titik kampus, kemungkinan diinput manual")
// 3. Presisi desimal terlalu sedikit (GPS asli selalu ≥4 desimal)
val latDesimal = lat.toString().substringAfter(".").trimEnd('0').length
val lonDesimal = lon.toString().substringAfter(".").trimEnd('0').length
if (latDesimal < 4 || lonDesimal < 4)
return Pair(true, "Presisi koordinat terlalu rendah ($latDesimal/$lonDesimal desimal), bukan dari GPS asli")
// 4. Di luar batas geografis Indonesia
if (lat < -11.0 || lat > 6.0 || lon < 95.0 || lon > 141.0)
return Pair(true, "Koordinat di luar wilayah Indonesia")
return Pair(false, "OK")
}
// ==================== VALIDASI FOTO (FIX 2) ====================
/**
* Validasi string base64 foto:
* - Tidak boleh kosong
* - Harus valid base64
* - Harus file gambar (JPG/PNG/WEBP berdasarkan magic bytes)
* - Ukuran minimum 5KB
*
* Catatan: Fungsi ini menggunakan implementasi Base64 murni tanpa
* android.util.Base64 agar bisa ditest di JVM (Local Unit Test).
* Return: Pair(isValid, pesan)
*/
// ==================== VALIDASI FOTO (FIX 2) ====================
fun validasiFoto(fotoBase64: String?): Pair<Boolean, String> {
if (fotoBase64.isNullOrBlank())
return Pair(false, "Foto wajib disertakan")
// Hapus prefix data URL jika ada (misal: "data:image/jpeg;base64,")
val raw = if (fotoBase64.contains(",")) fotoBase64.substringAfter(",") else fotoBase64
val decoded: ByteArray = try {
java.util.Base64.getDecoder().decode(raw)
} catch (e: Exception) {
return Pair(false, "Format foto tidak valid")
}
// Magic bytes check
val isJpg = decoded.size >= 3 && decoded[0] == 0xFF.toByte() && decoded[1] == 0xD8.toByte() && decoded[2] == 0xFF.toByte()
// PERBAIKAN DI SINI: Tambahkan .toByte() pada setiap elemen di dalam byteArrayOf
val isPng = decoded.size >= 8 && decoded.sliceArray(0..7).contentEquals(
byteArrayOf(
0x89.toByte(), 0x50.toByte(), 0x4E.toByte(), 0x47.toByte(),
0x0D.toByte(), 0x0A.toByte(), 0x1A.toByte(), 0x0A.toByte()
)
)
val isWebp = decoded.size >= 12 && String(decoded.sliceArray(8..11)) == "WEBP"
if (!isJpg && !isPng && !isWebp)
return Pair(false, "File bukan gambar yang valid (harus JPG/PNG/WEBP)")
// Minimum 5KB
if (decoded.size < 5 * 1024)
return Pair(false, "Ukuran foto terlalu kecil, pastikan foto wajah terambil dengan benar")
return Pair(true, "OK")
}
// ==================== VALIDASI FORM ====================
/**
* Validasi NPM mahasiswa.
* Return: Pair(isValid, pesanError)
*/
fun validasiNpm(npm: String): Pair<Boolean, String> {
if (npm.isBlank())
return Pair(false, "NPM tidak boleh kosong")
if (!npm.all { it.isDigit() })
return Pair(false, "NPM harus berupa angka")
if (npm.length < 9 || npm.length > 15)
return Pair(false, "Panjang NPM tidak valid (9-15 digit)")
return Pair(true, "OK")
}
fun validasiPassword(password: String): Pair<Boolean, String> {
if (password.length < 6)
return Pair(false, "Password minimal 6 karakter")
return Pair(true, "OK")
}
fun validasiNama(nama: String): Pair<Boolean, String> {
if (nama.isBlank())
return Pair(false, "Nama tidak boleh kosong")
if (nama.trim().length < 3)
return Pair(false, "Nama terlalu pendek (minimal 3 karakter)")
return Pair(true, "OK")
}
fun validasiSemester(semester: Int): Pair<Boolean, String> {
if (semester < 1 || semester > 14)
return Pair(false, "Semester tidak valid (1-14)")
return Pair(true, "OK")
}
fun validasiDeviceId(deviceId: String): Pair<Boolean, String> {
if (deviceId.isBlank())
return Pair(false, "Field device_id wajib diisi")
return Pair(true, "OK")
}
/**
* Validasi semua field registrasi sekaligus.
* Return: Pair(isValid, pesanError) mengembalikan error pertama yang ditemukan.
*/
fun validasiFormRegistrasi(
npm: String, password: String, nama: String,
semester: Int, deviceId: String
): Pair<Boolean, String> {
validasiNpm(npm).let { if (!it.first) return it }
validasiPassword(password).let { if (!it.first) return it }
validasiNama(nama).let { if (!it.first) return it }
validasiSemester(semester).let { if (!it.first) return it }
validasiDeviceId(deviceId).let { if (!it.first) return it }
return Pair(true, "OK")
}
// ==================== SIMULASI DEVICE BINDING (FIX 3) ====================
/**
* Simulasi pengecekan device binding di sisi client.
* Di production, validasi ini dilakukan oleh server.
* deviceIdTerdaftar = device_id yang tersimpan di SharedPreferences / server
* deviceIdSekarang = device_id HP yang sedang login
*/
fun cekDeviceBinding(
deviceIdTerdaftar: String?,
deviceIdSekarang: String
): Pair<Boolean, String> {
if (deviceIdTerdaftar.isNullOrBlank())
return Pair(true, "Belum ada device terdaftar, login pertama kali")
if (deviceIdTerdaftar == deviceIdSekarang)
return Pair(true, "Device sama, login diizinkan")
return Pair(false, "Akun ini sudah terdaftar di perangkat lain. Hubungi admin untuk reset.")
}
// ==================== SIMULASI LOCATION TOKEN (FIX 7) ====================
data class LocationToken(
val token: String,
val idMahasiswa: Int,
val deviceId: String,
val lat: Double,
val lon: Double,
val jarakMeter: Double,
val createdAtMs: Long // System.currentTimeMillis() saat token dibuat
)
/**
* Validasi apakah location token masih berlaku.
* Return: Pair(isValid, pesanError)
*/
fun validasiLocationToken(
token: LocationToken?,
idMahasiswaLogin: Int,
deviceIdLogin: String,
nowMs: Long = System.currentTimeMillis()
): Pair<Boolean, String> {
if (token == null)
return Pair(false, "Location token tidak valid atau sudah digunakan")
if (nowMs - token.createdAtMs > LOCATION_TOKEN_TTL_MS)
return Pair(false, "Location token sudah kadaluarsa (maks 120 detik). Silakan ulangi proses absensi.")
if (token.idMahasiswa != idMahasiswaLogin)
return Pair(false, "Location token bukan milik Anda")
if (token.deviceId != deviceIdLogin)
return Pair(false, "Location token digunakan dari perangkat berbeda")
return Pair(true, "OK")
}
// ==================== SIMULASI RATE LIMITING (FIX 4) ====================
/**
* Cek apakah sudah melebihi batas percobaan dalam window waktu tertentu.
* timestamps = daftar waktu percobaan sebelumnya (dalam ms)
* nowMs = waktu sekarang (ms)
*/
fun cekRateLimit(
timestamps: List<Long>,
maxPercobaan: Int = MAX_LOGIN_ATTEMPTS,
windowMs: Long = RATE_LIMIT_WINDOW_MS,
nowMs: Long = System.currentTimeMillis()
): Boolean {
val dalamWindow = timestamps.count { nowMs - it < windowMs }
return dalamWindow >= maxPercobaan
}
// ==================== VALIDASI JADWAL (FIX 5) ====================
/**
* Cek apakah jadwal yang dipilih sesuai dengan jurusan & semester mahasiswa.
*/
fun cekKepemilikanJadwal(
jurusanMahasiswa: String,
semesterMahasiswa: Int,
jurusanJadwal: String,
semesterJadwal: Int
): Pair<Boolean, String> {
if (jurusanMahasiswa.trim().lowercase() != jurusanJadwal.trim().lowercase())
return Pair(false, "Jadwal tidak valid atau bukan milik kelas Anda")
if (semesterMahasiswa != semesterJadwal)
return Pair(false, "Jadwal tidak valid atau bukan milik kelas Anda")
return Pair(true, "OK")
}
// ==================== VALIDASI JAM KELAS (FIX 6) ====================
/**
* Cek apakah waktu sekarang berada dalam rentang jam kelas.
* jamMulai / jamSelesai dalam format "HH:MM"
* jamSekarang dalam format "HH:MM"
*/
fun cekJamKelasAktif(
jamMulai: String,
jamSelesai: String,
jamSekarang: String
): Pair<Boolean, String> {
fun parse(s: String): Int {
val parts = s.split(":")
return parts[0].toInt() * 60 + parts[1].toInt()
}
val mulai = parse(jamMulai)
val selesai = parse(jamSelesai)
val sekarang = parse(jamSekarang)
if (sekarang < mulai || sekarang > selesai)
return Pair(false, "Absensi hanya bisa dilakukan saat jam kelas aktif ($jamMulai - $jamSelesai)")
return Pair(true, "OK")
}
}

View File

@ -88,14 +88,14 @@ import androidx.compose.ui.unit.sp
object AppConstants {
// Backend API URL - GANTI SESUAI SERVER ANDA
// const val BASE_URL = "http://10.0.2.2:5000" // Untuk emulator Android
const val BASE_URL = "http://192.168.000.000:5000" // Untuk device fisik
const val BASE_URL = "http://192.168.100.99:5000" // Untuk device fisik
// // Koordinat Kampus (UBHARA Jaya)
const val KAMPUS_LATITUDE = -6.223325
const val KAMPUS_LONGITUDE = 107.009406
// Koordinat Device Saat ini (Untuk Testing)
// const val KAMPUS_LATITUDE = -6.2394664
// const val KAMPUS_LONGITUDE = 107.0898995
// Koordinat Kampus (UBHARA Jaya)
// const val KAMPUS_LATITUDE = -6.223325
// const val KAMPUS_LONGITUDE = 107.009406
// Koordinat Saat ini
const val KAMPUS_LATITUDE = -6.239513
const val KAMPUS_LONGITUDE = 107.089676
const val RADIUS_METER = 500.0
@ -113,7 +113,6 @@ object AppConstants {
const val KEY_FAKULTAS = "fakultas"
const val KEY_JURUSAN = "jurusan"
const val KEY_SEMESTER = "semester"
const val KEY_DEVICE_ID = "device_id"
}
/* ================= DATA CLASSES ================= */
@ -206,13 +205,6 @@ class UserPreferences(private val context: Context) {
}
}
fun getDeviceId(context: Context): String {
return android.provider.Settings.Secure.getString(
context.contentResolver,
android.provider.Settings.Secure.ANDROID_ID
) ?: "unknown-device"
}
/* ================= UTIL FUNCTIONS ================= */
fun bitmapToBase64(bitmap: Bitmap): String {
@ -273,7 +265,6 @@ fun getCurrentTimestamp(): String {
fun registerMahasiswa(
npm: String, password: String, nama: String, jenkel: String,
fakultas: String, jurusan: String, semester: Int,
deviceId: String, // ← BARU
onSuccess: (String, Mahasiswa) -> Unit, onError: (String) -> Unit
) {
thread {
@ -287,14 +278,8 @@ fun registerMahasiswa(
conn.readTimeout = 15000
val json = JSONObject().apply {
put("npm", npm)
put("password", password)
put("nama", nama)
put("jenkel", jenkel)
put("fakultas", fakultas)
put("jurusan", jurusan)
put("semester", semester)
put("device_id", deviceId) // ← BARU
put("npm", npm); put("password", password); put("nama", nama)
put("jenkel", jenkel); put("fakultas", fakultas); put("jurusan", jurusan); put("semester", semester)
}
conn.outputStream.use { it.write(json.toString().toByteArray()) }
@ -302,15 +287,13 @@ fun registerMahasiswa(
val responseCode = conn.responseCode
val response = if (responseCode == 201) conn.inputStream.bufferedReader().readText()
else conn.errorStream?.bufferedReader()?.readText() ?: ""
conn.disconnect()
if (responseCode == 201) {
val data = JSONObject(response).getJSONObject("data")
val token = data.getString("token")
val mahasiswa = Mahasiswa(
data.getInt("id_mahasiswa"), data.getString("npm"),
data.getString("nama"), jenkel, fakultas, jurusan, semester
)
val mahasiswa = Mahasiswa(data.getInt("id_mahasiswa"), data.getString("npm"), data.getString("nama"), jenkel, fakultas, jurusan, semester)
onSuccess(token, mahasiswa)
} else {
onError(ErrorHandler.parseHttpError(responseCode, response))
@ -321,7 +304,6 @@ fun registerMahasiswa(
fun loginMahasiswa(
npm: String, password: String,
deviceId: String, // ← BARU
onSuccess: (String, Mahasiswa) -> Unit, onError: (String) -> Unit
) {
thread {
@ -334,26 +316,18 @@ fun loginMahasiswa(
conn.connectTimeout = 15000
conn.readTimeout = 15000
val json = JSONObject().apply {
put("npm", npm)
put("password", password)
put("device_id", deviceId) // ← BARU
}
val json = JSONObject().apply { put("npm", npm); put("password", password) }
conn.outputStream.use { it.write(json.toString().toByteArray()) }
val responseCode = conn.responseCode
val response = if (responseCode == 200) conn.inputStream.bufferedReader().readText()
else conn.errorStream?.bufferedReader()?.readText() ?: ""
conn.disconnect()
if (responseCode == 200) {
val data = JSONObject(response).getJSONObject("data")
val mahasiswa = Mahasiswa(
data.getInt("id_mahasiswa"), data.getString("npm"),
data.getString("nama"), data.getString("jenkel"),
data.getString("fakultas"), data.getString("jurusan"),
data.getInt("semester")
)
val mahasiswa = Mahasiswa(data.getInt("id_mahasiswa"), data.getString("npm"), data.getString("nama"), data.getString("jenkel"), data.getString("fakultas"), data.getString("jurusan"), data.getInt("semester"))
onSuccess(data.getString("token"), mahasiswa)
} else {
onError(ErrorHandler.parseHttpError(responseCode, response))
@ -362,45 +336,6 @@ fun loginMahasiswa(
}
}
fun requestLocationToken(
token: String,
latitude: Double,
longitude: Double,
onSuccess: (String) -> Unit, // mengembalikan location_token
onError: (String) -> Unit
) {
thread {
try {
val url = URL("${AppConstants.BASE_URL}/api/absensi/request-location-token")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Authorization", "Bearer $token")
conn.doOutput = true
conn.connectTimeout = 15000
conn.readTimeout = 15000
val json = JSONObject().apply {
put("latitude", latitude)
put("longitude", longitude)
}
conn.outputStream.use { it.write(json.toString().toByteArray()) }
val responseCode = conn.responseCode
val response = if (responseCode == 200) conn.inputStream.bufferedReader().readText()
else conn.errorStream?.bufferedReader()?.readText() ?: ""
conn.disconnect()
if (responseCode == 200) {
val locationToken = JSONObject(response).getString("location_token")
onSuccess(locationToken)
} else {
onError(ErrorHandler.parseHttpError(responseCode, response))
}
} catch (e: Exception) { onError(ErrorHandler.parseException(e)) }
}
}
fun getJadwalToday(
token: String, onSuccess: (List<JadwalKelas>) -> Unit, onError: (String) -> Unit
) {
@ -471,22 +406,9 @@ fun getJadwalToday(
}
fun submitAbsensiWithJadwal(
token: String,
idJadwal: Int,
latitude: Double,
longitude: Double,
fotoBase64: String,
status: String,
onSuccess: (String) -> Unit,
onError: (String) -> Unit
token: String, idJadwal: Int, latitude: Double, longitude: Double,
fotoBase64: String, status: String, onSuccess: (String) -> Unit, onError: (String) -> Unit
) {
// STEP 1: Minta location token ke server dengan koordinat GPS saat ini
requestLocationToken(
token = token,
latitude = latitude,
longitude = longitude,
onSuccess = { locationToken ->
// STEP 2: Jika berhasil dapat location token, baru submit absensi
thread {
try {
val url = URL("${AppConstants.BASE_URL}/api/absensi/submit")
@ -495,17 +417,12 @@ fun submitAbsensiWithJadwal(
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Authorization", "Bearer $token")
conn.doOutput = true
conn.connectTimeout = 30000
conn.connectTimeout = 30000 // Timeout lebih lama untuk upload foto
conn.readTimeout = 30000
val json = JSONObject().apply {
put("id_jadwal", idJadwal)
put("location_token", locationToken) // ← BARU: wajib ada
put("foto_base64", fotoBase64)
put("status", status)
put("timestamp", getCurrentTimestamp())
// latitude & longitude tidak perlu dikirim lagi,
// backend ambil dari location_token
put("id_jadwal", idJadwal); put("latitude", latitude); put("longitude", longitude)
put("timestamp", getCurrentTimestamp()); put("foto_base64", fotoBase64); put("status", status)
}
conn.outputStream.use { it.write(json.toString().toByteArray()) }
@ -513,6 +430,7 @@ fun submitAbsensiWithJadwal(
val responseCode = conn.responseCode
val response = if (responseCode == 201) conn.inputStream.bufferedReader().readText()
else conn.errorStream?.bufferedReader()?.readText() ?: ""
conn.disconnect()
if (responseCode == 201) {
@ -522,12 +440,6 @@ fun submitAbsensiWithJadwal(
}
} catch (e: Exception) { onError(ErrorHandler.parseException(e)) }
}
},
onError = { err ->
// Jika gagal dapat location token (lokasi tidak valid, dll)
onError(err)
}
)
}
fun getAbsensiHistory(
@ -841,7 +753,7 @@ fun JadwalScreen(
color = androidx.compose.ui.graphics.Color.Black
)
Text(
text = "Semester Genap 2026/2027", // Bisa dibuat dinamis nanti
text = "Semester Ganjil 2025/2026", // Bisa dibuat dinamis nanti
style = MaterialTheme.typography.bodySmall,
color = androidx.compose.ui.graphics.Color.Gray
)
@ -1130,13 +1042,7 @@ fun RegisterScreen(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedLabelColor = GoldPrimary,
unfocusedLabelColor = Color.Gray
)
colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
)
Spacer(modifier = Modifier.height(8.dp))
@ -1153,13 +1059,7 @@ fun RegisterScreen(
},
modifier = Modifier.fillMaxWidth(), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedLabelColor = GoldPrimary,
unfocusedLabelColor = Color.Gray
)
colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
)
Spacer(modifier = Modifier.height(8.dp))
@ -1176,13 +1076,7 @@ fun RegisterScreen(
},
modifier = Modifier.fillMaxWidth(), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedLabelColor = GoldPrimary,
unfocusedLabelColor = Color.Gray
)
colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
)
Spacer(modifier = Modifier.height(16.dp))
@ -1200,13 +1094,7 @@ fun RegisterScreen(
leadingIcon = { Icon(Icons.Default.Person, null, tint = GoldPrimary) },
modifier = Modifier.fillMaxWidth(), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedLabelColor = GoldPrimary,
unfocusedLabelColor = Color.Gray
)
colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
)
Spacer(modifier = Modifier.height(12.dp))
@ -1246,13 +1134,7 @@ fun RegisterScreen(
leadingIcon = { Icon(Icons.Default.School, null, tint = GoldPrimary) },
modifier = Modifier.fillMaxWidth(), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedLabelColor = GoldPrimary,
unfocusedLabelColor = Color.Gray
)
colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
)
Spacer(modifier = Modifier.height(8.dp))
@ -1264,13 +1146,7 @@ fun RegisterScreen(
leadingIcon = { Icon(Icons.Default.Book, null, tint = GoldPrimary) },
modifier = Modifier.weight(1.5f), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedLabelColor = GoldPrimary,
unfocusedLabelColor = Color.Gray
)
colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
)
OutlinedTextField(
value = semester,
@ -1280,13 +1156,7 @@ fun RegisterScreen(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedLabelColor = GoldPrimary,
unfocusedLabelColor = Color.Gray
)
colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
)
}
@ -1302,12 +1172,10 @@ fun RegisterScreen(
errorMessage = "Konfirmasi password tidak cocok"
} else {
isLoading = true
val deviceId = getDeviceId(context)
registerMahasiswa(
npm = npm.trim(), password = password, nama = nama.trim(),
jenkel = jenkel, fakultas = fakultas.trim(), jurusan = jurusan.trim(),
semester = semester.toIntOrNull() ?: 1,
deviceId = deviceId,
onSuccess = { token, mhs ->
(context as? ComponentActivity)?.runOnUiThread {
isLoading = false
@ -1539,11 +1407,9 @@ fun LoginScreen(
}
isLoading = true
val deviceId = getDeviceId(context)
loginMahasiswa(
npm = npm.trim(),
password = password,
deviceId = deviceId,
onSuccess = { token, mhs ->
(context as? ComponentActivity)?.runOnUiThread {
isLoading = false

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@ -1,572 +0,0 @@
package id.ac.ubharajaya.sistemakademik
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
// ================================================================
// AbsensiUtilsTest.kt
// Letakkan file ini di:
// app/src/test/java/com/ubhara/absensi/AbsensiUtilsTest.kt
//
// 22 Unit Test sesuai dokumen Unit Testing Aplikasi Absensi
// UBHARA Jaya — 10 Mei 2026
// ================================================================
@RunWith(JUnit4::class)
class AbsensiUtilsTest {
// ============================================================
// DATA KONSTANTA TEST
// ============================================================
private val NPM_VALID = "202310715297"
private val PASSWORD_VALID = "Password123"
private val NAMA_VALID = "Ariq Dwi Saputra"
private val DEVICE_TERDAFTAR = "abc123device"
private val DEVICE_LAIN = "device-orang-lain-999"
// Koordinat dalam radius kampus (~66m dari titik kampus)
private val LAT_DALAM = -6.223401
private val LON_DALAM = 107.009512
// Koordinat di luar radius kampus (~1.9km dari kampus)
private val LAT_LUAR = -6.235000
private val LON_LUAR = 107.021000
// Base64 gambar JPG valid berukuran >5KB
// (representasi minimal JPG valid 1x1 pixel merah berukuran ~630 byte untuk test format,
// gunakan foto nyata >5KB untuk test ukuran)
private val FOTO_JPG_VALID_HEADER = "/9j/4AAQ" // prefix JPG base64
private val FOTO_BUKAN_GAMBAR = "aGVsbG8gd29ybGQ=" // "hello world" di base64
private val FOTO_PNG_HEADER = "iVBORw0KGgo=" // prefix PNG base64
// ============================================================
// MODUL AUTENTIKASI — REGISTRASI
// ============================================================
/**
* UT-001
* Validasi form registrasi dengan data lengkap dan valid
* Tipe: Black-Box Testing
*/
@Test
fun ut001_registrasi_data_lengkap_valid_harus_lulus() {
val (valid, pesan) = AbsensiUtils.validasiFormRegistrasi(
npm = NPM_VALID,
password = PASSWORD_VALID,
nama = NAMA_VALID,
semester = 6,
deviceId = DEVICE_TERDAFTAR
)
assertTrue("UT-001 GAGAL — Registrasi valid seharusnya diterima: $pesan", valid)
assertEquals("OK", pesan)
}
/**
* UT-002
* Registrasi dengan NPM yang sudah terdaftar (duplikat)
* Di unit test, disimulasikan dengan cek format NPM yang sama
* Tipe: Black-Box Testing
*/
@Test
fun ut002_registrasi_npm_duplikat_harus_ditolak() {
// Simulasi: daftar NPM yang sudah ada di "database lokal"
val npmSudahTerdaftar = setOf("202310715297", "202310715001")
val npmBaru = "202310715297" // sama dengan yang sudah ada
val sudahAda = npmSudahTerdaftar.contains(npmBaru)
assertTrue("UT-002 GAGAL — NPM duplikat seharusnya terdeteksi", sudahAda)
}
/**
* UT-003
* Registrasi dengan field wajib tidak diisi (device_id kosong)
* Tipe: Black-Box Testing
*/
@Test
fun ut003_registrasi_device_id_kosong_harus_ditolak() {
val (valid, pesan) = AbsensiUtils.validasiFormRegistrasi(
npm = NPM_VALID,
password = PASSWORD_VALID,
nama = NAMA_VALID,
semester = 6,
deviceId = "" // ← kosong
)
assertFalse("UT-003 GAGAL — device_id kosong seharusnya ditolak", valid)
assertTrue("UT-003 GAGAL — Pesan error harus menyebut device_id: $pesan",
pesan.contains("device_id", ignoreCase = true))
}
// ============================================================
// MODUL AUTENTIKASI — LOGIN
// ============================================================
/**
* UT-004
* Validasi data login dengan NPM dan password valid
* Tipe: Black-Box Testing
*/
@Test
fun ut004_login_npm_dan_password_valid_harus_diterima() {
val (npmValid, _) = AbsensiUtils.validasiNpm(NPM_VALID)
val (passValid, _) = AbsensiUtils.validasiPassword(PASSWORD_VALID)
val (devValid, _) = AbsensiUtils.validasiDeviceId(DEVICE_TERDAFTAR)
assertTrue("UT-004 GAGAL — NPM valid seharusnya diterima", npmValid)
assertTrue("UT-004 GAGAL — Password valid seharusnya diterima", passValid)
assertTrue("UT-004 GAGAL — Device ID valid seharusnya diterima", devValid)
}
/**
* UT-005
* Login dengan password terlalu pendek (simulasi validasi sebelum kirim ke server)
* Tipe: Black-Box Testing
*/
@Test
fun ut005_login_password_terlalu_pendek_harus_ditolak() {
val (valid, pesan) = AbsensiUtils.validasiPassword("abc")
assertFalse("UT-005 GAGAL — Password pendek seharusnya ditolak", valid)
assertTrue("UT-005 GAGAL — Pesan harus menyebut '6': $pesan",
pesan.contains("6"))
}
/**
* UT-006
* Login dari perangkat berbeda setelah device sudah terdaftar (Device Binding FIX 3)
* Tipe: Black-Box Testing
*/
@Test
fun ut006_login_device_berbeda_harus_ditolak() {
val (diizinkan, pesan) = AbsensiUtils.cekDeviceBinding(
deviceIdTerdaftar = DEVICE_TERDAFTAR,
deviceIdSekarang = DEVICE_LAIN // berbeda!
)
assertFalse("UT-006 GAGAL — Device berbeda seharusnya ditolak", diizinkan)
assertTrue("UT-006 GAGAL — Pesan harus menyebut 'perangkat lain': $pesan",
pesan.contains("perangkat lain", ignoreCase = true))
}
// ============================================================
// MODUL VALIDASI GPS — KALKULASI JARAK
// ============================================================
/**
* UT-007
* Koordinat dalam radius kampus (~66m) harus diterima
* Tipe: White-Box Testing
*/
@Test
fun ut007_koordinat_dalam_radius_kampus_harus_diterima() {
val jarak = AbsensiUtils.hitungJarakMeter(
LAT_DALAM, LON_DALAM,
AbsensiUtils.KAMPUS_LATITUDE, AbsensiUtils.KAMPUS_LONGITUDE
)
assertTrue(
"UT-007 GAGAL — Jarak $jarak meter seharusnya < ${AbsensiUtils.RADIUS_METER}m",
jarak < AbsensiUtils.RADIUS_METER
)
assertTrue(
"UT-007 GAGAL — dalamRadiusKampus() harus true",
AbsensiUtils.dalamRadiusKampus(LAT_DALAM, LON_DALAM)
)
}
/**
* UT-008
* Koordinat di luar radius kampus (~1.9km) harus ditolak
* Tipe: White-Box Testing
*/
@Test
fun ut008_koordinat_luar_radius_kampus_harus_ditolak() {
val jarak = AbsensiUtils.hitungJarakMeter(
LAT_LUAR, LON_LUAR,
AbsensiUtils.KAMPUS_LATITUDE, AbsensiUtils.KAMPUS_LONGITUDE
)
assertTrue(
"UT-008 GAGAL — Jarak $jarak meter seharusnya > ${AbsensiUtils.RADIUS_METER}m",
jarak > AbsensiUtils.RADIUS_METER
)
assertFalse(
"UT-008 GAGAL — dalamRadiusKampus() harus false",
AbsensiUtils.dalamRadiusKampus(LAT_LUAR, LON_LUAR)
)
}
// ============================================================
// MODUL VALIDASI GPS — DETEKSI ANOMALI (FIX 8)
// ============================================================
/**
* UT-009
* Deteksi koordinat 0,0 GPS tidak aktif / null island
* Tipe: White-Box Testing
*/
@Test
fun ut009_koordinat_nol_nol_harus_terdeteksi_anomali() {
val (anomali, pesan) = AbsensiUtils.deteksiAnomaliKoordinat(0.0, 0.0)
assertTrue("UT-009 GAGAL — Koordinat 0,0 seharusnya terdeteksi anomali", anomali)
assertTrue("UT-009 GAGAL — Pesan harus menyebut '0,0': $pesan",
pesan.contains("0,0"))
}
/**
* UT-010
* Deteksi koordinat identik dengan titik pusat kampus (copy-paste manual)
* Tipe: White-Box Testing
*/
@Test
fun ut010_koordinat_identik_titik_kampus_harus_terdeteksi_anomali() {
val (anomali, pesan) = AbsensiUtils.deteksiAnomaliKoordinat(
AbsensiUtils.KAMPUS_LATITUDE,
AbsensiUtils.KAMPUS_LONGITUDE
)
assertTrue("UT-010 GAGAL — Koordinat identik kampus seharusnya terdeteksi", anomali)
assertTrue("UT-010 GAGAL — Pesan harus menyebut 'identik': $pesan",
pesan.contains("identik", ignoreCase = true))
}
/**
* UT-011
* Deteksi presisi koordinat terlalu rendah (bukan dari GPS hardware)
* GPS asli selalu menghasilkan 4 angka desimal
* Tipe: White-Box Testing
*/
@Test
fun ut011_koordinat_presisi_rendah_harus_terdeteksi_anomali() {
val (anomali, pesan) = AbsensiUtils.deteksiAnomaliKoordinat(-6.22, 107.00)
assertTrue("UT-011 GAGAL — Presisi rendah seharusnya terdeteksi", anomali)
assertTrue("UT-011 GAGAL — Pesan harus menyebut 'presisi' atau 'desimal': $pesan",
pesan.contains("presisi", ignoreCase = true) ||
pesan.contains("desimal", ignoreCase = true))
}
/**
* UT-012
* Deteksi koordinat di luar wilayah geografis Indonesia
* Tipe: White-Box Testing
*/
@Test
fun ut012_koordinat_luar_indonesia_harus_terdeteksi_anomali() {
// Tokyo, Jepang
val (anomali, pesan) = AbsensiUtils.deteksiAnomaliKoordinat(35.676200, 139.650300)
assertTrue("UT-012 GAGAL — Koordinat luar Indonesia seharusnya terdeteksi", anomali)
assertTrue("UT-012 GAGAL — Pesan harus menyebut 'Indonesia': $pesan",
pesan.contains("Indonesia", ignoreCase = true))
}
// ============================================================
// MODUL VALIDASI FOTO (FIX 2)
// ============================================================
/**
* UT-013
* Submit absensi tanpa foto (null / kosong)
* Tipe: Black-Box Testing
*/
@Test
fun ut013_foto_null_harus_ditolak() {
val (valid, pesan) = AbsensiUtils.validasiFoto(null)
assertFalse("UT-013 GAGAL — Foto null seharusnya ditolak", valid)
assertTrue("UT-013 GAGAL — Pesan harus menyebut 'wajib': $pesan",
pesan.contains("wajib", ignoreCase = true))
}
@Test
fun ut013b_foto_string_kosong_harus_ditolak() {
val (valid, pesan) = AbsensiUtils.validasiFoto("")
assertFalse("UT-013b GAGAL — Foto kosong seharusnya ditolak", valid)
assertTrue(pesan.isNotBlank())
}
/**
* UT-014
* Submit dengan base64 bukan file gambar (teks random)
* "hello world" base64 bukan gambar
* Tipe: Black-Box Testing
*/
@Test
fun ut014_foto_bukan_gambar_harus_ditolak() {
// base64 dari string "hello world" — bukan file gambar
val (valid, pesan) = AbsensiUtils.validasiFoto(FOTO_BUKAN_GAMBAR)
assertFalse("UT-014 GAGAL — Bukan gambar seharusnya ditolak", valid)
assertTrue("UT-014 GAGAL — Pesan harus menyebut 'gambar': $pesan",
pesan.contains("gambar", ignoreCase = true) ||
pesan.contains("valid", ignoreCase = true))
}
/**
* UT-015
* Submit dengan gambar valid tapi berukuran < 5KB (gambar 1x1 pixel / dummy)
* Dibuat dari byte array minimal yang valid sebagai JPG
* Tipe: Black-Box Testing
*/
@Test
fun ut015_foto_terlalu_kecil_harus_ditolak() {
// Buat JPG minimal yang valid (magic bytes benar tapi ukuran < 5KB)
val jpgKecil = ByteArray(100).also { buf ->
buf[0] = 0xFF.toByte()
buf[1] = 0xD8.toByte()
buf[2] = 0xFF.toByte()
// Sisanya nol — valid JPG header tapi isi kosong
}
val base64Kecil = java.util.Base64.getEncoder().encodeToString(jpgKecil)
val (valid, pesan) = AbsensiUtils.validasiFoto(base64Kecil)
assertFalse("UT-015 GAGAL — Foto kecil seharusnya ditolak", valid)
assertTrue("UT-015 GAGAL — Pesan harus menyebut 'kecil' atau 'ukuran': $pesan",
pesan.contains("kecil", ignoreCase = true) ||
pesan.contains("ukuran", ignoreCase = true))
}
// ============================================================
// MODUL LOCATION TOKEN (FIX 7)
// ============================================================
/**
* UT-016
* Submit absensi tanpa location_token (null)
* Tipe: Black-Box Testing
*/
@Test
fun ut016_submit_tanpa_location_token_harus_ditolak() {
val (valid, pesan) = AbsensiUtils.validasiLocationToken(
token = null, // tidak ada token
idMahasiswaLogin = 1,
deviceIdLogin = DEVICE_TERDAFTAR
)
assertFalse("UT-016 GAGAL — Tanpa token seharusnya ditolak", valid)
assertTrue("UT-016 GAGAL — Pesan harus menyebut 'tidak valid': $pesan",
pesan.contains("tidak valid", ignoreCase = true) ||
pesan.contains("digunakan", ignoreCase = true))
}
/**
* UT-017
* Submit dengan location_token palsu (tidak ada di server store)
* Disimulasikan dengan token = null karena tidak ditemukan di store
* Tipe: Black-Box Testing
*/
@Test
fun ut017_location_token_palsu_harus_ditolak() {
// Token palsu: tidak ditemukan di store → validasi terima null
val tokenStore: Map<String, AbsensiUtils.LocationToken> = emptyMap()
val tokenPalsu = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
val tokenData = tokenStore[tokenPalsu] // null karena tidak ada
val (valid, pesan) = AbsensiUtils.validasiLocationToken(
token = tokenData,
idMahasiswaLogin = 1,
deviceIdLogin = DEVICE_TERDAFTAR
)
assertFalse("UT-017 GAGAL — Token palsu seharusnya ditolak", valid)
}
/**
* UT-018
* Location token digunakan dua kali (one-time use)
* Setelah dipakai pertama kali, token dihapus dari store
* Tipe: Black-Box Testing
*/
@Test
fun ut018_location_token_digunakan_dua_kali_harus_ditolak() {
val nowMs = System.currentTimeMillis()
val tokenData = AbsensiUtils.LocationToken(
token = "valid-token-abc123",
idMahasiswa = 1,
deviceId = DEVICE_TERDAFTAR,
lat = LAT_DALAM,
lon = LON_DALAM,
jarakMeter = 66.0,
createdAtMs = nowMs
)
// Simulasi store token
val tokenStore = mutableMapOf("valid-token-abc123" to tokenData)
// Pemakaian PERTAMA — harus berhasil
val hasilPertama = AbsensiUtils.validasiLocationToken(
token = tokenStore["valid-token-abc123"],
idMahasiswaLogin = 1,
deviceIdLogin = DEVICE_TERDAFTAR,
nowMs = nowMs
)
assertTrue("UT-018 GAGAL — Pemakaian pertama seharusnya berhasil: ${hasilPertama.second}", hasilPertama.first)
// Hapus token dari store (one-time use)
tokenStore.remove("valid-token-abc123")
// Pemakaian KEDUA — harus gagal
val hasilKedua = AbsensiUtils.validasiLocationToken(
token = tokenStore["valid-token-abc123"], // null setelah dihapus
idMahasiswaLogin = 1,
deviceIdLogin = DEVICE_TERDAFTAR,
nowMs = nowMs
)
assertFalse("UT-018 GAGAL — Pemakaian kedua seharusnya ditolak", hasilKedua.first)
}
/**
* UT-019
* Location token yang sudah kadaluarsa (lebih dari 2 menit)
* Tipe: Black-Box Testing
*/
@Test
fun ut019_location_token_expired_harus_ditolak() {
val nowMs = System.currentTimeMillis()
// Token dibuat 121 detik yang lalu
val tokenExpired = AbsensiUtils.LocationToken(
token = "token-expired",
idMahasiswa = 1,
deviceId = DEVICE_TERDAFTAR,
lat = LAT_DALAM,
lon = LON_DALAM,
jarakMeter = 66.0,
createdAtMs = nowMs - 121_000L // 121 detik lalu
)
val (valid, pesan) = AbsensiUtils.validasiLocationToken(
token = tokenExpired,
idMahasiswaLogin = 1,
deviceIdLogin = DEVICE_TERDAFTAR,
nowMs = nowMs
)
assertFalse("UT-019 GAGAL — Token expired seharusnya ditolak", valid)
assertTrue("UT-019 GAGAL — Pesan harus menyebut 'kadaluarsa': $pesan",
pesan.contains("kadaluarsa", ignoreCase = true))
}
// ============================================================
// MODUL RATE LIMITING (FIX 4)
// ============================================================
/**
* UT-020
* Login gagal 6x dalam 1 menit harus diblokir pada percobaan ke-6
* Tipe: Black-Box Testing
*/
@Test
fun ut020_rate_limit_login_6x_harus_diblokir() {
val nowMs = System.currentTimeMillis()
// Simulasi 5 percobaan gagal dalam 1 menit terakhir
val timestamps = List(5) { nowMs - (it * 5000L) } // tiap 5 detik
// Percobaan ke 1-5: belum diblokir
val percobaan5 = AbsensiUtils.cekRateLimit(
timestamps = timestamps.take(4), // 4 percobaan sebelumnya
nowMs = nowMs
)
assertFalse("UT-020 GAGAL — Percobaan ke-5 belum seharusnya diblokir", percobaan5)
// Percobaan ke-6: harus diblokir
val percobaan6 = AbsensiUtils.cekRateLimit(
timestamps = timestamps, // sudah 5 percobaan
nowMs = nowMs
)
assertTrue("UT-020 GAGAL — Percobaan ke-6 seharusnya diblokir", percobaan6)
}
@Test
fun ut020b_rate_limit_setelah_1_menit_harus_reset() {
val nowMs = System.currentTimeMillis()
// 5 percobaan yang terjadi 61 detik lalu (sudah di luar window)
val timestampsLama = List(5) { nowMs - 61_000L - (it * 1000L) }
val diblokir = AbsensiUtils.cekRateLimit(
timestamps = timestampsLama,
nowMs = nowMs
)
assertFalse("UT-020b GAGAL — Setelah 1 menit seharusnya tidak diblokir lagi", diblokir)
}
// ============================================================
// MODUL VALIDASI JADWAL (FIX 5)
// ============================================================
/**
* UT-021
* Mahasiswa Teknik Informatika coba absen di jadwal Sistem Informasi
* Tipe: Black-Box Testing
*/
@Test
fun ut021_absen_jadwal_jurusan_lain_harus_ditolak() {
val (valid, pesan) = AbsensiUtils.cekKepemilikanJadwal(
jurusanMahasiswa = "Teknik Informatika",
semesterMahasiswa = 6,
jurusanJadwal = "Sistem Informasi", // jurusan berbeda
semesterJadwal = 6
)
assertFalse("UT-021 GAGAL — Jadwal jurusan lain seharusnya ditolak", valid)
assertTrue("UT-021 GAGAL — Pesan harus menyebut 'tidak valid' atau 'bukan milik': $pesan",
pesan.contains("tidak valid", ignoreCase = true) ||
pesan.contains("bukan milik", ignoreCase = true))
}
@Test
fun ut021b_absen_jadwal_semester_berbeda_harus_ditolak() {
val (valid, _) = AbsensiUtils.cekKepemilikanJadwal(
jurusanMahasiswa = "Teknik Informatika",
semesterMahasiswa = 6,
jurusanJadwal = "Teknik Informatika",
semesterJadwal = 4 // semester berbeda
)
assertFalse("UT-021b GAGAL — Semester berbeda seharusnya ditolak", valid)
}
@Test
fun ut021c_absen_jadwal_sendiri_harus_diterima() {
val (valid, _) = AbsensiUtils.cekKepemilikanJadwal(
jurusanMahasiswa = "Teknik Informatika",
semesterMahasiswa = 6,
jurusanJadwal = "Teknik Informatika",
semesterJadwal = 6
)
assertTrue("UT-021c GAGAL — Jadwal milik sendiri seharusnya diterima", valid)
}
// ============================================================
// MODUL VALIDASI JAM KELAS (FIX 6)
// ============================================================
/**
* UT-022
* Absensi di luar jam kelas aktif harus ditolak
* Tipe: Black-Box Testing
*/
@Test
fun ut022_absen_setelah_jam_selesai_harus_ditolak() {
// Jam kelas 08:00-10:30, absen pada 16:04
val (valid, pesan) = AbsensiUtils.cekJamKelasAktif(
jamMulai = "08:00",
jamSelesai = "10:30",
jamSekarang = "16:04"
)
assertFalse("UT-022 GAGAL — Absen setelah jam selesai seharusnya ditolak", valid)
assertTrue("UT-022 GAGAL — Pesan harus menyebut jam kelas: $pesan",
pesan.contains("08:00") && pesan.contains("10:30"))
}
@Test
fun ut022b_absen_sebelum_jam_mulai_harus_ditolak() {
// Jam kelas 13:00-15:30, absen pada 10:00
val (valid, pesan) = AbsensiUtils.cekJamKelasAktif(
jamMulai = "13:00",
jamSelesai = "15:30",
jamSekarang = "10:00"
)
assertFalse("UT-022b GAGAL — Absen sebelum jam mulai seharusnya ditolak", valid)
assertTrue(pesan.contains("13:00"))
}
@Test
fun ut022c_absen_saat_jam_kelas_aktif_harus_diterima() {
// Jam kelas 08:00-10:30, absen pada 09:15
val (valid, _) = AbsensiUtils.cekJamKelasAktif(
jamMulai = "08:00",
jamSelesai = "10:30",
jamSekarang = "09:15"
)
assertTrue("UT-022c GAGAL — Absen saat jam aktif seharusnya diterima", valid)
}
}

View File

@ -1,19 +1,6 @@
"""
Backend API untuk Aplikasi Absensi Akademik VERSI AMAN v2
Backend API untuk Aplikasi Absensi Akademik
Python Flask + MySQL + JWT Authentication
Ubhara Jaya 2026
SECURITY FIXES v1:
[FIX 1] Validasi koordinat GPS di server (bukan hanya di client)
[FIX 2] Validasi foto: wajib ada, harus valid base64 image, min size
[FIX 3] JWT terikat ke device_id token tidak bisa dibagikan antar HP
[FIX 4] Rate limiting max 5 percobaan login / 3 submit absensi per menit
[FIX 5] Validasi kepemilikan jadwal mahasiswa hanya bisa absen di jadwalnya sendiri
[FIX 6] Validasi timestamp absensi hanya diterima saat jam kelas aktif
SECURITY FIXES v2 (BARU):
[FIX 7] Location Token one-time token 2 menit, cegah bypass koordinat via Postman
[FIX 8] Deteksi anomali koordinat koordinat terlalu bulat / persis titik kampus ditolak
"""
from flask import Flask, request, jsonify
@ -22,15 +9,13 @@ import mysql.connector
from mysql.connector import Error
import jwt
import bcrypt
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta
import os
from functools import wraps
import base64
import math
import time
import secrets
import requests
from collections import defaultdict
# Hapus APScheduler agar server tidak berat/blocking
app = Flask(__name__)
CORS(app)
@ -39,298 +24,124 @@ CORS(app)
DB_CONFIG = {
'host': 'localhost',
'user': 'root',
'password': '',
'password': '@Rique03',
'database': 'db_absensi_akademik'
}
SECRET_KEY = 'ubhara-jaya-absensi-secret-2026-change-this'
# Lokasi Kampus
KAMPUS_LATITUDE = -6.223325
KAMPUS_LONGITUDE = 107.009406
KAMPUS_LONGITUDE = 107.009406
RADIUS_METER = 500.0
# Testing
# KAMPUS_LATITUDE = -6.2394664
# KAMPUS_LONGITUDE = 107.0898995
# RADIUS_METER = 500.0
# ==================== [FIX 7] LOCATION TOKEN STORE ====================
# Format: { 'token_hex': { 'id_mahasiswa': int, 'lat': float, 'lon': float, 'expires_at': float } }
# Disimpan di memory — otomatis hilang kalau server restart (aman, by design)
_location_tokens: dict = {}
LOCATION_TOKEN_TTL = 120 # detik — token expired dalam 2 menit
def bersihkan_token_expired():
"""Hapus token yang sudah expired dari store."""
now = time.time()
expired = [k for k, v in _location_tokens.items() if now > v['expires_at']]
for k in expired:
del _location_tokens[k]
# ==================== IN-MEMORY RATE LIMITER ====================
_rate_limit_store = defaultdict(list)
def is_rate_limited(identifier: str, max_calls: int, window_seconds: int) -> bool:
now = time.time()
_rate_limit_store[identifier] = [t for t in _rate_limit_store[identifier] if now - t < window_seconds]
if len(_rate_limit_store[identifier]) >= max_calls:
return True
_rate_limit_store[identifier].append(now)
return False
def get_client_ip():
return request.headers.get('X-Forwarded-For', request.remote_addr)
# ==================== HELPER JARAK GPS ====================
def hitung_jarak_meter(lat1, lon1, lat2, lon2):
"""Haversine formula."""
R = 6371000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi/2)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda/2)**2
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
# ==================== [FIX 8] DETEKSI ANOMALI KOORDINAT ====================
def deteksi_anomali_koordinat(lat: float, lon: float) -> tuple[bool, str]:
"""
Deteksi koordinat yang mencurigakan / kemungkinan di-input manual:
1. Koordinat 0,0 GPS tidak aktif
2. Koordinat persis sama dengan konstanta kampus copy-paste manual
3. Presisi desimal kurang dari 4 angka bukan dari GPS asli
(GPS hardware selalu menghasilkan 6-8 angka desimal)
4. Nilai lat/lon di luar batas geografis Indonesia
"""
# 1. Koordinat null island
if lat == 0.0 and lon == 0.0:
return True, "GPS tidak aktif atau koordinat tidak valid (0,0)"
# 2. Persis sama dengan titik kampus (kemungkinan hardcoded/copy-paste)
if lat == KAMPUS_LATITUDE and lon == KAMPUS_LONGITUDE:
return True, "Koordinat identik dengan titik kampus, kemungkinan diinput manual"
# 3. Cek presisi desimal — GPS asli selalu punya ≥4 angka desimal
lat_str = f"{lat}"
lon_str = f"{lon}"
lat_desimal = len(lat_str.split('.')[-1]) if '.' in lat_str else 0
lon_desimal = len(lon_str.split('.')[-1]) if '.' in lon_str else 0
if lat_desimal < 4 or lon_desimal < 4:
return True, f"Presisi koordinat terlalu rendah ({lat_desimal}/{lon_desimal} desimal), bukan dari GPS asli"
# 4. Batas geografis Indonesia (lat: -11 s/d 6, lon: 95 s/d 141)
if not (-11.0 <= lat <= 6.0) or not (95.0 <= lon <= 141.0):
return True, "Koordinat di luar wilayah Indonesia"
return False, "OK"
# ==================== HELPER VALIDASI FOTO ====================
def validasi_foto(foto_base64: str) -> tuple[bool, str]:
if not foto_base64 or len(foto_base64.strip()) == 0:
return False, "Foto wajib disertakan"
if ',' in foto_base64:
foto_base64 = foto_base64.split(',')[1]
try:
decoded = base64.b64decode(foto_base64)
except Exception:
return False, "Format foto tidak valid"
is_jpg = decoded[:3] == b'\xff\xd8\xff'
is_png = decoded[:8] == b'\x89PNG\r\n\x1a\n'
is_webp = decoded[8:12] == b'WEBP'
if not (is_jpg or is_png or is_webp):
return False, "File bukan gambar yang valid (harus JPG/PNG/WEBP)"
if len(decoded) < 5 * 1024:
return False, "Ukuran foto terlalu kecil, pastikan foto wajah terambil dengan benar"
return True, "OK"
# ==================== DATABASE ====================
# ==================== DATABASE CONNECTION ====================
def get_db_connection():
try:
return mysql.connector.connect(**DB_CONFIG)
connection = mysql.connector.connect(**DB_CONFIG)
return connection
except Error as e:
print(f"DB Error: {e}")
print(f"Error connecting to MySQL: {e}")
return None
def init_database():
try:
temp_config = {k: v for k, v in DB_CONFIG.items() if k != 'database'}
connection = mysql.connector.connect(**temp_config)
except Error as e:
print(f"❌ Tidak bisa konek ke MySQL: {e}")
return
connection = get_db_connection()
if connection is None: return
cursor = connection.cursor()
try:
cursor.execute(f"CREATE DATABASE IF NOT EXISTS {DB_CONFIG['database']}")
cursor.execute(f"USE {DB_CONFIG['database']}")
cursor.execute("""
CREATE TABLE IF NOT EXISTS mahasiswa (
id_mahasiswa INT AUTO_INCREMENT PRIMARY KEY,
npm VARCHAR(20) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
nama VARCHAR(100) NOT NULL,
jenkel VARCHAR(10) NOT NULL,
fakultas VARCHAR(100) NOT NULL,
jurusan VARCHAR(100) NOT NULL,
semester INT NOT NULL,
device_id VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS mata_kuliah (
id_matkul INT AUTO_INCREMENT PRIMARY KEY,
kode_matkul VARCHAR(20) UNIQUE NOT NULL,
nama_matkul VARCHAR(100) NOT NULL,
sks INT NOT NULL,
dosen VARCHAR(100) NOT NULL
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS jadwal_kelas (
id_jadwal INT AUTO_INCREMENT PRIMARY KEY,
id_matkul INT NOT NULL,
hari VARCHAR(10) NOT NULL,
jam_mulai TIME NOT NULL,
jam_selesai TIME NOT NULL,
ruangan VARCHAR(50) NOT NULL,
jurusan VARCHAR(100) NOT NULL,
semester INT NOT NULL,
FOREIGN KEY (id_matkul) REFERENCES mata_kuliah(id_matkul)
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS absensi (
id_absensi INT AUTO_INCREMENT PRIMARY KEY,
id_mahasiswa INT NOT NULL,
npm VARCHAR(20) NOT NULL,
nama VARCHAR(100) NOT NULL,
id_jadwal INT,
mata_kuliah VARCHAR(100),
latitude DOUBLE,
longitude DOUBLE,
jarak_meter DOUBLE,
timestamp DATETIME NOT NULL,
photo LONGTEXT,
foto_base64 LONGTEXT,
status VARCHAR(20) NOT NULL DEFAULT 'HADIR',
device_id VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (id_mahasiswa) REFERENCES mahasiswa(id_mahasiswa),
FOREIGN KEY (id_jadwal) REFERENCES jadwal_kelas(id_jadwal)
)
""")
# Upgrade kolom dari versi lama (abaikan error jika sudah ada)
for col_sql in [
"ALTER TABLE mahasiswa ADD COLUMN device_id VARCHAR(255) AFTER semester",
"ALTER TABLE absensi ADD COLUMN jarak_meter DOUBLE AFTER longitude",
"ALTER TABLE absensi ADD COLUMN device_id VARCHAR(255) AFTER foto_base64",
]:
try:
cursor.execute(col_sql)
except Exception:
pass
# (Kode pembuatan tabel tetap sama, disembunyikan agar ringkas)
# ... Tabel Mahasiswa, Matkul, Jadwal, Absensi ...
connection.commit()
print("✅ Database & semua tabel siap!")
except Error as e:
print(f"❌ Error init DB: {e}")
print(f"❌ Error creating tables: {e}")
finally:
cursor.close()
connection.close()
cursor.close(); connection.close()
# ==================== HELPER WAKTU ====================
# ==================== HELPER WAKTU (PENTING UTK AUTO ALFA) ====================
def get_hari_indo():
"""Mengambil hari saat ini sesuai jam Laptop/Server"""
hari_inggris = datetime.now().strftime('%A')
mapping = {
'Monday': 'Senin', 'Tuesday': 'Selasa', 'Wednesday': 'Rabu',
'Thursday': 'Kamis', 'Friday': 'Jumat', 'Saturday': 'Sabtu', 'Sunday': 'Minggu'
}
return mapping.get(datetime.now().strftime('%A'), 'Senin')
return mapping.get(hari_inggris, 'Senin')
# ==================== AUTO ALFA ====================
# ==================== LOGIKA AUTO ALFA (TRIGGER) ====================
def jalankan_auto_alfa():
"""
Fungsi ini dipanggil SETIAP KALI user me-refresh jadwal.
Mengecek apakah ada kelas yang sudah lewat jamnya, lalu set TIDAK HADIR.
"""
try:
conn = get_db_connection()
if conn is None:
return
if conn is None: return
cursor = conn.cursor(dictionary=True)
hari_ini = get_hari_indo()
jam_sekarang = datetime.now().time()
timestamp_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 1. Waktu Sekarang
hari_ini = get_hari_indo()
waktu_skrg = datetime.now()
jam_sekarang = waktu_skrg.time()
timestamp_str = waktu_skrg.strftime('%Y-%m-%d %H:%M:%S')
# 2. Cari Jadwal yang SUDAH SELESAI hari ini (jam_selesai < jam_sekarang)
cursor.execute("""
SELECT j.id_jadwal, m.nama_matkul, j.jurusan, j.semester
FROM jadwal_kelas j JOIN mata_kuliah m ON j.id_matkul=m.id_matkul
WHERE j.hari=%s AND j.jam_selesai < %s
SELECT j.id_jadwal, m.nama_matkul, j.jam_selesai, j.jurusan, j.semester
FROM jadwal_kelas j
JOIN mata_kuliah m ON j.id_matkul = m.id_matkul
WHERE j.hari = %s
AND j.jam_selesai < %s
""", (hari_ini, jam_sekarang))
for j in cursor.fetchall():
cursor.execute(
"SELECT id_mahasiswa,npm,nama FROM mahasiswa WHERE jurusan=%s AND semester=%s",
(j['jurusan'], j['semester'])
)
for mhs in cursor.fetchall():
jadwal_selesai = cursor.fetchall()
for j in jadwal_selesai:
# Cari Mahasiswa Target
cursor.execute("SELECT id_mahasiswa, npm, nama FROM mahasiswa WHERE jurusan=%s AND semester=%s",
(j['jurusan'], j['semester']))
mahasiswa_list = cursor.fetchall()
for mhs in mahasiswa_list:
# Cek Absen
cursor.execute("""
SELECT COUNT(*) as cnt FROM absensi
WHERE id_mahasiswa=%s AND id_jadwal=%s AND DATE(timestamp)=CURDATE()
""", (mhs['id_mahasiswa'], j['id_jadwal']))
WHERE id_mahasiswa=%s AND id_jadwal=%s AND DATE(timestamp)=DATE(%s)
""", (mhs['id_mahasiswa'], j['id_jadwal'], timestamp_str))
if cursor.fetchone()['cnt'] == 0:
# INSERT TIDAK HADIR
print(f"⚠️ Auto-Alfa Triggered: {mhs['nama']} - {j['nama_matkul']}")
cursor.execute("""
INSERT INTO absensi (id_mahasiswa,npm,nama,id_jadwal,mata_kuliah,
latitude,longitude,jarak_meter,timestamp,status)
VALUES (%s,%s,%s,%s,%s,0,0,0,%s,'TIDAK HADIR')
""", (mhs['id_mahasiswa'],mhs['npm'],mhs['nama'],
j['id_jadwal'],j['nama_matkul'],timestamp_str))
INSERT INTO absensi (
id_mahasiswa, npm, nama, id_jadwal, mata_kuliah,
latitude, longitude, timestamp, photo, foto_base64, status
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'TIDAK HADIR')
""", (mhs['id_mahasiswa'], mhs['npm'], mhs['nama'], j['id_jadwal'], j['nama_matkul'], 0.0, 0.0, timestamp_str, None, None))
conn.commit()
print(f"⚠️ Auto-Alfa: {mhs['nama']} - {j['nama_matkul']}")
cursor.close(); conn.close()
except Exception as e:
print(f"Error Auto Alfa: {e}")
# ==================== JWT ====================
# ==================== JWT HELPER ====================
def generate_token(id_mahasiswa, npm, device_id):
def generate_token(id_mahasiswa, npm):
payload = {
'id_mahasiswa': id_mahasiswa,
'npm': npm,
'device_id': device_id,
'exp': datetime.now(timezone.utc) + timedelta(days=30)
'id_mahasiswa': id_mahasiswa, 'npm': npm,
'exp': datetime.utcnow() + timedelta(days=30)
}
return jwt.encode(payload, SECRET_KEY, algorithm='HS256')
def token_required(f):
@wraps(f)
def decorated(*args, **kwargs):
token = request.headers.get('Authorization', '')
if not token:
return jsonify({'error': 'Token tidak ditemukan'}), 401
token = request.headers.get('Authorization')
if not token: return jsonify({'error': 'Token tidak ditemukan'}), 401
try:
if token.startswith('Bearer '):
token = token.split(' ')[1]
if token.startswith('Bearer '): token = token.split(' ')[1]
data = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
request.user_data = data
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Token kadaluarsa, silakan login ulang'}), 401
except Exception:
return jsonify({'error': 'Token tidak valid'}), 401
except: return jsonify({'error': 'Token invalid'}), 401
return f(*args, **kwargs)
return decorated
@ -338,410 +149,235 @@ def token_required(f):
@app.route('/api/health', methods=['GET'])
def health_check():
return jsonify({'status': 'OK', 'message': 'API Running — Secured Version v2'})
return jsonify({'status': 'OK', 'message': 'API Running'})
# -------- AUTH --------
# ==================== AUTH (Register & Login) ====================
# (Kode Register & Login Anda tidak saya ubah, tetap sama persis)
@app.route('/api/auth/register', methods=['POST'])
def register():
if is_rate_limited(f"{get_client_ip()}:register", 3, 600):
return jsonify({'error': 'Terlalu banyak percobaan, coba lagi nanti'}), 429
try:
data = request.get_json()
for field in ['npm','password','nama','jenkel','fakultas','jurusan','semester','device_id']:
if not data.get(field):
return jsonify({'error': f'Field {field} wajib diisi'}), 400
hashed = bcrypt.hashpw(data['password'].encode(), bcrypt.gensalt()).decode()
conn = get_db_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO mahasiswa (npm,password,nama,jenkel,fakultas,jurusan,semester,device_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s)",
(data['npm'],hashed,data['nama'],data['jenkel'],data['fakultas'],data['jurusan'],data['semester'],data['device_id'])
)
conn.commit()
id_mhs = cur.lastrowid
cur.close(); conn.close()
token = generate_token(id_mhs, data['npm'], data['device_id'])
return jsonify({
'message': 'Registrasi berhasil',
'data': {
'token': token, 'id_mahasiswa': id_mhs, 'npm': data['npm'],
'nama': data['nama'], 'jenkel': data['jenkel'],
'fakultas': data['fakultas'], 'jurusan': data['jurusan'],
'semester': data['semester']
}
}), 201
except mysql.connector.IntegrityError:
return jsonify({'error': 'NPM sudah terdaftar'}), 409
except Exception as e:
return jsonify({'error': str(e)}), 500
# ... (Logika register Anda tetap sama) ...
# (Saya singkat agar tidak terlalu panjang, tapi logika asli tetap jalan)
hashed_password = bcrypt.hashpw(data['password'].encode('utf-8'), bcrypt.gensalt())
connection = get_db_connection()
cursor = connection.cursor()
cursor.execute("INSERT INTO mahasiswa (npm, password, nama, jenkel, fakultas, jurusan, semester) VALUES (%s, %s, %s, %s, %s, %s, %s)",
(data['npm'], hashed_password.decode('utf-8'), data['nama'], data['jenkel'], data['fakultas'], data['jurusan'], data['semester']))
connection.commit()
id_mahasiswa = cursor.lastrowid
cursor.close(); connection.close()
token = generate_token(id_mahasiswa, data['npm'])
return jsonify({'message': 'Registrasi berhasil', 'data': {'token': token}}), 201
except Exception as e: return jsonify({'error': str(e)}), 500
@app.route('/api/auth/login', methods=['POST'])
def login():
if is_rate_limited(f"{get_client_ip()}:login", 5, 60):
return jsonify({'error': 'Terlalu banyak percobaan login, tunggu 1 menit'}), 429
try:
data = request.get_json()
device_id = data.get('device_id', '')
connection = get_db_connection()
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT * FROM mahasiswa WHERE npm = %s", (data['npm'],))
mahasiswa = cursor.fetchone()
cursor.close(); connection.close()
conn = get_db_connection()
cur = conn.cursor(dictionary=True)
cur.execute("SELECT * FROM mahasiswa WHERE npm=%s", (data['npm'],))
mhs = cur.fetchone()
if not mhs or not bcrypt.checkpw(data['password'].encode(), mhs['password'].encode()):
if not mahasiswa or not bcrypt.checkpw(data['password'].encode('utf-8'), mahasiswa['password'].encode('utf-8')):
return jsonify({'error': 'NPM atau Password salah'}), 401
# [FIX 3] Cek device binding
if mhs.get('device_id') and mhs['device_id'] != device_id:
cur.close(); conn.close()
return jsonify({'error': 'Akun ini sudah terdaftar di perangkat lain. Hubungi admin untuk reset.'}), 403
if device_id:
cur.execute("UPDATE mahasiswa SET device_id=%s WHERE id_mahasiswa=%s",
(device_id, mhs['id_mahasiswa']))
conn.commit()
cur.close(); conn.close()
token = generate_token(mhs['id_mahasiswa'], mhs['npm'], device_id)
mhs.pop('password', None)
return jsonify({'message': 'Login berhasil', 'data': {**mhs, 'token': token}}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
token = generate_token(mahasiswa['id_mahasiswa'], mahasiswa['npm'])
return jsonify({'message': 'Login berhasil', 'data': {**mahasiswa, 'token': token}}), 200
except Exception as e: return jsonify({'error': str(e)}), 500
@app.route('/api/mahasiswa/profile', methods=['GET'])
@token_required
def get_profile():
connection = get_db_connection()
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT * FROM mahasiswa WHERE id_mahasiswa = %s", (request.user_data['id_mahasiswa'],))
mahasiswa = cursor.fetchone()
cursor.close(); connection.close()
return jsonify({'data': mahasiswa}), 200
# ==================== ABSENSI & JADWAL ====================
@app.route('/api/absensi/submit', methods=['POST'])
@token_required
def submit_absensi():
try:
data = request.get_json()
status = data.get('status', 'HADIR')
# Ambil data mentah dari Android
foto_input = data.get('foto_base64') or data.get('photo')
conn = get_db_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT id_mahasiswa,npm,nama,jenkel,fakultas,jurusan,semester FROM mahasiswa WHERE id_mahasiswa=%s",
(request.user_data['id_mahasiswa'],)
)
mhs = cur.fetchone()
cur.close(); conn.close()
return jsonify({'data': mhs}), 200
# -------- JADWAL --------
# 1. Cek Double Absen
cur.execute("SELECT COUNT(*) as c FROM absensi WHERE id_mahasiswa=%s AND id_jadwal=%s AND DATE(timestamp)=CURDATE()",
(request.user_data['id_mahasiswa'], data['id_jadwal']))
if cur.fetchone()['c'] > 0:
cur.close(); conn.close()
return jsonify({'error': 'Anda sudah absen mata kuliah ini hari ini!'}), 400
# 2. Ambil Nama Mhs & Matkul
cur.execute("SELECT nama FROM mahasiswa WHERE id_mahasiswa=%s", (request.user_data['id_mahasiswa'],))
nama_mhs = cur.fetchone()['nama']
cur.execute("SELECT nama_matkul FROM mata_kuliah m JOIN jadwal_kelas j ON m.id_matkul=j.id_matkul WHERE j.id_jadwal=%s", (data['id_jadwal'],))
nama_matkul = cur.fetchone()['nama_matkul']
# 3. Insert ke Database
waktu_skrg = datetime.now()
timestamp_str = waktu_skrg.strftime('%Y-%m-%d %H:%M:%S')
cur.execute("""
INSERT INTO absensi (id_mahasiswa, npm, nama, id_jadwal, mata_kuliah, latitude, longitude, timestamp, photo, foto_base64, status)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (request.user_data['id_mahasiswa'], request.user_data['npm'], nama_mhs, data['id_jadwal'], nama_matkul,
data['latitude'], data['longitude'], timestamp_str, foto_input, foto_input, status))
# Simpan perubahan & Ambil ID Baru
conn.commit()
new_id = cur.lastrowid
# ==========================================================
# 4. AMBIL ULANG DARI DATABASE (SOLUSI PASTI)
# ==========================================================
# Sesuai permintaan Anda: Kita ambil data yang BARU SAJA masuk
# untuk memastikan variabelnya tidak kosong.
cur.execute("SELECT foto_base64 FROM absensi WHERE id_absensi=%s", (new_id,))
row = cur.fetchone()
# Pastikan kita punya datanya
foto_final = row['foto_base64'] if row else None
cur.close(); conn.close()
# ==========================================================
# 5. KIRIM KE WEBHOOK N8N
# ==========================================================
try:
webhook_url = "https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254"
# Payload dengan Foto ASLI dari Database
webhook_payload = {
"id_absensi": new_id,
"npm": request.user_data['npm'],
"nama": nama_mhs,
"mata_kuliah": nama_matkul,
"latitude": data['latitude'],
"longitude": data['longitude'],
"timestamp": timestamp_str,
"status": status,
"foto_base64": foto_final, # Kirim String Base64 Panjang
}
# Kirim (Timeout agak lama karena Base64 besar)
requests.post(webhook_url, json=webhook_payload, timeout=10)
print(f"✅ Data ID {new_id} terkirim ke N8N (Size foto: {len(str(foto_final))} chars)")
except Exception as e:
print(f"⚠️ Gagal kirim ke N8N: {e}")
# 6. Respon ke Android
return jsonify({
'message': 'Absensi berhasil disimpan',
'data': {
'id_absensi': new_id,
'status': status,
'mata_kuliah': nama_matkul,
'timestamp': timestamp_str
}
}), 201
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/jadwal/today', methods=['GET'])
@token_required
def get_jadwal_today():
try:
# 1. TRIGGER AUTO ALFA
# Jalankan pengecekan otomatis SEBELUM mengambil data jadwal
jalankan_auto_alfa()
# 2. Ambil Data Jadwal
hari_ini = get_hari_indo()
conn = get_db_connection()
cur = conn.cursor(dictionary=True)
cur.execute("SELECT jurusan,semester FROM mahasiswa WHERE id_mahasiswa=%s",
(request.user_data['id_mahasiswa'],))
cur.execute("SELECT jurusan, semester FROM mahasiswa WHERE id_mahasiswa=%s", (request.user_data['id_mahasiswa'],))
mhs = cur.fetchone()
cur.execute("""
SELECT j.*, m.nama_matkul, m.sks, m.dosen, m.kode_matkul
FROM jadwal_kelas j JOIN mata_kuliah m ON j.id_matkul=m.id_matkul
FROM jadwal_kelas j
JOIN mata_kuliah m ON j.id_matkul = m.id_matkul
WHERE j.hari=%s AND j.jurusan=%s AND j.semester=%s
ORDER BY j.jam_mulai
""", (hari_ini, mhs['jurusan'], mhs['semester']))
jadwal = cur.fetchall()
for j in jadwal:
for col in ['jam_mulai','jam_selesai']:
if isinstance(j.get(col), timedelta):
j[col] = str(j[col])
if isinstance(j['jam_mulai'], timedelta): j['jam_mulai'] = str(j['jam_mulai'])
if isinstance(j['jam_selesai'], timedelta): j['jam_selesai'] = str(j['jam_selesai'])
# Ambil Status Absensi (HADIR / TIDAK HADIR / SAKIT / IZIN)
cur.execute("""
SELECT status FROM absensi
WHERE id_mahasiswa=%s AND id_jadwal=%s AND DATE(timestamp)=CURDATE()
""", (request.user_data['id_mahasiswa'], j['id_jadwal']))
res = cur.fetchone()
j['sudah_absen'] = bool(res)
j['status_absensi'] = res['status'] if res else None
if res:
j['sudah_absen'] = True
j['status_absensi'] = res['status']
else:
j['sudah_absen'] = False
j['status_absensi'] = None
cur.close(); conn.close()
return jsonify({'data': jadwal, 'hari': hari_ini})
except Exception as e:
return jsonify({'error': str(e)}), 500
# -------- [FIX 7] LOCATION TOKEN --------
@app.route('/api/absensi/request-location-token', methods=['POST'])
@token_required
def request_location_token():
"""
Android memanggil endpoint ini tepat setelah GPS fix diterima.
Backend mencatat waktu & koordinat, lalu memberikan token sekali pakai.
Token hanya valid 2 menit dan hanya bisa dipakai 1x.
Alur wajib di Android:
1. Terima GPS fix
2. POST /api/absensi/request-location-token dapat location_token
3. Ambil foto selfie (maks dalam 2 menit)
4. POST /api/absensi/submit + location_token
"""
# Rate limit: maks 10 request token per 5 menit per user
user_id = str(request.user_data['id_mahasiswa'])
if is_rate_limited(f"{user_id}:loc_token", 10, 300):
return jsonify({'error': 'Terlalu banyak permintaan token lokasi'}), 429
try:
data = request.get_json()
lat = data.get('latitude')
lon = data.get('longitude')
if lat is None or lon is None:
return jsonify({'error': 'Koordinat GPS wajib disertakan'}), 400
try:
lat = float(lat)
lon = float(lon)
except (ValueError, TypeError):
return jsonify({'error': 'Koordinat tidak valid'}), 400
# [FIX 8] Jalankan deteksi anomali koordinat
mencurigakan, alasan = deteksi_anomali_koordinat(lat, lon)
if mencurigakan:
return jsonify({'error': f'Koordinat tidak valid: {alasan}'}), 400
# Cek apakah sudah dalam radius kampus
jarak = hitung_jarak_meter(lat, lon, KAMPUS_LATITUDE, KAMPUS_LONGITUDE)
if jarak > RADIUS_METER:
return jsonify({
'error': f'Lokasi Anda terlalu jauh dari kampus ({jarak:.0f}m). Maksimal {RADIUS_METER:.0f}m.',
'jarak_meter': round(jarak, 1)
}), 403
# Bersihkan token lama yang expired
bersihkan_token_expired()
# Buat location token baru
loc_token = secrets.token_hex(32)
_location_tokens[loc_token] = {
'id_mahasiswa': request.user_data['id_mahasiswa'],
'device_id': request.user_data.get('device_id', ''),
'lat': lat,
'lon': lon,
'jarak_meter': round(jarak, 1),
'expires_at': time.time() + LOCATION_TOKEN_TTL
}
print(f"📍 Location token diterbitkan untuk user {user_id} | jarak: {jarak:.1f}m")
return jsonify({
'location_token': loc_token,
'expires_in_seconds': LOCATION_TOKEN_TTL,
'jarak_meter': round(jarak, 1),
'message': f'Token lokasi valid selama {LOCATION_TOKEN_TTL} detik. Segera ambil foto dan submit absensi.'
}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
# -------- ABSENSI SUBMIT --------
@app.route('/api/absensi/submit', methods=['POST'])
@token_required
def submit_absensi():
# [FIX 4] Rate limit
user_id = str(request.user_data['id_mahasiswa'])
if is_rate_limited(f"{user_id}:absensi", 3, 60):
return jsonify({'error': 'Terlalu banyak request, coba lagi sebentar'}), 429
try:
data = request.get_json()
status = data.get('status', 'HADIR')
conn = get_db_connection()
cur = conn.cursor(dictionary=True)
# [FIX 3] Verifikasi device_id
token_device_id = request.user_data.get('device_id', '')
cur.execute("SELECT device_id,nama,jurusan,semester FROM mahasiswa WHERE id_mahasiswa=%s",
(request.user_data['id_mahasiswa'],))
mhs_data = cur.fetchone()
if mhs_data.get('device_id') and mhs_data['device_id'] != token_device_id:
cur.close(); conn.close()
return jsonify({'error': 'Request berasal dari perangkat tidak sah'}), 403
# [FIX 7] Validasi location token — WAJIB ADA
loc_token = data.get('location_token')
if not loc_token:
cur.close(); conn.close()
return jsonify({
'error': 'Location token wajib disertakan. Panggil /api/absensi/request-location-token terlebih dahulu.'
}), 400
token_data = _location_tokens.get(loc_token)
if not token_data:
cur.close(); conn.close()
return jsonify({'error': 'Location token tidak valid atau sudah digunakan'}), 403
if time.time() > token_data['expires_at']:
del _location_tokens[loc_token]
cur.close(); conn.close()
return jsonify({'error': f'Location token sudah kadaluarsa (maks {LOCATION_TOKEN_TTL} detik). Silakan ulangi proses absensi.'}), 403
if token_data['id_mahasiswa'] != request.user_data['id_mahasiswa']:
cur.close(); conn.close()
return jsonify({'error': 'Location token bukan milik Anda'}), 403
if token_data['device_id'] != token_device_id:
cur.close(); conn.close()
return jsonify({'error': 'Location token digunakan dari perangkat berbeda'}), 403
# Ambil koordinat dan jarak yang sudah diverifikasi dari token (bukan dari request body)
# Ini mencegah manipulasi koordinat di tahap submit
lat = token_data['lat']
lon = token_data['lon']
jarak = token_data['jarak_meter']
# Hapus token — ONE TIME USE
del _location_tokens[loc_token]
# [FIX 5] Validasi kepemilikan jadwal
cur.execute("""
SELECT j.id_jadwal,j.jam_mulai,j.jam_selesai,j.jurusan,j.semester,m.nama_matkul
FROM jadwal_kelas j JOIN mata_kuliah m ON j.id_matkul=m.id_matkul
WHERE j.id_jadwal=%s AND j.jurusan=%s AND j.semester=%s
""", (data['id_jadwal'], mhs_data['jurusan'], mhs_data['semester']))
jadwal = cur.fetchone()
if not jadwal:
cur.close(); conn.close()
return jsonify({'error': 'Jadwal tidak valid atau bukan milik kelas Anda'}), 403
# [FIX 6] Validasi jam kelas aktif
jam_sekarang = datetime.now().time()
jam_mulai = (datetime.min + jadwal['jam_mulai']).time() if isinstance(jadwal['jam_mulai'], timedelta) else jadwal['jam_mulai']
jam_selesai = (datetime.min + jadwal['jam_selesai']).time() if isinstance(jadwal['jam_selesai'], timedelta) else jadwal['jam_selesai']
if not (jam_mulai <= jam_sekarang <= jam_selesai):
cur.close(); conn.close()
return jsonify({
'error': f'Absensi hanya bisa dilakukan saat jam kelas aktif ({jam_mulai.strftime("%H:%M")} - {jam_selesai.strftime("%H:%M")})'
}), 403
# [FIX 2] Validasi foto
foto_input = data.get('foto_base64') or data.get('photo')
valid, pesan = validasi_foto(foto_input)
if not valid:
cur.close(); conn.close()
return jsonify({'error': f'Foto tidak valid: {pesan}'}), 400
# Cek double absen
cur.execute(
"SELECT COUNT(*) as c FROM absensi WHERE id_mahasiswa=%s AND id_jadwal=%s AND DATE(timestamp)=CURDATE()",
(request.user_data['id_mahasiswa'], data['id_jadwal'])
)
if cur.fetchone()['c'] > 0:
cur.close(); conn.close()
return jsonify({'error': 'Anda sudah absen mata kuliah ini hari ini!'}), 400
timestamp_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cur.execute("""
INSERT INTO absensi (id_mahasiswa,npm,nama,id_jadwal,mata_kuliah,
latitude,longitude,jarak_meter,timestamp,photo,foto_base64,status,device_id)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", (request.user_data['id_mahasiswa'], request.user_data['npm'], mhs_data['nama'],
data['id_jadwal'], jadwal['nama_matkul'],
lat, lon, jarak, timestamp_str,
foto_input, foto_input, status, token_device_id))
conn.commit()
new_id = cur.lastrowid
cur.close(); conn.close()
# Kirim ke N8N webhook
try:
requests.post(
"https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254",
json={
"id_absensi": new_id, "npm": request.user_data['npm'],
"nama": mhs_data['nama'], "mata_kuliah": jadwal['nama_matkul'],
"latitude": lat, "longitude": lon, "jarak_meter": jarak,
"timestamp": timestamp_str, "status": status, "foto_base64": foto_input
}, timeout=10
)
except Exception as e:
print(f"⚠️ Gagal kirim ke N8N: {e}")
return jsonify({
'message': 'Absensi berhasil disimpan',
'data': {
'id_absensi': new_id, 'status': status,
'mata_kuliah': jadwal['nama_matkul'],
'jarak_meter': jarak,
'timestamp': timestamp_str
}
}), 201
except Exception as e:
print(f"Error submit absensi: {e}")
return jsonify({'error': str(e)}), 500
# -------- RIWAYAT & FOTO --------
except Exception as e: return jsonify({'error': str(e)}), 500
@app.route('/api/absensi/history', methods=['GET'])
@token_required
def get_history():
try:
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
conn = get_db_connection()
cur = conn.cursor(dictionary=True)
query = """
SELECT a.id_absensi,a.npm,a.nama,a.mata_kuliah,a.latitude,a.longitude,
a.jarak_meter,a.timestamp,a.status,a.created_at,j.jam_mulai,j.jam_selesai
FROM absensi a LEFT JOIN jadwal_kelas j ON a.id_jadwal=j.id_jadwal
WHERE a.id_mahasiswa=%s
"""
params = [request.user_data['id_mahasiswa']]
if start_date:
query += " AND DATE(a.timestamp)>=%s"; params.append(start_date)
if end_date:
query += " AND DATE(a.timestamp)<=%s"; params.append(end_date)
query += " ORDER BY a.timestamp DESC"
cur.execute(query, params)
history = cur.fetchall()
connection = get_db_connection()
cursor = connection.cursor(dictionary=True)
# Join jadwal untuk ambil jam
cursor.execute("""
SELECT a.*, j.jam_mulai, j.jam_selesai
FROM absensi a
LEFT JOIN jadwal_kelas j ON a.id_jadwal = j.id_jadwal
WHERE a.id_mahasiswa = %s ORDER BY a.timestamp DESC
""", (request.user_data['id_mahasiswa'],))
history = cursor.fetchall()
for item in history:
for col in ['jam_mulai','jam_selesai']:
if isinstance(item.get(col), timedelta):
item[col] = str(item[col])
cur.close(); conn.close()
if item['jam_mulai']: item['jam_mulai'] = str(item['jam_mulai'])
if item['jam_selesai']: item['jam_selesai'] = str(item['jam_selesai'])
cursor.close(); connection.close()
return jsonify({'data': history}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/absensi/photo/<int:id_absensi>', methods=['GET'])
@token_required
def get_photo(id_absensi):
conn = get_db_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT foto_base64 FROM absensi WHERE id_absensi=%s AND id_mahasiswa=%s",
(id_absensi, request.user_data['id_mahasiswa'])
)
result = cur.fetchone()
cur.close(); conn.close()
if result:
return jsonify({'data': result}), 200
return jsonify({'error': 'Foto tidak ditemukan atau bukan milik Anda'}), 404
connection = get_db_connection()
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT foto_base64 FROM absensi WHERE id_absensi = %s", (id_absensi,))
result = cursor.fetchone()
cursor.close(); connection.close()
if result: return jsonify({'data': result}), 200
return jsonify({'error': 'Not found'}), 404
# ==================== RUN SERVER ====================
if __name__ == '__main__':
# HAPUS semua kode Scheduler disini agar tidak blocking
print("🚀 Menginisialisasi database...")
init_database()
print("🔒 Security fixes aktif:")
print(" ✅ [FIX 1] Server-side GPS validation")
print(" ✅ [FIX 2] Server-side foto validation")
print(" ✅ [FIX 3] JWT device binding")
print(" ✅ [FIX 4] Rate limiting")
print(" ✅ [FIX 5] Jadwal ownership validation")
print(" ✅ [FIX 6] Jam kelas aktif validation")
print(" ✅ [FIX 7] Location token (one-time, 2 menit)")
print(" ✅ [FIX 8] Anomali koordinat detection")
print("🌐 Starting Flask server...")
print("🌐 Starting Flask server (Auto Alfa Trigger Mode)...")
app.run(debug=True, host='0.0.0.0', port=5000)

View File

@ -7,4 +7,3 @@ bcrypt==4.1.2
python-dotenv==1.0.0
requests==2.31.0
Flask-APScheduler==1.13.1
pytest==8.2.0

View File

@ -1,137 +0,0 @@
import pytest
import json
from unittest.mock import patch
import app as flask_app # Asumsi file utamamu bernama app.py
# ==========================================
# FIXTURE: Setup Lingkungan Testing
# ==========================================
@pytest.fixture
def setup_client():
# 1. Override nama database ke database khusus testing
flask_app.DB_CONFIG['database'] = 'db_absensi_test'
flask_app.init_database()
# 2. Buka koneksi dan Seed Data Dummy
conn = flask_app.get_db_connection()
cur = conn.cursor()
# Seed Mahasiswa
hashed_pw = flask_app.bcrypt.hashpw(b'Password123', flask_app.bcrypt.gensalt()).decode()
cur.execute("""
INSERT INTO mahasiswa (npm, password, nama, jenkel, fakultas, jurusan, semester, device_id)
VALUES ('202310715297', %s, 'Ariq', 'L', 'Fasilkom', 'Informatika', 6, 'device_sah_01')
""", (hashed_pw,))
# Seed Mata Kuliah
cur.execute("INSERT INTO mata_kuliah (kode_matkul, nama_matkul, sks, dosen) VALUES ('IF123', 'Integration Testing', 3, 'Tim QA')")
id_matkul = cur.lastrowid
# Seed Jadwal (Diset 00:00 - 23:59 hari ini agar [FIX 6] selalu lolos saat testing kapan pun)
hari_ini = flask_app.get_hari_indo()
cur.execute("""
INSERT INTO jadwal_kelas (id_matkul, hari, jam_mulai, jam_selesai, ruangan, jurusan, semester)
VALUES (%s, %s, '00:00:00', '23:59:59', 'Lab RPL', 'Informatika', 6)
""", (id_matkul, hari_ini))
id_jadwal = cur.lastrowid
conn.commit()
cur.close()
conn.close()
# 3. Jalankan Client Testing
with flask_app.app.test_client() as client:
yield client, id_jadwal
# 4. Teardown: Bersihkan/Drop database testing setelah selesai
conn = flask_app.get_db_connection()
cur = conn.cursor()
cur.execute("DROP DATABASE db_absensi_test")
cur.close()
conn.close()
# ==========================================
# SKENARIO INTEGRATION TESTING
# ==========================================
def test_it001_login_berhasil_dan_jwt_terbit(setup_client):
client, _ = setup_client
response = client.post('/api/auth/login', json={
"npm": "202310715297",
"password": "Password123",
"device_id": "device_sah_01"
})
data = json.loads(response.data)
assert response.status_code == 200
assert "token" in data['data']
assert data["message"] == "Login berhasil"
def test_it002_login_ditolak_device_berbeda(setup_client):
client, _ = setup_client
response = client.post('/api/auth/login', json={
"npm": "202310715297",
"password": "Password123",
"device_id": "device_ilegal_999" # [FIX 3] Memicu device binding error
})
assert response.status_code == 403
assert "terdaftar di perangkat lain" in json.loads(response.data)["error"]
def test_it003_request_location_token_berhasil(setup_client):
client, _ = setup_client
# Login ambil token
res_login = client.post('/api/auth/login', json={"npm": "202310715297", "password": "Password123", "device_id": "device_sah_01"})
jwt_token = json.loads(res_login.data)['data']['token']
# Koordinat di-offset sedikit dari KAMPUS_LATITUDE agar lolos [FIX 8] (Anomali Koordinat Identik)
# tetapi tetap masuk dalam radius 500m
lat_valid = flask_app.KAMPUS_LATITUDE + 0.0001
lon_valid = flask_app.KAMPUS_LONGITUDE + 0.0001
res_loc = client.post('/api/absensi/request-location-token',
json={"latitude": lat_valid, "longitude": lon_valid},
headers={'Authorization': f'Bearer {jwt_token}'}
)
data_loc = json.loads(res_loc.data)
assert res_loc.status_code == 200
assert "location_token" in data_loc
assert data_loc["expires_in_seconds"] == 120
@patch('app.requests.post') # Mock eksekusi jaringan eksternal (webhook) agar tidak hit API luar
@patch('app.validasi_foto') # Mock validasi foto agar tidak perlu kirim base64 5KB+ di script test
def test_it005_e2e_absensi_berhasil(mock_validasi_foto, mock_requests_post, setup_client):
client, id_jadwal = setup_client
mock_validasi_foto.return_value = (True, "OK")
# 1. Login
res_login = client.post('/api/auth/login', json={"npm": "202310715297", "password": "Password123", "device_id": "device_sah_01"})
jwt_token = json.loads(res_login.data)['data']['token']
# 2. Minta Location Token [FIX 7]
lat_valid = flask_app.KAMPUS_LATITUDE + 0.0001
lon_valid = flask_app.KAMPUS_LONGITUDE + 0.0001
res_loc = client.post('/api/absensi/request-location-token',
json={"latitude": lat_valid, "longitude": lon_valid},
headers={'Authorization': f'Bearer {jwt_token}'}
)
loc_token = json.loads(res_loc.data)['location_token']
# 3. Submit Absensi E2E
absensi_payload = {
"location_token": loc_token,
"id_jadwal": id_jadwal,
"foto_base64": "data:image/jpeg;base64,mocked_base64_string",
"status": "HADIR"
}
res_submit = client.post('/api/absensi/submit',
json=absensi_payload,
headers={'Authorization': f'Bearer {jwt_token}'}
)
assert res_submit.status_code == 201
assert json.loads(res_submit.data)['message'] == "Absensi berhasil disimpan"

View File

@ -11,9 +11,6 @@ pluginManagement {
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {