feat: Android

This commit is contained in:
2026-06-21 14:28:07 +02:00
parent edb1d5fe82
commit af06bc2b5c
13 changed files with 879 additions and 1258 deletions
+3
View File
@@ -55,3 +55,6 @@ yarn-error.log
# Expo
.expo/*
ios/JecnaapiIOS.xcframework/
ios/framework.zip
+19
View File
@@ -1,6 +1,7 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
id 'org.jetbrains.kotlin.android'
}
group = 'cz.jzitnik.jecnaapireactnative'
@@ -8,11 +9,29 @@ version = '0.1.0'
android {
namespace "cz.jzitnik.jecnaapireactnative"
compileSdk = 36
defaultConfig {
versionCode 1
versionName "0.1.0"
minSdk = 26
}
lintOptions {
abortOnError false
}
kotlinOptions {
freeCompilerArgs += [
'-Xskip-metadata-version-check'
]
}
}
repositories {
mavenCentral()
}
dependencies {
implementation "io.github.tomhula:jecnaapi-jecna:10.3.5"
implementation "io.github.tomhula:jecnaapi-canteen:10.3.5"
implementation "com.google.code.gson:gson:2.14.0"
}
@@ -1,10 +1,222 @@
package cz.jzitnik.jecnaapireactnative
import com.google.gson.Gson
import expo.modules.kotlin.functions.Coroutine
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import io.github.tomhula.jecnaapi.CanteenClient
import io.github.tomhula.jecnaapi.JecnaClient
import io.github.tomhula.jecnaapi.data.canteen.ExchangeItem
import io.github.tomhula.jecnaapi.data.canteen.MenuItem
import io.github.tomhula.jecnaapi.data.notification.NotificationReference
import io.github.tomhula.jecnaapi.data.notification.NotificationReference.NotificationType
import io.github.tomhula.jecnaapi.util.SchoolYear
import io.github.tomhula.jecnaapi.util.SchoolYearHalf
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
class JecnaapiReactNativeModule : Module() {
private var client: JecnaClient = JecnaClient(autoLogin = true)
private var canteenClient: CanteenClient = CanteenClient(autoLogin = true)
private val gson = Gson()
override fun definition() = ModuleDefinition {
Name("JecnaapiReactNative")
// ==========================================
// JecnaClient Functions
// ==========================================
AsyncFunction("login") Coroutine { username: String, pass: String ->
return@Coroutine client.login(username, pass)
}
AsyncFunction("logout") Coroutine { ->
client.logout()
}
AsyncFunction("isLoggedIn") Coroutine { ->
return@Coroutine client.isLoggedIn()
}
AsyncFunction("getNewsPage") Coroutine { ->
val news = client.getNewsPage()
return@Coroutine gson.toJson(news)
}
AsyncFunction("getGradesPage") Coroutine { ->
val grades = client.getGradesPage()
return@Coroutine gson.toJson(grades)
}
AsyncFunction("getGradesPageByYear") Coroutine { firstCalendarYear: Int, half: String ->
val schoolYear = SchoolYear(firstCalendarYear)
val schoolYearHalf = SchoolYearHalf.valueOf(half.uppercase())
val grades = client.getGradesPage(schoolYear, schoolYearHalf)
return@Coroutine gson.toJson(grades)
}
AsyncFunction("getTimetablePage") Coroutine { ->
val timetable = client.getTimetablePage()
return@Coroutine gson.toJson(timetable)
}
AsyncFunction("getTimetablePageByYear") Coroutine { firstCalendarYear: Int, periodId: Int? ->
val schoolYear = SchoolYear(firstCalendarYear)
val timetable = client.getTimetablePage(schoolYear, periodId)
return@Coroutine gson.toJson(timetable)
}
AsyncFunction("getAttendancesPage") Coroutine { ->
val attendance = client.getAttendancesPage()
return@Coroutine gson.toJson(attendance)
}
AsyncFunction("getAttendancesPageByMonth") Coroutine { firstCalendarYear: Int, monthName: String ->
val schoolYear = SchoolYear(firstCalendarYear)
val month = Month.valueOf(monthName.uppercase())
val attendance = client.getAttendancesPage(schoolYear, month)
return@Coroutine gson.toJson(attendance)
}
AsyncFunction("getAbsencesPage") Coroutine { ->
val absences = client.getAbsencesPage()
return@Coroutine gson.toJson(absences)
}
AsyncFunction("getAbsencesPageByYear") Coroutine { firstCalendarYear: Int ->
val schoolYear = SchoolYear(firstCalendarYear)
val absences = client.getAbsencesPage(schoolYear)
return@Coroutine gson.toJson(absences)
}
AsyncFunction("getTeachersPage") Coroutine { ->
val teachers = client.getTeachersPage()
return@Coroutine gson.toJson(teachers)
}
AsyncFunction("getTeacher") Coroutine { teacherTag: String ->
val teacher = client.getTeacher(teacherTag)
return@Coroutine gson.toJson(teacher)
}
AsyncFunction("getRoomsPage") Coroutine { ->
val rooms = client.getRoomsPage()
return@Coroutine gson.toJson(rooms)
}
AsyncFunction("getRoom") Coroutine { roomCode: String ->
val room = client.getRoom(roomCode)
return@Coroutine gson.toJson(room)
}
AsyncFunction("getLocker") Coroutine { ->
val locker = client.getLocker()
return@Coroutine gson.toJson(locker)
}
AsyncFunction("getStudentProfile") Coroutine { ->
val profile = client.getStudentProfile()
return@Coroutine gson.toJson(profile)
}
AsyncFunction("getStudentProfileByUsername") Coroutine { username: String ->
val profile = client.getStudentProfile(username)
return@Coroutine gson.toJson(profile)
}
AsyncFunction("getNotifications") Coroutine { ->
val notifications = client.getNotifications()
return@Coroutine gson.toJson(notifications)
}
AsyncFunction("getNotification") Coroutine { type: String, message: String, recordId: Int ->
val notificationType = NotificationType.valueOf(type.uppercase())
val notificationRef = NotificationReference(notificationType, message, recordId)
val notification = client.getNotification(notificationRef)
return@Coroutine gson.toJson(notification)
}
AsyncFunction("getStudentCertificates") Coroutine { ->
val certificates = client.getStudentCertificates()
return@Coroutine gson.toJson(certificates)
}
AsyncFunction("getDocumentsPage") Coroutine { path: String ->
val documents = client.getDocumentsPage(path)
return@Coroutine gson.toJson(documents)
}
AsyncFunction("getDocumentsPageDefault") Coroutine { ->
val documents = client.getDocumentsPage()
return@Coroutine gson.toJson(documents)
}
// ==========================================
// CanteenClient Functions
// ==========================================
AsyncFunction("canteenLogin") Coroutine { username: String, pass: String ->
return@Coroutine canteenClient.login(username, pass)
}
AsyncFunction("canteenLogout") Coroutine { ->
canteenClient.logout()
}
AsyncFunction("canteenIsLoggedIn") Coroutine { ->
return@Coroutine canteenClient.isLoggedIn()
}
AsyncFunction("canteenGetMenuPage") Coroutine { ->
val menuPage = canteenClient.getMenuPage()
return@Coroutine gson.toJson(menuPage)
}
AsyncFunction("canteenGetMenuAsync") Coroutine { daysStrings: List<String> ->
val localDates = daysStrings.map { LocalDate.parse(it) }
val dayMenusList = mutableListOf<io.github.tomhula.jecnaapi.data.canteen.DayMenu>()
canteenClient.getMenuAsync(localDates).collect { dayMenu ->
dayMenusList.add(dayMenu)
}
return@Coroutine gson.toJson(dayMenusList)
}
AsyncFunction("canteenGetDayMenu") Coroutine { dayString: String ->
val day = LocalDate.parse(dayString)
val dayMenu = canteenClient.getDayMenu(day)
return@Coroutine gson.toJson(dayMenu)
}
AsyncFunction("canteenGetExchange") Coroutine { ->
val exchange = canteenClient.getExchange()
return@Coroutine gson.toJson(exchange)
}
AsyncFunction("canteenGetCredit") Coroutine { ->
return@Coroutine canteenClient.getCredit()
}
AsyncFunction("canteenOrderMenuItem") Coroutine { menuItemJson: String ->
val menuItem = gson.fromJson(menuItemJson, MenuItem::class.java)
return@Coroutine canteenClient.order(menuItem)
}
AsyncFunction("canteenOrderExchangeItem") Coroutine { number: Int, orderPath: String, dayString: String, amount: Int ->
val day = LocalDate.parse(dayString)
val exchangeItem = ExchangeItem(
number = number,
description = null,
amount = amount,
orderPath = orderPath,
day = day
)
return@Coroutine canteenClient.order(exchangeItem)
}
AsyncFunction("canteenPutOnExchange") Coroutine { menuItemJson: String ->
val menuItem = gson.fromJson(menuItemJson, MenuItem::class.java)
return@Coroutine canteenClient.putOnExchange(menuItem)
}
}
}
+101 -20
View File
@@ -1,28 +1,109 @@
import { Button, SafeAreaView, ScrollView, Text, View } from 'react-native';
import { useState } from 'react';
import { StyleSheet, Text, View, TextInput, Button, ScrollView, ActivityIndicator } from 'react-native';
import JecnaapiReactNative from 'jecnaapi-react-native';
export default function App() {
return (
<SafeAreaView style={styles.container}>
<ScrollView style={styles.container}>
<Text style={styles.header}>Module API Example</Text>
</ScrollView>
</SafeAreaView>
);
}
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<string>('No data yet. Please login.');
const handleTestLoginAndFetch = async () => {
if (!username || !password) {
setResult('Error: Enter username and password');
return;
}
setLoading(true);
setResult('Logging in...');
try {
// 1. Call the raw native Kotlin bridge
const loginSuccess = await JecnaapiReactNative.login(username, password);
if (loginSuccess) {
setResult('Login Successful! Fetching profile...');
// 2. Fetch the raw JSON string
const rawProfileString = await JecnaapiReactNative.getStudentProfile();
// 3. Manually parse it (since you kept the default export)
const profile = JSON.parse(rawProfileString);
// Display it nicely on screen
setResult(JSON.stringify(profile, null, 2));
} else {
setResult('Login failed. Check credentials.');
}
} catch (error: any) {
setResult(`Bridge Error: ${error.message}`);
} finally {
setLoading(false);
}
};
function Group(props: { name: string; children: React.ReactNode }) {
return (
<View style={styles.group}>
<Text style={styles.groupHeader}>{props.name}</Text>
{props.children}
<View style={styles.container}>
<Text style={styles.header}>JecnaAPI Native Tester</Text>
<TextInput
style={styles.input}
placeholder="Username"
value={username}
onChangeText={setUsername}
autoCapitalize="none"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<Button title="Test Login & Fetch Profile" onPress={handleTestLoginAndFetch} disabled={loading} />
{loading && <ActivityIndicator style={{ marginTop: 20 }} size="large" color="#0000ff" />}
<ScrollView style={styles.resultBox}>
<Text style={styles.resultText}>{result}</Text>
</ScrollView>
</View>
);
}
const styles = {
header: { fontSize: 30, margin: 20 },
groupHeader: { fontSize: 20, marginBottom: 20 },
group: { margin: 20, backgroundColor: '#fff', borderRadius: 10, padding: 20 },
container: { flex: 1, backgroundColor: '#eee' },
view: { flex: 1, height: 200 },
};
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
paddingTop: 80,
backgroundColor: '#F5FCFF',
},
header: {
fontSize: 22,
fontWeight: 'bold',
marginBottom: 20,
textAlign: 'center',
},
input: {
height: 50,
borderColor: '#ccc',
borderWidth: 1,
borderRadius: 8,
marginBottom: 15,
paddingHorizontal: 15,
backgroundColor: '#fff',
},
resultBox: {
marginTop: 20,
flex: 1,
backgroundColor: '#1e1e1e',
borderRadius: 8,
padding: 15,
},
resultText: {
color: '#00FF00',
fontFamily: 'monospace',
fontSize: 12,
},
});
+10
View File
@@ -22,6 +22,16 @@
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
[
"expo-build-properties",
{
"android": {
"minSdkVersion": 26
}
}
]
]
}
}
+15
View File
@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"expo": "~56.0.12",
"expo-build-properties": "~56.0.19",
"react": "19.2.3",
"react-native": "0.85.3"
},
@@ -2625,6 +2626,20 @@
}
}
},
"node_modules/expo-build-properties": {
"version": "56.0.19",
"resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-56.0.19.tgz",
"integrity": "sha512-InoviXcxWosNp4cC7L3SWoiY99Xr2HdgN+LYHb6mUm/BBVxy1mIMrZR+3PJ2gwDZzW6EJNDz8ioASWGHBTmzpA==",
"license": "MIT",
"dependencies": {
"@expo/schema-utils": "^56.0.0",
"resolve-from": "^5.0.0",
"semver": "^7.6.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-modules-autolinking": {
"version": "56.0.16",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.16.tgz",
+1
View File
@@ -4,6 +4,7 @@
"main": "index.ts",
"dependencies": {
"expo": "~56.0.12",
"expo-build-properties": "~56.0.19",
"react": "19.2.3",
"react-native": "0.85.3"
},
+187 -1224
View File
File diff suppressed because it is too large Load Diff
+13 -4
View File
@@ -11,7 +11,8 @@
"test": "node internal/module_scripts/test.js",
"prepare": "node internal/module_scripts/prepare.js",
"open:ios": "node internal/module_scripts/open-ios.js",
"open:android": "node internal/module_scripts/open-android.js"
"open:android": "node internal/module_scripts/open-android.js",
"postinstall": "node scripts/download-ios.mjs"
},
"keywords": [
"react-native",
@@ -31,14 +32,14 @@
"@babel/core": "^7.26.0",
"@types/jest": "^29.2.1",
"@types/react": "~19.1.1",
"babel-preset-expo": "~55.0.8",
"babel-preset-expo": "~56.0.8",
"eslint": "~9.39.4",
"eslint-config-universe": "^15.0.3",
"expo": "^56.0.11",
"jest": "^29.7.0",
"jest-expo": "~55.0.9",
"prettier": "^3.0.0",
"react-native": "0.82.1",
"react-native": "0.85.3",
"typescript": "^5.9.2"
},
"jest": {
@@ -51,5 +52,13 @@
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"files": [
"build",
"android",
"ios",
"scripts",
"src/web",
"jecnaapi-react-native.podspec"
]
}
+52
View File
@@ -0,0 +1,52 @@
import { execSync } from 'child_process';
import fs from 'fs';
import https from 'https';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const url =
'https://github.com/tomhula/JecnaAPI/releases/download/v10.3.5/JecnaapiIOS-v10.3.5-xcframework.zip';
const iosDir = path.join(__dirname, '..', 'ios');
const zipPath = path.join(iosDir, 'framework.zip');
if (fs.existsSync(path.join(iosDir, 'JecnaapiIOS.xcframework'))) {
console.log('JecnaapiIOS XCFramework already exists.');
process.exit(0);
}
console.log('Downloading JecnaapiIOS XCFramework...');
function download(url, dest) {
return new Promise((resolve, reject) => {
https
.get(url, (res) => {
if (res.statusCode === 301 || res.statusCode === 302) {
return download(res.headers.location, dest).then(resolve).catch(reject);
}
if (res.statusCode !== 200) {
return reject(new Error(`Server responded with status code ${res.statusCode}`));
}
const file = fs.createWriteStream(dest);
res.pipe(file);
file.on('finish', () => {
file.close(resolve);
});
})
.on('error', reject);
});
}
download(url, zipPath)
.then(() => {
console.log('Unzipping framework...');
execSync(`unzip -o -q "${zipPath}" -d "${iosDir}"`);
fs.unlinkSync(zipPath);
console.log('iOS framework successfully linked.');
})
.catch((err) => {
console.error('Failed to download framework:', err);
process.exit(1);
});
+143 -1
View File
@@ -1 +1,143 @@
// Define your exported module types here.
export type JecnaapiReactNativeModuleEvents = {};
export type SchoolYearHalf = 'FIRST' | 'SECOND';
export type MonthName =
| 'JANUARY'
| 'FEBRUARY'
| 'MARCH'
| 'APRIL'
| 'MAY'
| 'JUNE'
| 'JULY'
| 'AUGUST'
| 'SEPTEMBER'
| 'OCTOBER'
| 'NOVEMBER'
| 'DECEMBER';
export type NotificationType = 'GRADE' | 'ABSENCE' | 'MESSAGE' | string;
// School portal types
export interface StudentProfile {
userName?: string;
firstName?: string;
lastName?: string;
className?: string;
email?: string;
}
export interface News {
title: string;
content: string;
date: string;
author?: string;
}
export interface Grade {
subject: string;
value: string;
weight?: number;
date: string;
teacher?: string;
note?: string;
}
export interface SubjectGrades {
subject: string;
average?: number;
grades: Grade[];
}
export interface TimetableEntry {
subject: string;
teacher?: string;
room?: string;
dayOfWeek?: number;
hour?: number;
parity?: string;
}
export interface AttendanceRecord {
subject: string;
date: string;
status: string;
excused?: boolean;
}
export interface AbsenceRecord {
subject: string;
date: string;
hours: number;
excused: boolean;
note?: string;
}
export interface Teacher {
name: string;
tag: string;
email?: string;
}
export interface Room {
code: string;
name?: string;
capacity?: number;
}
export interface Locker {
number?: string;
location?: string;
validFrom?: string;
validTo?: string;
}
export interface Notification {
type: string;
message: string;
recordId: number;
date?: string;
read?: boolean;
}
export interface Certificate {
title: string;
date: string;
issuer?: string;
}
export interface Document {
name: string;
path: string;
isDirectory: boolean;
size?: number;
lastModified?: string;
}
// Canteen types
export interface MenuItem {
id?: number;
name: string;
price?: number;
allergens?: string[];
mealType?: string;
}
export interface DayMenu {
date: string;
items: MenuItem[];
}
export interface ExchangeItem {
number: number;
orderPath: string;
day: string;
amount: number;
description?: string;
}
export interface CreditBalance {
balance: number;
}
+68 -1
View File
@@ -1,5 +1,72 @@
import { NativeModule, requireNativeModule } from 'expo';
declare class JecnaapiReactNativeModule extends NativeModule<{}> {}
import { JecnaapiReactNativeModuleEvents } from './JecnaapiReactNative.types';
declare class JecnaapiReactNativeModule extends NativeModule<JecnaapiReactNativeModuleEvents> {
// ====================
// JecnaClient (school portal)
// ====================
login: (username: string, password: string) => Promise<boolean>;
logout: () => Promise<void>;
isLoggedIn: () => Promise<boolean>;
getNewsPage: () => Promise<string>;
getGradesPage: () => Promise<string>;
getGradesPageByYear: (firstCalendarYear: number, half: string) => Promise<string>;
getTimetablePage: () => Promise<string>;
getTimetablePageByYear: (firstCalendarYear: number, periodId: number | null) => Promise<string>;
getAttendancesPage: () => Promise<string>;
getAttendancesPageByMonth: (firstCalendarYear: number, monthName: string) => Promise<string>;
getAbsencesPage: () => Promise<string>;
getAbsencesPageByYear: (firstCalendarYear: number) => Promise<string>;
getTeachersPage: () => Promise<string>;
getTeacher: (teacherTag: string) => Promise<string>;
getRoomsPage: () => Promise<string>;
getRoom: (roomCode: string) => Promise<string>;
getLocker: () => Promise<string>;
getStudentProfile: () => Promise<string>;
getStudentProfileByUsername: (username: string) => Promise<string>;
getNotifications: () => Promise<string>;
getNotification: (type: string, message: string, recordId: number) => Promise<string>;
getStudentCertificates: () => Promise<string>;
getDocumentsPage: (path: string) => Promise<string>;
getDocumentsPageDefault: () => Promise<string>;
// ====================
// CanteenClient
// ====================
canteenLogin: (username: string, password: string) => Promise<boolean>;
canteenLogout: () => Promise<void>;
canteenIsLoggedIn: () => Promise<boolean>;
canteenGetMenuPage: () => Promise<string>;
canteenGetMenuAsync: (daysStrings: string[]) => Promise<string>;
canteenGetDayMenu: (dayString: string) => Promise<string>;
canteenGetExchange: () => Promise<string>;
canteenGetCredit: () => Promise<number>;
canteenOrderMenuItem: (menuItemJson: string) => Promise<boolean>;
canteenOrderExchangeItem: (
number: number,
orderPath: string,
dayString: string,
amount: number
) => Promise<boolean>;
canteenPutOnExchange: (menuItemJson: string) => Promise<boolean>;
}
export default requireNativeModule<JecnaapiReactNativeModule>('JecnaapiReactNative');
+48 -1
View File
@@ -1,5 +1,52 @@
import { registerWebModule, NativeModule } from 'expo';
class JecnaapiReactNativeModule extends NativeModule<{}> {}
function unavailable(): never {
throw new Error('JecnaapiReactNative is not available on web');
}
class JecnaapiReactNativeModule extends NativeModule<{}> {
login = (_username: string, _password: string) => unavailable();
logout = () => unavailable();
isLoggedIn = () => unavailable();
getNewsPage = () => unavailable();
getGradesPage = () => unavailable();
getGradesPageByYear = (_firstCalendarYear: number, _half: string) => unavailable();
getTimetablePage = () => unavailable();
getTimetablePageByYear = (_firstCalendarYear: number, _periodId: number | null) => unavailable();
getAttendancesPage = () => unavailable();
getAttendancesPageByMonth = (_firstCalendarYear: number, _monthName: string) => unavailable();
getAbsencesPage = () => unavailable();
getAbsencesPageByYear = (_firstCalendarYear: number) => unavailable();
getTeachersPage = () => unavailable();
getTeacher = (_teacherTag: string) => unavailable();
getRoomsPage = () => unavailable();
getRoom = (_roomCode: string) => unavailable();
getLocker = () => unavailable();
getStudentProfile = () => unavailable();
getStudentProfileByUsername = (_username: string) => unavailable();
getNotifications = () => unavailable();
getNotification = (_type: string, _message: string, _recordId: number) => unavailable();
getStudentCertificates = () => unavailable();
getDocumentsPage = (_path: string) => unavailable();
getDocumentsPageDefault = () => unavailable();
canteenLogin = (_username: string, _password: string) => unavailable();
canteenLogout = () => unavailable();
canteenIsLoggedIn = () => unavailable();
canteenGetMenuPage = () => unavailable();
canteenGetMenuAsync = (_daysStrings: string[]) => unavailable();
canteenGetDayMenu = (_dayString: string) => unavailable();
canteenGetExchange = () => unavailable();
canteenGetCredit = () => unavailable();
canteenOrderMenuItem = (_menuItemJson: string) => unavailable();
canteenOrderExchangeItem = (
_number: number,
_orderPath: string,
_dayString: string,
_amount: number
) => unavailable();
canteenPutOnExchange = (_menuItemJson: string) => unavailable();
}
export default registerWebModule(JecnaapiReactNativeModule, 'JecnaapiReactNativeModule');