Compare commits

...

10 Commits

Author SHA1 Message Date
Fadhlul Wafi
21cb4efc2a first commit 2026-01-13 22:21:02 +07:00
926d3e0a14 add n8n workflow script 2026-01-13 14:37:01 +07:00
cddaf87d88 update readme 2026-01-13 13:59:42 +07:00
c9cc99baa2 update readme 2026-01-13 09:50:58 +07:00
2a00b834c7 real time location 2026-01-13 09:37:52 +07:00
4d7fc844e2 real time location 2026-01-13 09:37:39 +07:00
d4d1b27209 real time location 2026-01-13 09:34:37 +07:00
3e66ebcf9e mockup 2026-01-12 22:07:16 +07:00
46b74d7099 mockup 2026-01-12 15:32:34 +07:00
cbe7e50b96 update readme 2026-01-12 15:28:48 +07:00
11 changed files with 710 additions and 25 deletions

View File

@ -4,6 +4,14 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-01-13T05:20:56.137492Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=RR8TA08RD8Z" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>

13
.idea/deviceManager.xml generated Normal file
View File

@ -0,0 +1,13 @@
<?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>

1
.idea/gradle.xml generated
View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>

1
.idea/misc.xml generated
View File

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

6
.idea/studiobot.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedIn" />
</component>
</project>

BIN
Mockup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 KiB

View File

