9 Commits

Author SHA1 Message Date
ksan 45e57181a9 fix push notification delivery on android
CI/CD / Backend Unit Tests (push) Successful in 2m16s
CI/CD / Deploy (push) Successful in 2m6s
CI/CD / Mobile Release (push) Successful in 22m0s
2026-07-30 15:28:12 +02:00
ksan ce01ab763c added app icon and readme
CI/CD / Backend Unit Tests (push) Successful in 3m16s
CI/CD / Deploy (push) Successful in 2m45s
CI/CD / Mobile Release (push) Has been cancelled
2026-07-30 15:12:17 +02:00
ksan dcf65840c0 fixing wrong directory in ci
CI/CD / Backend Unit Tests (push) Successful in 2m30s
CI/CD / Deploy (push) Successful in 2m7s
CI/CD / Mobile Release (push) Successful in 21m43s
2026-06-11 17:54:12 +02:00
ksan 194d598d6d fixing spelling mistake
CI/CD / Backend Unit Tests (push) Successful in 2m13s
CI/CD / Deploy (push) Successful in 2m16s
CI/CD / Mobile Release (push) Failing after 21m26s
2026-06-11 17:23:21 +02:00
ksan d76434014f aaa
CI/CD / Backend Unit Tests (push) Successful in 2m8s
CI/CD / Deploy (push) Successful in 2m7s
CI/CD / Mobile Release (push) Failing after 21m35s
2026-06-11 16:51:29 +02:00
ksan 266802088a fixing
CI/CD / Backend Unit Tests (push) Successful in 2m8s
CI/CD / Deploy (push) Successful in 2m9s
CI/CD / Mobile Release (push) Has been cancelled
2026-06-11 16:43:38 +02:00
ksan 6c98e4a469 updated ci file
CI/CD / Backend Unit Tests (push) Successful in 2m12s
CI/CD / Deploy (push) Successful in 2m6s
CI/CD / Mobile Release (push) Failing after 1m12s
2026-06-11 16:33:16 +02:00
ksan 5d2898dd13 fixing error
CI/CD / Backend Unit Tests (push) Successful in 2m20s
CI/CD / Deploy (push) Successful in 2m8s
CI/CD / Mobile Release (push) Failing after 25m30s
2026-06-11 15:47:48 +02:00
ksan 694b68cbc5 updated ci/cd to add releases and testing claude code on my project
CI/CD / Backend Unit Tests (push) Successful in 2m11s
CI/CD / Deploy (push) Successful in 2m0s
CI/CD / Mobile Release (push) Failing after 21m39s
2026-06-11 15:11:09 +02:00
18 changed files with 223 additions and 299 deletions
+114
View File
@@ -82,3 +82,117 @@ jobs:
ssh -i ~/.ssh/key \ ssh -i ~/.ssh/key \
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"cd ~/programs/etf-oglasi-server && docker compose pull && docker compose up -d" "cd ~/programs/etf-oglasi-server && docker compose pull && docker compose up -d"
mobile-release:
name: Mobile Release
needs: backend-unit-tests
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ env.JAVA_VERSION }}
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Accept Android SDK licenses
run: yes | sdkmanager --licenses > /dev/null || true
- name: Determine next mobile version
id: version
run: |
git fetch --tags
LAST=$(git tag -l 'mobile-v*' | sed 's/mobile-v//' | sort -n | tail -1)
NEXT=$(( ${LAST:-0} + 1 ))
echo "next=$NEXT" >> "$GITHUB_OUTPUT"
echo "tag=mobile-v$NEXT" >> "$GITHUB_OUTPUT"
- name: Stamp app version
run: node scripts/set-mobile-version.js ${{ steps.version.outputs.next }}
- name: Restore Firebase config
run: echo "${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}" | base64 -d > google-services.json
- name: Restore env
run: echo "EXPO_PUBLIC_API_URL=${{ secrets.EXPO_PUBLIC_API_URL }}" > .env
- name: Restore signing keystore
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > ksan.dev.keystore
- name: Install dependencies
run: npm ci
- name: Prebuild Android project
run: npx expo prebuild -p android --no-install
- name: Increase Gradle memory
run: |
echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> android/gradle.properties
- name: Build signed release APK
working-directory: frontend/android
run: |
./gradlew assembleRelease --no-daemon \
-Pandroid.injected.signing.store.file="$PWD/../ksan.dev.keystore" \
-Pandroid.injected.signing.store.password="${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" \
-Pandroid.injected.signing.key.alias="${{ secrets.ANDROID_KEY_ALIAS }}" \
-Pandroid.injected.signing.key.password="${{ secrets.ANDROID_KEY_PASSWORD }}"
- name: Locate APK
id: apk
run: |
APK=$(find android/app/build/outputs/apk/release -name "*.apk" | head -n1)
OUT="etfoglasi-${{ steps.version.outputs.tag }}.apk"
cp "$APK" "$OUT"
echo "path=$OUT" >> "$GITHUB_OUTPUT"
echo "name=$OUT" >> "$GITHUB_OUTPUT"
- name: Create Gitea release
run: |
API="https://git.${{ secrets.DOMAIN }}/api/v1/repos/${{ secrets.REGISTRY_USER }}/etf-oglasi"
AUTH="Authorization: token ${{ secrets.GITEARELEASES_TOKEN }}"
HTTP_CODE=$(curl -s -o release.json -w "%{http_code}" -X POST \
-H "$AUTH" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${{ steps.version.outputs.tag }}\",\"target_commitish\":\"${{ github.sha }}\",\"name\":\"${{ steps.version.outputs.tag }}\",\"draft\":false,\"prerelease\":false}" \
"$API/releases")
echo "HTTP $HTTP_CODE"
cat release.json
if [ "$HTTP_CODE" != "201" ]; then
echo "Failed to create release"
exit 1
fi
RELEASE_ID=$(node -pe "JSON.parse(require('fs').readFileSync('release.json','utf8')).id")
HTTP_CODE=$(curl -s -o asset.json -w "%{http_code}" -X POST \
-H "$AUTH" \
-F "attachment=@${{ steps.apk.outputs.path }}" \
"$API/releases/$RELEASE_ID/assets?name=${{ steps.apk.outputs.name }}")
echo "HTTP $HTTP_CODE"
cat asset.json
if [ "$HTTP_CODE" != "201" ]; then
echo "Failed to upload asset"
exit 1
fi
+31 -75
View File
@@ -1,91 +1,47 @@
# Full-Stack Learning Project # ETF Oglasi
This repository is a full-stack learning project focused on building and understanding modern application development practices end-to-end. <img src="logo.png" alt="ETF Oglasi logo" width="150" />
It demonstrates how to design, build, and deploy a real-world application using a backend API, mobile frontend, database integration, and CI/CD workflows. A hobby full-stack project I built to learn modern app development end to end.
--- It scrapes the announcement boards of the Faculty of Electrical Engineering,
University of Banja Luka, stores the announcements in PostgreSQL, and lets
students browse and subscribe to subjects from a mobile app. Subscribers get
push notifications when new announcements are posted.
## Tech Stack ## Tech Stack
### Backend - **Backend:** Spring Boot 3.5 (Java 21), REST API, PostgreSQL
- Spring Boot - **Frontend:** React Native / Expo (Expo Router, NativeWind)
- RESTful API architecture - **Notifications:** Firebase Cloud Messaging
- PostgreSQL database - **Tooling:** Docker, Gitea Actions for CI/CD, deployed to a self-hosted home server
### Frontend ## Project Structure
- React Native (Expo)
### DevOps / Tooling - `backend/` — Spring Boot REST API and the scraper
- Docker - `frontend/` — Expo mobile app
- CI/CD pipelines (Gitea Actions or similar)
- Git version control
--- ## Backend
## Purpose ```bash
cd backend
cp src/main/resources/application.properties.example src/main/resources/application.properties # fill in datasource + jwt.secret
docker compose up -d # local Postgres
./gradlew bootRun
```
The goal of this project is to gain hands-on experience with: Run tests: `./gradlew test`
- Full-stack application architecture ## Frontend
- Backend API development with Spring Boot
- Mobile development with React Native
- Database design and integration
- Authentication and security concepts
- Containerization with Docker
- Continuous Integration / Continuous Deployment (CI/CD)
--- ```bash
cd frontend
## Deployment npm install
# set EXPO_PUBLIC_API_URL in .env to the backend base URL
The application is deployed on a self-hosted home server for development and testing purposes. npm start
```
---
## How to Run (Backend)
To be added.
Planned steps:
- Install Java and Gradle
- Configure PostgreSQL database
- Set environment variables
- Run Spring Boot application
- Expose API on localhost or network
- I usually run eveything with traefik but i dont know should i include that here???
---
## How to Run (Frontend)
To be added...
---
## CI/CD
To be added.
Planned steps:
- Configure Gitea Actions / CI pipeline
- Build backend and frontend automatically
- Deploy to home server
- Automate testing and builds
---
## Status ## Status
This project is currently under semi-active development as a learning and experimentation environment. Semi-actively developed as a learning project. It is an app I actually use, and
maybe others will find it useful too.
---
## Notes
This project is just me messing around with stuff while making an app i will use and perhaps others will find it useful
need to made .creds .env and init directory with subjects.txt
+1 -1
View File
@@ -50,7 +50,7 @@ CREATE TABLE IF NOT EXISTS public.users
email character varying(255) COLLATE pg_catalog."default" NOT NULL, email character varying(255) COLLATE pg_catalog."default" NOT NULL,
password character varying(255) COLLATE pg_catalog."default" NOT NULL, password character varying(255) COLLATE pg_catalog."default" NOT NULL,
reg_time timestamp without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, reg_time timestamp without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
notification_type character varying(255) COLLATE pg_catalog."default" NOT NULL DEFAULT 'NO_NOTIFICATION'::character varying, notification_type character varying(255) COLLATE pg_catalog."default" NOT NULL DEFAULT 'PUSH_NOTIFICATION'::character varying,
CONSTRAINT users_pkey PRIMARY KEY (id), CONSTRAINT users_pkey PRIMARY KEY (id),
CONSTRAINT unique_email UNIQUE (email) CONSTRAINT unique_email UNIQUE (email)
); );
@@ -24,8 +24,20 @@ public class NotificationService {
for (DeviceToken token : tokens) { for (DeviceToken token : tokens) {
// Include a notification block so Android's system tray displays it
// even when the app is killed/dozing (a data-only message is only
// ever shown by the app's own JS handler, which won't run when the
// OS has force-stopped the app). HIGH priority tells FCM to deliver
// promptly and wake the device out of Doze.
Message message = Message.builder() Message message = Message.builder()
.setToken(token.getFcmToken()) .setToken(token.getFcmToken())
.setNotification(Notification.builder()
.setTitle(title)
.setBody(body)
.build())
.setAndroidConfig(AndroidConfig.builder()
.setPriority(AndroidConfig.Priority.HIGH)
.build())
.putData("title", title) .putData("title", title)
.putData("body", body) .putData("body", body)
.build(); .build();
@@ -74,6 +74,11 @@ public class SubjectService {
@Async @Async
public void notifyAsync(Entry entry) { public void notifyAsync(Entry entry) {
// The scraper couldn't always match an announcement to a known subject, in
// which case there's nobody to notify. Guard against the NPE that would
// otherwise be thrown (and silently swallowed) on this async thread.
if (entry.getSubject() == null) return;
List<User> users = List<User> users =
userService.findUsersBySubjectId(entry.getSubject().getId()); userService.findUsersBySubjectId(entry.getSubject().getId());
+1 -1
View File
@@ -14,7 +14,7 @@
"android": { "android": {
"googleServicesFile": "./google-services.json", "googleServicesFile": "./google-services.json",
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "backgroundColor": "#1C1918",
"foregroundImage": "./assets/images/android-icon-foreground.png", "foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png", "backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png" "monochromeImage": "./assets/images/android-icon-monochrome.png"
+3 -9
View File
@@ -7,21 +7,15 @@ import {
onMessage, onMessage,
onNotificationOpenedApp, onNotificationOpenedApp,
getInitialNotification, getInitialNotification,
setBackgroundMessageHandler,
} from '@react-native-firebase/messaging'; } from '@react-native-firebase/messaging';
import { registerDeviceToken, listenForTokenRefresh, displayLocalNotification } from '@/services/notifications'; import { registerDeviceToken, listenForTokenRefresh } from '@/services/notifications';
import { AuthProvider, useAuth } from "@/context/AuthContext"; import { AuthProvider, useAuth } from "@/context/AuthContext";
import { useUpdatecheck } from '@/hooks/useUpdatecheck'; import { useUpdatecheck } from '@/hooks/useUpdatecheck';
import { UpdatePrompt } from '@/components/UpdatePrompt'; import { UpdatePrompt } from '@/components/UpdatePrompt';
import Toast from 'react-native-toast-message'; import Toast from 'react-native-toast-message';
// Registered at module scope so it's installed as soon as this entry file // The FCM background handler now lives in index.js (registered before this
// loads, which is required for it to fire while the app is backgrounded/killed. // module loads) so it fires reliably while the app is backgrounded/killed.
setBackgroundMessageHandler(getMessaging(), async (remoteMessage) => {
const title = remoteMessage.data?.title as string | undefined;
const body = remoteMessage.data?.body as string | undefined;
await displayLocalNotification(title, body);
});
function NotificationSetup() { function NotificationSetup() {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 916 KiB

-46
View File
@@ -1,46 +0,0 @@
import { useEffect } from "react";
import messaging from "@react-native-firebase/messaging";
import notifee, { AndroidImportance, EventType } from "@notifee/react-native";
async function displayLocalNotification(title?: string, body?: string) {
if (!title && !body) return;
const channelId = await notifee.createChannel({
id: "default",
name: "General",
importance: AndroidImportance.HIGH,
});
await notifee.displayNotification({
title,
body,
android: {
channelId,
pressAction: { id: "default" },
},
});
}
export function usePushNotifications() {
useEffect(() => {
const unsubscribeMessage = messaging().onMessage(async remoteMessage => {
const title = remoteMessage.data?.title as string | undefined;
const body = remoteMessage.data?.body as string | undefined;
await displayLocalNotification(title, body);
});
const unsubscribeNotifee = notifee.onForegroundEvent(({ type, detail }) => {
if (type === EventType.PRESS) {
console.log("Notification tapped:", detail.notification);
}
});
return () => {
unsubscribeMessage();
unsubscribeNotifee();
};
}, []);
}
+27
View File
@@ -0,0 +1,27 @@
// Custom entry point. The FCM background handler must be registered in the very
// first module that loads — before the router/app mounts — so it reliably fires
// when the app is backgrounded or fully quit. Registering it here (rather than in
// app/_layout.tsx) is what react-native-firebase requires for the killed state.
import messaging from '@react-native-firebase/messaging';
import notifee, { AndroidImportance } from '@notifee/react-native';
messaging().setBackgroundMessageHandler(async (remoteMessage) => {
const title = remoteMessage.data?.title;
const body = remoteMessage.data?.body;
if (!title && !body) return;
const channelId = await notifee.createChannel({
id: 'default',
name: 'General',
importance: AndroidImportance.HIGH,
});
await notifee.displayNotification({
title,
body,
android: { channelId, pressAction: { id: 'default' } },
});
});
// Hand off to expo-router, which mounts app/_layout.tsx and the routes.
import 'expo-router/entry';
-166
View File
@@ -1,166 +0,0 @@
import "./globals.css";
import React, { useState } from "react";
import { useColorScheme } from "react-native";
import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { Ionicons } from "@expo/vector-icons";
import { enableScreens } from "react-native-screens";
import { SafeAreaProvider } from "react-native-safe-area-context";
import messaging from "@react-native-firebase/messaging";
import notifee, { AndroidImportance } from "@notifee/react-native";
import SubscribedFeed from "@/screens/SubscribedFeed";
import AllFeed from "@/screens/AllFeed";
import Profile from "@/screens/Profile";
import AuthGate from "@/screens/AuthGate";
import { AuthProvider, useAuth } from "@/context/AuthContext";
import { useUpdatecheck } from "./hooks/useUpdatecheck";
import { UpdatePrompt } from "./components/UpdatePrompt";
import { usePushNotifications } from "./hooks/usePushNotifications";
// Handles data-only FCM messages when the app is in the background or closed.
messaging().setBackgroundMessageHandler(async remoteMessage => {
const title = remoteMessage.data?.title as string | undefined;
const body = remoteMessage.data?.body as string | undefined;
if (!title && !body) return;
const channelId = await notifee.createChannel({
id: "default",
name: "General",
importance: AndroidImportance.HIGH,
});
await notifee.displayNotification({
title,
body,
android: {
channelId,
pressAction: { id: "default" },
},
});
});
// Must be called before any navigator renders
enableScreens();
const Tab = createBottomTabNavigator();
const LIGHT_TAB = {
bg: "#FFFFFF",
border: "#E8E2D5",
active: "#C4622D",
inactive: "#8A8278",
label: "#1A1714",
};
const DARK_TAB = {
bg: "#161513",
border: "#2C2A27",
active: "#E07B45",
inactive: "#706D67",
label: "#F0EDE8",
};
function ProfileTab() {
const { user, loading } = useAuth();
if (loading) return null;
return user ? <Profile /> : <AuthGate />;
}
export default function App() {
const scheme = useColorScheme();
const dark = scheme === "dark";
const tab = dark ? DARK_TAB : LIGHT_TAB;
const navTheme = dark
? { ...DarkTheme, colors: { ...DarkTheme.colors, background: "#0E0D0C" } }
: { ...DefaultTheme, colors: { ...DefaultTheme.colors, background: "#FDFAF5" } };
const { updateInfo } = useUpdatecheck();
const [updateDismissed, setUpdateDismissed] = useState(false);
usePushNotifications();
return (
<AuthProvider>
<SafeAreaProvider>
<NavigationContainer theme={navTheme}>
<Tab.Navigator
screenOptions={({ route }) => ({
headerShown: false,
tabBarStyle: {
backgroundColor: tab.bg,
borderTopColor: tab.border,
borderTopWidth: 1,
height: 60,
paddingBottom: 8,
paddingTop: 6,
},
tabBarActiveTintColor: tab.active,
tabBarInactiveTintColor: tab.inactive,
tabBarLabelStyle: {
fontSize: 10,
fontWeight: "600",
letterSpacing: 0.3,
},
tabBarIcon: ({ focused, color, size }) => {
const icons: Record<
string,
{ active: keyof typeof Ionicons.glyphMap; inactive: keyof typeof Ionicons.glyphMap }
> = {
"Subscribed": { active: "bookmark", inactive: "bookmark-outline" },
"Discover": { active: "compass", inactive: "compass-outline" },
"Profile": { active: "person-circle", inactive: "person-circle-outline" },
};
const set = icons[route.name];
return (
<Ionicons
name={focused ? set.active : set.inactive}
size={focused ? size + 1 : size}
color={color}
/>
);
},
})}
>
<Tab.Screen
name="Subscribed"
component={SubscribedFeed}
options={{ title: "My Feed" }}
/>
<Tab.Screen
name="Discover"
component={AllFeed}
options={{ title: "Discover" }}
/>
<Tab.Screen
name="Profile"
component={ProfileTab}
options={{ title: "Profile" }}
/>
</Tab.Navigator>
</NavigationContainer>
{/* Overlays the entire app including the tab bar */}
{updateInfo && !updateDismissed && (
<UpdatePrompt
updateInfo={updateInfo}
onDismiss={() => setUpdateDismissed(true)}
/>
)}
</SafeAreaProvider>
</AuthProvider>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "etfoglasi", "name": "etfoglasi",
"main": "expo-router/entry", "main": "index.js",
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
+28
View File
@@ -0,0 +1,28 @@
// Used by CI to stamp a mobile release build with its version number.
// Updates version.json (read by hooks/useUpdatecheck.tsx) and app.json's
// android.versionCode/versionName so the build, the in-app update check,
// and the Gitea release tag (mobile-vN) all agree on the same number.
const fs = require('fs');
const path = require('path');
const version = parseInt(process.argv[2], 10);
if (!Number.isInteger(version) || version <= 0) {
console.error('Usage: node set-mobile-version.js <positive integer>');
process.exit(1);
}
const root = path.join(__dirname, '..');
fs.writeFileSync(
path.join(root, 'version.json'),
JSON.stringify({ version }, null, 2) + '\n',
);
const appJsonPath = path.join(root, 'app.json');
const appJson = JSON.parse(fs.readFileSync(appJsonPath, 'utf8'));
appJson.expo.version = `1.0.${version}`;
appJson.expo.android = appJson.expo.android ?? {};
appJson.expo.android.versionCode = version;
fs.writeFileSync(appJsonPath, JSON.stringify(appJson, null, 2) + '\n');
console.log(`Stamped mobile version ${version}`);
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB