diff --git a/android_app/.gitignore b/android_app/.gitignore new file mode 100644 index 00000000..aa724b77 --- /dev/null +++ b/android_app/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/android_app/app/.gitignore b/android_app/app/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/android_app/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android_app/app/build.gradle b/android_app/app/build.gradle new file mode 100644 index 00000000..ee7989ab --- /dev/null +++ b/android_app/app/build.gradle @@ -0,0 +1,55 @@ +plugins { + alias(libs.plugins.android.application) +} + +android { + namespace 'com.asp.android_app' + compileSdk 35 + + buildFeatures { + buildConfig true + } + + defaultConfig { + applicationId "com.asp.android_app" + minSdk 24 + targetSdk 35 + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + buildConfigField "String", "API_BASE_URL", "\"${API_BASE_URL}\"" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } +} + +dependencies { + implementation libs.appcompat + implementation libs.material + implementation libs.constraintlayout + implementation libs.navigation.fragment + implementation libs.navigation.ui + implementation libs.activity + implementation libs.swiperefreshlayout + testImplementation libs.junit + androidTestImplementation libs.ext.junit + androidTestImplementation libs.espresso.core + // Retrofit & Gson + implementation libs.retrofit + implementation libs.gson.converter + // Lifecycle ViewModel + LiveData + implementation libs.lifecycle.viewmodel + implementation libs.lifecycle.livedata + // Room + implementation libs.room.runtime + annotationProcessor libs.room.compiler +} \ No newline at end of file diff --git a/android_app/app/proguard-rules.pro b/android_app/app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/android_app/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android_app/app/src/androidTest/java/com/asp/android_app/ExampleInstrumentedTest.java b/android_app/app/src/androidTest/java/com/asp/android_app/ExampleInstrumentedTest.java new file mode 100644 index 00000000..470e37b6 --- /dev/null +++ b/android_app/app/src/androidTest/java/com/asp/android_app/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.asp.android_app; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("com.asp.android_app", appContext.getPackageName()); + } +} \ No newline at end of file diff --git a/android_app/app/src/main/AndroidManifest.xml b/android_app/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..bf60f27b --- /dev/null +++ b/android_app/app/src/main/AndroidManifest.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android_app/app/src/main/java/com/asp/android_app/api/ApiClient.java b/android_app/app/src/main/java/com/asp/android_app/api/ApiClient.java new file mode 100644 index 00000000..4233a408 --- /dev/null +++ b/android_app/app/src/main/java/com/asp/android_app/api/ApiClient.java @@ -0,0 +1,88 @@ +package com.asp.android_app.api; + +import android.content.Context; +import android.content.Intent; + +import com.asp.android_app.BuildConfig; +import com.asp.android_app.ui.auth_activity.AuthActivity; +import com.asp.android_app.utils.TokenManager; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import retrofit2.Retrofit; +import retrofit2.converter.gson.GsonConverterFactory; + +/** + * Singleton class responsible for providing a configured Retrofit instance. + * - Uses `http://10.0.2.2:3001/api/` as the base URL (emulator-friendly localhost) + * - Automatically adds a Bearer token to all requests using an OkHttp interceptor + * - Applies `GsonConverterFactory` to handle JSON serialization/deserialization + */ +public class ApiClient { + private static final String BASE_URL = BuildConfig.API_BASE_URL; + private static Retrofit retrofit = null; + private static Context appContext = null; + + public static Retrofit getClient(Context context) { + if (appContext == null) { + appContext = context.getApplicationContext(); + } + + if (retrofit == null) { + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(chain -> { + Request original = chain.request(); + Request.Builder requestBuilder = original.newBuilder(); + // Get token fresh for each request + TokenManager tokenManager = TokenManager.getInstance(appContext); + String token = tokenManager.getToken(); + // Add token Authorization only if token exists, isn't empty and isn't expired + if (token != null && !token.isBlank()) + requestBuilder.header("Authorization", "Bearer " + token); + + // add the request's body + requestBuilder.method(original.method(), original.body()); + Response response = chain.proceed(requestBuilder.build()); + + // check if the response includes unauthorized + String bodyStr = response.peekBody(Long.MAX_VALUE).string(); + if (isTokenUnauthorized(bodyStr)) { + response.close(); + tokenManager.clearToken(); + tokenManager.clearUser(); + moveToLogin(appContext); + } + return response; + }).build(); + + retrofit = new Retrofit.Builder() + .baseUrl(BASE_URL) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build(); + } + return retrofit; + } + + /** + * @param bodyStr response body as a string + * @return true if includes an error message from the backend of invalid/expired/missing token, + * otherwise false + */ + private static boolean isTokenUnauthorized(String bodyStr) { + return bodyStr.contains("\"error\":\"Invalid or expired token\"") || + bodyStr.contains("\"error\":\"Authorization header missing\""); + } + + /** + * Navigates the user to the login page (in case of unAuthorised request to server) + * + * @param context app context + */ + private static void moveToLogin(Context context) { + Intent i = new Intent(context, AuthActivity.class); + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + context.startActivity(i); + } +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/asp/android_app/api/LabelApi.java b/android_app/app/src/main/java/com/asp/android_app/api/LabelApi.java new file mode 100644 index 00000000..1f5fbb8c --- /dev/null +++ b/android_app/app/src/main/java/com/asp/android_app/api/LabelApi.java @@ -0,0 +1,63 @@ +package com.asp.android_app.api; + +import com.asp.android_app.model.Label; +import com.asp.android_app.model.request.LabelRequest; + +import java.util.List; + +import retrofit2.Call; +import retrofit2.http.Body; +import retrofit2.http.DELETE; +import retrofit2.http.GET; +import retrofit2.http.PATCH; +import retrofit2.http.POST; +import retrofit2.http.Path; + +/** + * Retrofit interface for managing label-related operations. + * Connects to endpoints defined in the NodeJS server. + * All calls require authentication via Authorization header. + */ +public interface LabelApi { + + /** + * Fetch all labels belonging to the authenticated user. + * @return A list of LabelResponse objects. + */ + @GET("labels/") + Call> getAllLabels(); + + /** + * Create a new root-level label. + * @param labelRequest The request body containing the label name. + * @return The newly created LabelResponse. + */ + @POST("labels/") + Call