@ -1,3 +1,95 @@
# Aplikasi Sistem Akademik Mobile # 📱 Aplikasi Absensi Akademik Berbasis Koordinat dan Foto (Mobile)
Untuk ## 📌 Deskripsi Proyek
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**.
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 **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
- 🔐 **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 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**
---
## 📸 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
- **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:
- `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.
## Pengecekan:
- https://ntfy.ubharajaya.ac.id/EAS
- https://docs.google.com/spreadsheets/d/1jH15MfnNgpPGuGeid0hYfY7fFUHCEFbCmg8afTyyLZs/edit?gid=0#gid=0
## 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

@ -42,14 +42,20 @@ android {
} }
dependencies { dependencies {
implementation("com.google.android.gms:play-services-location:21.0.1")
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation("androidx.activity:activity-compose:1.9.0")
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation("androidx.compose.material:material-icons-extended:1.6.7")
// Location (GPS)
implementation("com.google.android.gms:play-services-location:21.0.1")
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

View File

@ -1,6 +1,17 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<application <application
android:allowBackup="true" android:allowBackup="true"

View File

@ -1,47 +1,371 @@
package id.ac.ubharajaya.sistemakademik package id.ac.ubharajaya.sistemakademik
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.location.Location
import android.os.Bundle import android.os.Bundle
import android.provider.MediaStore
import android.util.Base64
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.Image
import androidx.compose.material3.Scaffold import androidx.compose.foundation.background
import androidx.compose.material3.Text import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.google.android.gms.location.LocationServices
import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme import id.ac.ubharajaya.sistemakademik.ui.theme.SistemAkademikTheme
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.*
import kotlin.concurrent.thread
// =============== MAIN ACTIVITY & NAVIGATION ===============
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
SistemAkademikTheme { SistemAkademikTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> AppNavigation(activity = this)
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
} }
} }
} }
} }
@Composable @Composable
fun Greeting(name: String, modifier: Modifier = Modifier) { fun AppNavigation(activity: ComponentActivity) {
Text( var currentScreen by remember { mutableStateOf("login") }
text = "Hello $name!", var npm by remember { mutableStateOf("") }
modifier = modifier var nama by remember { mutableStateOf("") }
) var loginTime by remember { mutableStateOf(0L) }
}
@Preview(showBackground = true) when (currentScreen) {
@Composable "login" -> LoginScreen(onLoginSuccess = { loggedInNpm, loggedInNama, time ->
fun GreetingPreview() { npm = loggedInNpm
SistemAkademikTheme { nama = loggedInNama
Greeting("Android") loginTime = time
currentScreen = "menu"
})
"menu" -> MenuScreen(
nama = nama,
npm = npm,
onAbsenClick = { currentScreen = "absensi" },
onRiwayatClick = { currentScreen = "riwayat" },
onLogout = {
currentScreen = "login"
npm = ""
nama = ""
}
)
"absensi" -> AbsensiScreen(activity = activity, npm = npm, nama = nama, loginTime = loginTime, onNavigateBack = {
currentScreen = "menu"
})
"riwayat" -> RiwayatScreen(onNavigateBack = { currentScreen = "menu" })
} }
} }
// =============== LOGIN SCREEN ===============
@Composable
fun LoginScreen(onLoginSuccess: (npm: String, nama: String, loginTime: Long) -> Unit) {
var npm by remember { mutableStateOf("") }
var nama by remember { mutableStateOf("") }
val context = LocalContext.current
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Login Absensi", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(32.dp))
OutlinedTextField(value = npm, onValueChange = { npm = it }, label = { Text("NPM") }, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
Spacer(Modifier.height(16.dp))
OutlinedTextField(value = nama, onValueChange = { nama = it }, label = { Text("Nama Lengkap") }, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text))
Spacer(Modifier.height(32.dp))
Button(
onClick = {
if (npm.isNotBlank() && nama.isNotBlank()) {
onLoginSuccess(npm, nama, System.currentTimeMillis())
} else {
Toast.makeText(context, "NPM dan Nama tidak boleh kosong", Toast.LENGTH_SHORT).show()
}
},
modifier = Modifier.fillMaxWidth().height(50.dp)
) {
Text("LOGIN")
}
}
}
// =============== MENU SCREEN ===============
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MenuScreen(nama: String, npm: String, onAbsenClick: () -> Unit, onRiwayatClick: () -> Unit, onLogout: () -> Unit) {
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Menu Utama", color = Color.White) },
navigationIcon = { IconButton(onClick = onLogout) { Icon(Icons.Default.ArrowBack, "Logout", tint = Color.White) } },
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color(0xFF388E3C))
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(16.dp))
Card(modifier = Modifier.fillMaxWidth(), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Selamat Datang,", style = MaterialTheme.typography.bodyLarge, color = Color.Gray)
Spacer(modifier = Modifier.height(4.dp))
Text(nama, fontWeight = FontWeight.Bold, fontSize = 22.sp)
Text("($npm)", fontSize = 16.sp, color = Color.Gray)
}
}
Spacer(modifier = Modifier.height(32.dp))
MenuButton(text = "Absen Kehadiran", icon = Icons.Filled.CameraAlt, onClick = onAbsenClick)
Spacer(modifier = Modifier.height(16.dp))
MenuButton(text = "Riwayat Absensi", icon = Icons.Filled.History, onClick = onRiwayatClick)
}
}
}
@Composable
fun MenuButton(text: String, icon: ImageVector, onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier.fillMaxWidth().height(55.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, contentDescription = null, modifier = Modifier.size(24.dp))
Spacer(Modifier.width(12.dp))
Text(text, fontSize = 16.sp)
}
}
}
// =============== RIWAYAT SCREEN (PLACEHOLDER) ===============
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RiwayatScreen(onNavigateBack: () -> Unit) {
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Riwayat Absensi", color = Color.White) },
navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "Kembali", tint = Color.White) } },
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color(0xFF388E3C))
)
}
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding).fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Halaman Riwayat Absensi (Segera Hadir)")
}
}
}
// =============== ABSENSI SCREEN ===============
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AbsensiScreen(activity: ComponentActivity, npm: String, nama: String, loginTime: Long, onNavigateBack: () -> Unit) {
val context = LocalContext.current
var lokasiStatus by remember { mutableStateOf("Mengecek izin lokasi...") }
var isLocationReady by remember { mutableStateOf(false) }
var foto by remember { mutableStateOf<Bitmap?>(null) }
var isUploading by remember { mutableStateOf(false) }
var userLocation by remember { mutableStateOf<Location?>(null) }
var mataKuliah by remember { mutableStateOf("") }
val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val bitmap = result.data?.extras?.get("data") as? Bitmap
if (bitmap != null) foto = bitmap
}
}
val requestPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true) {
lokasiStatus = "Mendapatkan koordinat..."
try {
fusedLocationClient.lastLocation.addOnSuccessListener { loc ->
if (loc != null) {
userLocation = loc
isLocationReady = true
lokasiStatus = "Lokasi berhasil didapatkan"
} else {
isLocationReady = false
lokasiStatus = "Gagal mendapatkan lokasi. Pastikan GPS aktif."
}
}
} catch (e: SecurityException) {
isLocationReady = false
lokasiStatus = "Izin lokasi dicabut."
}
} else {
lokasiStatus = "Izin lokasi ditolak."
}
}
LaunchedEffect(Unit) { requestPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.CAMERA)) }
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Absen Kehadiran", color = Color.White) },
navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "Kembali", tint = Color.White) } },
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color(0xFF388E3C))
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedTextField(
value = mataKuliah,
onValueChange = { mataKuliah = it },
label = { Text("Mata Kuliah") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(if (isLocationReady) Icons.Filled.CheckCircle else Icons.Filled.Close, "Status Lokasi", tint = if (isLocationReady) Color(0xFF2E7D32) else Color.Red, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(lokasiStatus, color = if (isLocationReady) Color.DarkGray else Color.Red, fontSize = 14.sp)
}
Spacer(Modifier.height(16.dp))
Box(Modifier.size(200.dp).clip(CircleShape).background(Color.LightGray), contentAlignment = Alignment.Center) {
if (foto != null) {
Image(foto!!.asImageBitmap(), "Foto Selfie", Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
} else {
Icon(Icons.Filled.Person, "Placeholder", modifier = Modifier.size(70.dp), tint = Color.Gray)
}
}
Spacer(Modifier.height(24.dp))
Button(
onClick = { cameraLauncher.launch(Intent(MediaStore.ACTION_IMAGE_CAPTURE)) },
enabled = isLocationReady, // Button enabled only when location is ready
modifier = Modifier.fillMaxWidth(0.8f).height(50.dp)
) {
Text("1. AMBIL FOTO")
}
Spacer(Modifier.height(16.dp))
if (isLocationReady && foto != null && mataKuliah.isNotBlank()) {
if (isUploading) {
CircularProgressIndicator()
} else {
Button(
onClick = {
isUploading = true
kirimKeServer(activity, npm, nama, userLocation?.latitude ?: 0.0, userLocation?.longitude ?: 0.0, foto!!, loginTime, mataKuliah) { success ->
if (success) {
Toast.makeText(activity, "Absensi Berhasil!", Toast.LENGTH_LONG).show()
onNavigateBack()
} else {
isUploading = false
}
}
},
modifier = Modifier.fillMaxWidth(0.8f).height(50.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1B5E20))
) {
Text("2. KIRIM ABSENSI")
}
}
}
}
}
}
// =============== NETWORK & UTILS ===============
fun kirimKeServer(activity: Activity, npm: String, nama: String, lat: Double, lon: Double, img: Bitmap, loginTime: Long, mataKuliah: String, onFinish: (Boolean) -> Unit) {
thread {
var success = false
try {
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json")
conn.doOutput = true
val json = JSONObject().apply {
put("npm", npm)
put("nama", nama)
put("latitude", lat)
put("longitude", lon)
put("timestamp", loginTime)
put("mata_kuliah", mataKuliah)
put("foto_base64", bitmapToBase64(img))
}
conn.outputStream.write(json.toString().toByteArray())
val code = conn.responseCode
success = code == 200
if (!success) {
activity.runOnUiThread {
Toast.makeText(activity, "Gagal mengirim absensi. Kode: $code", Toast.LENGTH_LONG).show()
}
}
} catch (e: Exception) {
e.printStackTrace()
activity.runOnUiThread {
Toast.makeText(activity, "Error: Gagal terhubung ke server.", Toast.LENGTH_SHORT).show()
}
} finally {
activity.runOnUiThread { onFinish(success) }
}
}
}
fun bitmapToBase64(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
}

225
n8n-workflow-EAS.json Normal file
View File

@ -0,0 +1,225 @@
{
"name": "EAS",
"nodes": [
{
"parameters": {
"method": "POST",
"url": "https://ntfy.ubharajaya.ac.id/EAS",
"sendBody": true,
"contentType": "raw",
"body": "=Absensi: {{ $json.body.nama }} NPM: {{ $json.body.npm }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
-272,
-240
],
"id": "83504eec-6d20-46d7-9ea1-509ae4ee8660",
"name": "NTFY HTTP Request"
},
{
"parameters": {
"httpMethod": "POST",
"path": "23c6993d-1792-48fb-ad1c-ffc78a3e6254",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
-864,
-112
],
"id": "9ed3d2db-2d50-40b5-8408-7404edd48442",
"name": "Webhook Absensi",
"webhookId": "23c6993d-1792-48fb-ad1c-ffc78a3e6254"
},
{
"parameters": {
"operation": "append",
"documentId": {
"__rl": true,
"value": "1jH15MfnNgpPGuGeid0hYfY7fFUHCEFbCmg8afTyyLZs",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "Absensi",
"mode": "name"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"latitude": "={{ $json.body.latitude }}",
"longitude": "={{ $json.body.longitude }}",
"timestamp": "={{ $json.body.timestamp }}",
"foto_base64": "={{ $json.body.foto_base64 }}",
"nama": "={{ $json.body.nama }}",
"npm": "={{ $json.body.npm }}"
},
"matchingColumns": [],
"schema": [
{
"id": "timestamp",
"displayName": "timestamp",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "npm",
"displayName": "npm",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "nama",
"displayName": "nama",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "latitude",
"displayName": "latitude",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "longitude",
"displayName": "longitude",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "photo",
"displayName": "photo",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "status",
"displayName": "status",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "foto_base64",
"displayName": "foto_base64",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
}
],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
-272,
-32
],
"id": "cd83a9fa-ea00-4a20-aa31-846bfe044aeb",
"name": "Append row in sheet",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "hNVNhkTQbqkJ3C56",
"name": "Google Sheets account"
}
}
},
{
"parameters": {
"jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1;\n}\n\nreturn $input.all();"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-528,
-240
],
"id": "4ed9edf6-4562-41b6-afd0-89c96991454a",
"name": "Code in JavaScript"
}
],
"pinData": {},
"connections": {
"Webhook Absensi": {
"main": [
[
{
"node": "Append row in sheet",
"type": "main",
"index": 0
},
{
"node": "Code in JavaScript",
"type": "main",
"index": 0
}
]
]
},
"NTFY HTTP Request": {
"main": [
[]
]
},
"Code in JavaScript": {
"main": [
[
{
"node": "NTFY HTTP Request",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1",
"availableInMCP": false
},
"versionId": "49466b31-67ce-49b7-af37-33cd28d7092d",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "b8ffac81bb85d267c3296e074b3e692ecef11caeef79fa72af892085548f350a"
},
"id": "E_gxZpNrN3G5ibejHcTFS",
"tags": []
}