Http Server / Client communication #7

Merged
faraphel merged 24 commits from test-http into main 2024-05-17 17:22:56 +02:00
88 changed files with 2204 additions and 690 deletions

7
.gitignore vendored
View file

@ -1,12 +1,7 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/
.DS_Store
/build
/captures

3
.idea/.gitignore vendored
View file

@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml

View file

@ -1 +0,0 @@
tasks-valider

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="selectedTabId" value="Android Vitals" />
</component>
</project>

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
</component>
</project>

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<value>
<entry key="app">
<State />
</entry>
</value>
</component>
</project>

View file

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>

View file

@ -1,41 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

View file

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

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

View file

@ -1,9 +0,0 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View file

@ -1,7 +1,8 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
kotlin("android")
kotlin("plugin.serialization")
}
android {
@ -41,7 +42,7 @@ android {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
kotlinCompilerExtensionVersion = "1.5.13"
}
packaging {
resources {
@ -51,15 +52,20 @@ android {
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.core:core-ktx:1.13.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.activity:activity-compose:1.8.2")
implementation("androidx.activity:activity-compose:1.9.0")
implementation(platform("androidx.compose:compose-bom:2023.08.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.room:room-ktx:2.6.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("androidx.navigation:navigation-compose:2.7.7")
implementation("org.nanohttpd:nanohttpd:2.3.1")
implementation("com.google.code.gson:gson:2.10.1")
implementation("com.squareup.okhttp3:okhttp-android:5.0.0-alpha.14")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")

View file

@ -2,6 +2,33 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- SDK -->
<uses-sdk
android:minSdkVersion="24"
tools:ignore="GradleOverrides" />
<!-- Permissions -->
<!-- Internet -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Internet < Android 13 (API 33) -->
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="32" />
<!-- Internet >= Android 13 (API 33) -->
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<!-- Applications -->
<!-- NOTE: usesCleartextTraffic is enabled because of the API system using simple HTTP -->
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@ -10,7 +37,9 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
tools:ignore="RtlEnabled"
android:theme="@style/Theme.Tasksvalider"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name=".MainActivity"

View file

@ -1,99 +1,44 @@
package com.faraphel.tasks_valider
import android.annotation.SuppressLint
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Column
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import com.faraphel.tasks_valider.database.Database
import com.faraphel.tasks_valider.database.entities.Group
import com.faraphel.tasks_valider.database.entities.GroupStudent
import com.faraphel.tasks_valider.database.entities.Student
import com.faraphel.tasks_valider.database.entities.Task
import com.faraphel.tasks_valider.database.entities.TaskGroup
import com.faraphel.tasks_valider.database.entities.Teacher
import com.faraphel.tasks_valider.ui.widgets.WidgetTaskStudent
import java.time.Instant
import com.faraphel.tasks_valider.connectivity.bwf.BwfManager
import com.faraphel.tasks_valider.database.TaskDatabase
import com.faraphel.tasks_valider.ui.screen.communication.CommunicationScreen
class MainActivity : ComponentActivity() {
private var bwfManager: BwfManager? = null ///< the WiFi-Direct helper
companion object {
lateinit var database: Database
private lateinit var database: TaskDatabase ///< the database manager
}
@RequiresApi(Build.VERSION_CODES.O)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Reset the database
this.deleteDatabase("local") // TODO: remove
// Create the database
database = Room.databaseBuilder(
this.applicationContext,
Database::class.java, "local"
)
.allowMainThreadQueries() // TODO: remove
.build()
// Create some data
val studentDao = database.studentDao()
studentDao.insert(
Student(firstName = "John", lastName = "Joe"),
Student(firstName = "Evan", lastName = "Doe"),
Student(firstName = "Xavier", lastName = "Moe"),
)
val teacherDao = database.teacherDao()
teacherDao.insert(
Teacher(firstName = "Jean", lastName = "Voe")
)
val groupDao = database.groupDao()
groupDao.insert(
Group(name = "Group 1"),
Group(name = "Group 2"),
)
val groupStudentDao = database.groupStudentDao()
groupStudentDao.insert(
GroupStudent(1, 1),
GroupStudent(1, 2),
GroupStudent(2, 3),
)
val taskDao = database.taskDao()
taskDao.insert(
Task(title = "Task 1", description = "Do something"),
Task(title = "Task 2", description = "Do something else"),
Task(title = "Task 3", description = "Do something nice"),
)
val taskGroupDao = database.taskGroupDao()
taskGroupDao.insert(
TaskGroup(1, 1),
TaskGroup(2, 2, true, 1, Instant.now()),
)
/*
// display some data
this.setContent {
Column {
database.taskGroupDao().getAll().forEach { taskGroup ->
WidgetTaskStudent(database, taskGroup)
}
}
CommunicationScreen(this)
}
}
*/
@SuppressLint("UnspecifiedRegisterReceiverFlag")
override fun onResume() {
super.onResume()
// enable the WiFi-Direct events
this.registerReceiver(this.bwfManager, BwfManager.ALL_INTENT_FILTER)
}
override fun onPause() {
super.onPause()
// disable the WiFi-Direct events
this.unregisterReceiver(this.bwfManager)
}
}

View file

@ -0,0 +1,192 @@
package com.faraphel.tasks_valider.connectivity.bwf
import android.Manifest
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.wifi.p2p.*
import android.util.Log
import androidx.compose.runtime.mutableStateOf
import com.faraphel.tasks_valider.connectivity.bwf.error.*
/**
* A helper to wrap the WiFi-Direct manager, the channel and the events.
*
* This avoids certain annoying features such as always specifying the channel as the first argument or
* handling all the events with the base event system.
*
* @param manager The WiFi-Direct manager
* @param channel The WiFi-Direct channel
*/
class BwfManager(
private var manager: WifiP2pManager,
private var channel: WifiP2pManager.Channel,
) : BroadcastReceiver() {
companion object {
var PERMISSION_ACCESS_FINE_LOCATION = 1001 ///< permission code for the fine location
var ALL_INTENT_FILTER = IntentFilter().apply {
addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION)
addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION)
}
fun isSupported(context: Context): Boolean {
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI_DIRECT)
}
/**
* Create a new BwfManager from an activity.
* @param activity The activity to create the manager from
*/
fun fromActivity(activity: Activity): BwfManager {
// check if the system support WiFi-Direct
if (this.isSupported(activity)) {
Log.e("wifi-p2p", "this device does not support the WiFi-Direct feature")
throw BwfNotSupportedException()
}
// TODO(Faraphel): more check on permissions
if (
activity.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED ||
activity.checkSelfPermission(Manifest.permission.NEARBY_WIFI_DEVICES) != PackageManager.PERMISSION_GRANTED
) {
// TODO(Faraphel): should be used with shouldShowRequestPermissionRationale, with a check
activity.requestPermissions(
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
PERMISSION_ACCESS_FINE_LOCATION
)
}
// get the WiFi-Direct manager
val manager = activity.getSystemService(Context.WIFI_P2P_SERVICE) as WifiP2pManager?
?: throw BwfPermissionException()
// get the WiFi-Direct channel
val channel = manager.initialize(activity, activity.mainLooper, null)
return BwfManager(manager, channel)
// NOTE(Faraphel): the broadcast receiver should be registered in the activity onResume
}
}
// Wrappers
/**
* Connect to another device, allowing for a communication using Sockets
* @see WifiP2pManager.connect
* @throws SecurityException if the permission has not been given
*/
@Throws(SecurityException::class)
fun connect(config: WifiP2pConfig, callback: () -> Unit = {}) =
this.manager.connect(this.channel, config, object : WifiP2pManager.ActionListener {
override fun onSuccess() { callback() }
override fun onFailure(reason: Int) = throw BwfConnectException(reason)
})
/**
* Request the list of peers after a discovery.
* @see WifiP2pManager.requestPeers
* @throws SecurityException if the permission has not been given
*/
@Throws(SecurityException::class)
fun requestPeers(callback: (WifiP2pDeviceList) -> Unit = {}) =
this.manager.requestPeers(this.channel, callback)
/**
* Start discovering peers.
* Once founds, the WIFI_P2P_PEERS_CHANGED_ACTION event will be triggered.
* @see WifiP2pManager.discoverPeers
* @throws SecurityException if the permission has not been given
*/
@Throws(SecurityException::class)
fun discoverPeers(callback: () -> Unit = {}) =
this.manager.discoverPeers(this.channel, object : WifiP2pManager.ActionListener {
override fun onSuccess() { callback() }
override fun onFailure(reason: Int) = throw BwfDiscoverException(reason)
})
/**
* Obtain information about a connection with another device.
* @see WifiP2pManager.requestConnectionInfo
*/
fun requestConnectionInfo(callback: (WifiP2pInfo) -> Unit = {}) =
this.manager.requestConnectionInfo(this.channel, callback)
/**
* Obtain information about the current group.
* @see WifiP2pManager.requestGroupInfo
* @throws SecurityException if the permission has not been given
*/
@Throws(SecurityException::class)
fun requestGroupInfo(callback: (WifiP2pGroup?) -> Unit = {}) =
this.manager.requestGroupInfo(this.channel, callback)
/**
* Create a new WiFi-Direct group.
* @see WifiP2pManager.createGroup
* @throws SecurityException if the permission has not been given
*/
@Throws(SecurityException::class)
fun createGroup(callback: () -> Unit = {}) =
this.manager.createGroup(this.channel, object : WifiP2pManager.ActionListener {
override fun onSuccess() { callback() }
override fun onFailure(reason: Int) = throw BwfCreateGroupException(reason)
})
/**
* Disconnect from the current WiFi-Direct group.
* @see WifiP2pManager.removeGroup
*/
fun removeGroup(callback: () -> Unit = {}) =
this.manager.removeGroup(this.channel, object : WifiP2pManager.ActionListener {
override fun onSuccess() { callback() }
override fun onFailure(reason: Int) = throw BwfRemoveGroupException(reason)
})
/**
* Create a new WiFi-Direct group. If already connected to a group, quit it first.
*
* Note: most of the failure on removal are caused by not having a group already created, which is checked.
*
* @param callback: the createGroup listener
*
* @see WifiP2pManager.createGroup
* @see WifiP2pManager.removeGroup
*/
fun recreateGroup(callback: () -> Unit = {}) {
// get the current group information
this.requestGroupInfo { group ->
// if a group exist, quit it
if (group != null)
this.removeGroup { this@BwfManager.createGroup(callback) }
else
// create the group
this.createGroup(callback)
}
}
// Events
val stateConnectionInfo = mutableStateOf<WifiP2pInfo?>(null)
val statePeers = mutableStateOf<WifiP2pDeviceList?>(null)
override fun onReceive(context: Context?, intent: Intent?) {
// ignore empty intent
if (intent == null)
return
// update the action corresponding state
when (intent.action) {
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> this.requestConnectionInfo {
connectionInfo -> stateConnectionInfo.value = connectionInfo
}
WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION -> this.requestPeers {
peers -> statePeers.value = peers
}
}
// TODO(Faraphel): implement event dispatcher
}
}

View file

@ -0,0 +1,11 @@
# Better WiFi-Direct (BWD)
This package contain code to improve the base WiFi-Direct implementation.
The base have some issue, like an abusive usage of listener, error code and events that make using it
very impractical.
This improved version will instead focus on asynchronous function and exception, allowing for a
cleaner linear code instead.
(Author: https://git.faraphel.fr/Faraphel)

View file

@ -0,0 +1,5 @@
package com.faraphel.tasks_valider.connectivity.bwf.error
class BwfConnectException(
reason: Int
) : BwfException("Cannot connect to the peer. Reason: $reason")

View file

@ -0,0 +1,5 @@
package com.faraphel.tasks_valider.connectivity.bwf.error
class BwfCreateGroupException (
reason: Int
) : BwfException("Could not create the group : $reason")

View file

@ -0,0 +1,5 @@
package com.faraphel.tasks_valider.connectivity.bwf.error
class BwfDiscoverException(
reason: Int
) : BwfException("Could not discover peers : $reason")

View file

@ -0,0 +1,9 @@
package com.faraphel.tasks_valider.connectivity.bwf.error
/**
* Base Exception for everything concerning the WifiP2pHelper class
*/
open class BwfException(
override val message: String?
) : Exception(message)

View file

@ -0,0 +1,5 @@
package com.faraphel.tasks_valider.connectivity.bwf.error
class BwfInvalidActionException(
action: String
) : BwfException("This WiFi-Direct action is not supported : $action")

View file

@ -0,0 +1,4 @@
package com.faraphel.tasks_valider.connectivity.bwf.error
class BwfNotSupportedException :
BwfException("WiFi-Direct is not supported on this device.")

View file

@ -0,0 +1,4 @@
package com.faraphel.tasks_valider.connectivity.bwf.error
class BwfPermissionException :
BwfException("WiFi-Direct requires permissions to work properly. Please grant the permissions.")

View file

@ -0,0 +1,5 @@
package com.faraphel.tasks_valider.connectivity.bwf.error
class BwfRemoveGroupException (
reason: Int
) : BwfException("Could not remove the group : $reason")

View file

@ -0,0 +1,100 @@
package com.faraphel.tasks_valider.connectivity.task
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
/**
* A client to handle the room connection.
* @param address the address of the server
* @param port the port of the server
* @param baseCookies list of cookies to use (optional)
*/
class TaskClient(
private val address: String,
private val port: Int,
private val baseCookies: List<okhttp3.Cookie> = listOf()
) {
private val baseUrl = "http://$address:$port"
private val client = OkHttpClient().newBuilder().cookieJar(
// TODO(Faraphel): should be moved into another object
object : okhttp3.CookieJar {
private val cookies = baseCookies.toMutableList() ///< list of cookies
override fun loadForRequest(url: HttpUrl): List<okhttp3.Cookie> {
return this.cookies
}
override fun saveFromResponse(url: HttpUrl, cookies: List<okhttp3.Cookie>) {
this.cookies.addAll(cookies)
}
}
).build()
// TODO(Faraphel): automatically convert content to the correct type ?
/**
* Return a basic request to the server
* @param endpoint the endpoint of the server
*/
private fun baseRequestBuilder(endpoint: String): okhttp3.Request.Builder =
okhttp3.Request.Builder().url("$baseUrl/$endpoint")
/**
* Run a HEAD request
* @param endpoint the endpoint of the server
*/
fun head(endpoint: String): okhttp3.Request =
this.baseRequestBuilder(endpoint).head().build()
/**
* Run a GET request
* @param endpoint the endpoint of the server
*/
fun get(endpoint: String): okhttp3.Response =
this.client.newCall(
this.baseRequestBuilder(endpoint)
.get()
.build()
).execute()
/**
* Run a POST request
* @param endpoint the endpoint of the server
* @param content the content of the request
* @param type the type of the content
*/
fun post(endpoint: String, content: String, type: String = "text/plain"): okhttp3.Response =
this.client.newCall(
this.baseRequestBuilder(endpoint)
.post(content.toRequestBody(type.toMediaType()))
.build()
).execute()
/**
* Run a PATCH request
* @param endpoint the endpoint of the server
* @param content the content of the request
* @param type the type of the content
*/
fun patch(endpoint: String, content: String, type: String = "text/plain"): okhttp3.Response =
this.client.newCall(
this.baseRequestBuilder(endpoint)
.patch(content.toRequestBody(type.toMediaType()))
.build()
).execute()
/**
* Run a DELETE request
* @param endpoint the endpoint of the server
* @param content the content of the request
* @param type the type of the content
*/
fun delete(endpoint: String, content: String, type: String = "text/plain"): okhttp3.Response =
this.client.newCall(
this.baseRequestBuilder(endpoint)
.delete(content.toRequestBody(type.toMediaType()))
.build()
).execute()
}

View file

@ -0,0 +1,94 @@
package com.faraphel.tasks_valider.connectivity.task
import com.faraphel.tasks_valider.connectivity.task.api.TaskSessionManagerApi
import com.faraphel.tasks_valider.connectivity.task.session.TaskRole
import com.faraphel.tasks_valider.connectivity.task.session.TaskSession
import com.faraphel.tasks_valider.connectivity.task.session.TaskSessionManager
import com.faraphel.tasks_valider.database.TaskDatabase
import com.faraphel.tasks_valider.database.api.TaskDatabaseApi
import fi.iki.elonen.NanoHTTPD
/**
* A server to handle the task API to allow clients to interact with the database.
* @param port the port of the server
* @param database the database to interact with
*/
class TaskServer(
private val port: Int,
private val database: TaskDatabase
) : NanoHTTPD(port) {
companion object {
private val TASK_SESSION_ADMIN = TaskSession( ///< the admin default session
role = TaskRole.ADMIN
)
}
private val sessionManager = TaskSessionManager() ///< the session manager
private val adminSessionId = this.sessionManager.newSessionData(TASK_SESSION_ADMIN) ///< default admin session id
private val sessionManagerApi = TaskSessionManagerApi(this.sessionManager) ///< the api of the session manager
private val databaseApi = TaskDatabaseApi(this.database) ///< the api of the database
/**
* Return a new client that can be used by the admin
*/
fun getClientAdmin(): TaskClient {
// create the session cookie for the admin
val cookieSession = okhttp3.Cookie.Builder()
.domain("localhost")
.name("sessionId")
.value(adminSessionId)
.build()
// create a new client
return TaskClient(
"localhost",
this.port,
listOf(cookieSession)
)
}
/**
* Handle an API request
* @param httpSession the http session
*/
override fun serve(httpSession: IHTTPSession): Response {
// get the session data of the client
val taskSession = this.sessionManager.getOrCreateSessionData(httpSession)
// parse the url
val uri: String = httpSession.uri.trim('/')
val path = uri.split("/").toMutableList()
// get the type of the request from the uri
val requestType = path.removeFirstOrNull()
?: return newFixedLengthResponse(
NanoHTTPD.Response.Status.BAD_REQUEST,
"text/plain",
"Missing request type"
)
// get the response from the correct part of the application
val response = when (requestType) {
// session requests
"sessions" -> this.sessionManagerApi.handleRequest(taskSession, httpSession, path)
// entities requests
"entities" -> return this.databaseApi.handleRequest(taskSession, httpSession, path)
// invalid requests
else ->
newFixedLengthResponse(
NanoHTTPD.Response.Status.BAD_REQUEST,
"text/plain",
"Unknown request type"
)
}
// wrap additional information in the response
return this.sessionManager.responseSetSessionData(response, httpSession.cookies)
}
/**
* Start the server with the default configuration
*/
override fun start() = super.start(SOCKET_READ_TIMEOUT, false)
}

View file

@ -0,0 +1,139 @@
package com.faraphel.tasks_valider.connectivity.task.api
import com.faraphel.tasks_valider.connectivity.task.session.TaskPermission
import com.faraphel.tasks_valider.connectivity.task.session.TaskRole
import com.faraphel.tasks_valider.connectivity.task.session.TaskSession
import com.faraphel.tasks_valider.connectivity.task.session.TaskSessionManager
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import fi.iki.elonen.NanoHTTPD
/**
* the HTTP API for the session manager
*/
class TaskSessionManagerApi(private val sessionManager: TaskSessionManager) {
private val jsonParser = Gson() ///< the json parser
/**
* Handle a HTTP Api request
* @param taskSession the data of the client session
* @param httpSession the data of the http session
* @param path the path of the request
*/
fun handleRequest(
taskSession: TaskSession,
httpSession: NanoHTTPD.IHTTPSession,
path: MutableList<String>,
): NanoHTTPD.Response {
// get the target session id
val targetSessionId = path.removeFirstOrNull()
return if (targetSessionId == null) {
// no specific session targeted
this.handleRequestGeneric(taskSession, httpSession)
} else {
// a specific session is targeted
this.handleRequestSpecific(taskSession, httpSession, targetSessionId)
}
}
/**
* Handle a request with no specific session targeted
*/
private fun handleRequestGeneric(
taskSession: TaskSession,
httpSession: NanoHTTPD.IHTTPSession,
): NanoHTTPD.Response {
when (httpSession.method) {
// get all the session data
NanoHTTPD.Method.GET -> {
// check the permission of the session
if (taskSession.role.permissions.contains(TaskPermission.READ))
return NanoHTTPD.newFixedLengthResponse(
NanoHTTPD.Response.Status.FORBIDDEN,
"text/plain",
"Forbidden"
)
// return the session data
return NanoHTTPD.newFixedLengthResponse(
NanoHTTPD.Response.Status.OK,
"application/json",
jsonParser.toJson(taskSession)
)
}
// other action are limited
else -> {
return NanoHTTPD.newFixedLengthResponse(
NanoHTTPD.Response.Status.METHOD_NOT_ALLOWED,
"text/plain",
"Unknown method"
)
}
}
}
private fun handleRequestSpecific(
taskSession: TaskSession,
httpSession: NanoHTTPD.IHTTPSession,
targetSessionId: String,
): NanoHTTPD.Response {
when (httpSession.method) {
// change a specific client session data
NanoHTTPD.Method.POST -> {
// check the permission of the session
if (taskSession.role.permissions.contains(TaskPermission.ADMIN))
return NanoHTTPD.newFixedLengthResponse(
NanoHTTPD.Response.Status.FORBIDDEN,
"text/plain",
"You are not allowed to update a session"
)
// parse the content of the request
val targetSession = jsonParser.fromJson(
httpSession.inputStream.bufferedReader().readText(),
TaskSession::class.java
)
// update the session
this.sessionManager.setSessionData(targetSessionId, targetSession)
// success message
return NanoHTTPD.newFixedLengthResponse(
NanoHTTPD.Response.Status.OK,
"text/plain",
"Session updated"
)
}
// delete the session
NanoHTTPD.Method.DELETE -> {
// check the permission of the session
if (taskSession.role.permissions.contains(TaskPermission.ADMIN))
return NanoHTTPD.newFixedLengthResponse(
NanoHTTPD.Response.Status.FORBIDDEN,
"text/plain",
"You are not allowed to delete a session"
)
// delete the target session
this.sessionManager.deleteSessionData(targetSessionId)
// success message
return NanoHTTPD.newFixedLengthResponse(
NanoHTTPD.Response.Status.OK,
"text/plain",
"Session deleted"
)
}
// ignore other methods
else -> {
return NanoHTTPD.newFixedLengthResponse(
NanoHTTPD.Response.Status.METHOD_NOT_ALLOWED,
"text/plain",
"Invalid method"
)
}
}
}
}

View file

@ -0,0 +1,7 @@
package com.faraphel.tasks_valider.connectivity.task.session
enum class TaskPermission {
READ,
WRITE,
ADMIN
}

View file

@ -0,0 +1,31 @@
package com.faraphel.tasks_valider.connectivity.task.session
/**
* A role system that can be used for in the task system
*/
enum class TaskRole(val value: String) {
NONE("none") {
override var permissions: List<TaskPermission> = listOf()
},
STUDENT("student") {
override var permissions = listOf(
TaskPermission.READ
)
},
TEACHER("teacher") {
override var permissions: List<TaskPermission> = listOf(
TaskPermission.READ,
TaskPermission.WRITE
)
},
ADMIN("admin") {
override var permissions: List<TaskPermission> = listOf(
TaskPermission.READ,
TaskPermission.WRITE,
TaskPermission.ADMIN
)
};
abstract var permissions: List<TaskPermission>
}

View file

@ -0,0 +1,13 @@
package com.faraphel.tasks_valider.connectivity.task.session
import kotlinx.serialization.Serializable
/**
* store the data of a session in the task system
* @param role the role accorded to the session
*/
@Serializable
data class TaskSession(
var role: TaskRole = TaskRole.STUDENT
)

View file

@ -0,0 +1,100 @@
package com.faraphel.tasks_valider.connectivity.task.session
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import fi.iki.elonen.NanoHTTPD
import java.util.*
/**
* The manager for the session system
*/
class TaskSessionManager {
private val sessions = mutableMapOf<String, TaskSession>() ///< sessions specific data
/**
* Create a new session
* @param session the data for the session (optional)
* @param sessionId the session id to use (optional)
* @return a new session identifier
*/
fun newSessionData(
session: TaskSession = TaskSession(),
sessionId: String = UUID.randomUUID().toString()
): String {
this.sessions[sessionId] = session
return sessionId
}
/**
* Get data from a http session
* @param httpSession the HTTP session
* @return the session data
*/
fun getSessionData(httpSession: NanoHTTPD.IHTTPSession): TaskSession? {
val sessionId = httpSession.cookies.read("sessionId") ?: return null
val sessionData = this.getSessionData(sessionId)
return sessionData
}
/**
* Get data from a session identifier
* @param sessionId the identifier of the session
* @return the session data
*/
fun getSessionData(sessionId: String): TaskSession? {
return this.sessions[sessionId]
}
/**
* Set the data of a session
* @param sessionId the identifier of the session
* @param session the session data
*/
fun setSessionData(sessionId: String, session: TaskSession) {
this.sessions[sessionId] = session
}
/**
* Delete a session
* @param sessionId the identifier of the session
*/
fun deleteSessionData(sessionId: String): TaskSession? {
return this.sessions.remove(sessionId)
}
/**
* Get data from a http session. If it does not exist, create it.
* @param httpSession the HTTP session
*/
fun getOrCreateSessionData(httpSession: NanoHTTPD.IHTTPSession): TaskSession {
// try to get the session directly
var session = this.getSessionData(httpSession)
// if the session does not exist, create it
if (session == null) {
val sessionId = this.newSessionData()
session = this.getSessionData(sessionId)!!
}
// return the session
return session
}
/**
* Insert new cookies for a session in a response
* @param response the response to inject cookies into
* @param cookies the cookie handler
*/
fun responseSetSessionData(
response: NanoHTTPD.Response,
cookies: NanoHTTPD.CookieHandler
): NanoHTTPD.Response {
// update the cookie of the user
cookies.set(NanoHTTPD.Cookie("sessionId", this.newSessionData()))
// load them in the response
cookies.unloadQueue(response)
return response
}
}

View file

@ -1,50 +0,0 @@
package com.faraphel.tasks_valider.database
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.room.Database
import androidx.room.DatabaseConfiguration
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.faraphel.tasks_valider.database.converters.InstantConverter
import com.faraphel.tasks_valider.database.dao.GroupDao
import com.faraphel.tasks_valider.database.dao.GroupStudentDao
import com.faraphel.tasks_valider.database.dao.StudentDao
import com.faraphel.tasks_valider.database.dao.TaskDao
import com.faraphel.tasks_valider.database.dao.TaskGroupDao
import com.faraphel.tasks_valider.database.dao.TeacherDao
import com.faraphel.tasks_valider.database.entities.Group
import com.faraphel.tasks_valider.database.entities.GroupStudent
import com.faraphel.tasks_valider.database.entities.Student
import com.faraphel.tasks_valider.database.entities.Task
import com.faraphel.tasks_valider.database.entities.TaskGroup
import com.faraphel.tasks_valider.database.entities.Teacher
import java.time.Instant
@Database(
entities = [
Group::class,
Student::class,
Teacher::class,
Task::class,
GroupStudent::class,
TaskGroup::class,
],
version = 1
)
@TypeConverters(
InstantConverter::class
)
abstract class Database : RoomDatabase() {
// entities
abstract fun groupDao(): GroupDao
abstract fun studentDao(): StudentDao
abstract fun teacherDao(): TeacherDao
abstract fun taskDao(): TaskDao
// relations
abstract fun groupStudentDao(): GroupStudentDao
abstract fun taskGroupDao(): TaskGroupDao
}

View file

@ -0,0 +1,40 @@
package com.faraphel.tasks_valider.database
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.faraphel.tasks_valider.database.converters.InstantConverter
import com.faraphel.tasks_valider.database.dao.*
import com.faraphel.tasks_valider.database.entities.*
/**
* The database for the tasks' application.
* Contains the entities and the relations between them.
*/
@Database(
entities = [
ClassEntity::class,
PersonEntity::class,
SessionEntity::class,
SubjectEntity::class,
TaskEntity::class,
ValidationEntity::class,
RelationClassPersonEntity::class,
],
version = 1
)
@TypeConverters(
InstantConverter::class
)
abstract class TaskDatabase: RoomDatabase() {
abstract fun classDao(): ClassDao
abstract fun personDao(): PersonDao
abstract fun sessionDao(): SessionDao
abstract fun subjectDao(): SubjectDao
abstract fun taskDao(): TaskDao
abstract fun validationDao(): ValidationDao
abstract fun relationClassPersonDao(): RelationClassPersonDao
}

View file

@ -0,0 +1,110 @@
package com.faraphel.tasks_valider.database.api
import com.faraphel.tasks_valider.connectivity.task.session.TaskPermission
import com.faraphel.tasks_valider.connectivity.task.session.TaskSession
import com.faraphel.tasks_valider.database.TaskDatabase
import com.faraphel.tasks_valider.database.api.entities.*
import com.faraphel.tasks_valider.database.api.entities.base.BaseApi
import com.faraphel.tasks_valider.database.entities.*
import fi.iki.elonen.NanoHTTPD
class TaskDatabaseApi(private val database: TaskDatabase) {
private val api: Map<String, BaseApi> = mapOf(
ClassEntity.TABLE_NAME to ClassApi(this.database.classDao()),
PersonEntity.TABLE_NAME to PersonApi(this.database.personDao()),
SessionEntity.TABLE_NAME to SessionApi(this.database.sessionDao()),
SubjectEntity.TABLE_NAME to SubjectApi(this.database.subjectDao()),
TaskEntity.TABLE_NAME to TaskApi(this.database.taskDao()),
ValidationEntity.TABLE_NAME to ValidationApi(this.database.validationDao()),
RelationClassPersonEntity.TABLE_NAME to RelationClassPersonApi(this.database.relationClassPersonDao()),
)
/**
* handle an API request
* @param taskSession the current user session
* @param httpSession the http session
* @param path the path of the request
*/
fun handleRequest(
taskSession: TaskSession,
httpSession: NanoHTTPD.IHTTPSession,
path: MutableList<String>
): NanoHTTPD.Response {
// get the entity name
val entityName = path.removeFirstOrNull()
?: return NanoHTTPD.newFixedLengthResponse(
NanoHTTPD.Response.Status.BAD_REQUEST,
"text/plain",
"Missing entity name"
)
// get the correspond Api object for this entity
val entityApi = this.api[entityName]
?: return NanoHTTPD.newFixedLengthResponse(
NanoHTTPD.Response.Status.NOT_FOUND,
"text/plain",
"Unknown entity name"
)
// dispatch the request to the correct entity API
when (httpSession.method) {
// check if the data is in the database
// TODO(Faraphel): should only be allowed to read data concerning the current class session
NanoHTTPD.Method.HEAD -> {
// check the permission of the session
if (taskSession.role.permissions.contains(TaskPermission.READ))
return NanoHTTPD.newFixedLengthResponse(
NanoHTTPD.Response.Status.FORBIDDEN,
"text/plain",
"Forbidden"
)
return entityApi.head(httpSession)
}
// get the data from the database
NanoHTTPD.Method.GET -> {
// check the permission of the session
if (taskSession.role.permissions.contains(TaskPermission.READ))
return NanoHTTPD.newFixedLengthResponse(
NanoHTTPD.Response.Status.FORBIDDEN,
"text/plain",
"Forbidden"
)
return entityApi.get(httpSession)
}
// insert the data into the database
NanoHTTPD.Method.POST -> {
// check the permission of the session
if (taskSession.role.permissions.contains(TaskPermission.WRITE))
return NanoHTTPD.newFixedLengthResponse(
NanoHTTPD.Response.Status.FORBIDDEN,
"text/plain",
"Forbidden"
)
return entityApi.post(httpSession)
}
// delete the data from the database
NanoHTTPD.Method.DELETE -> {
// check the permission of the session
if (taskSession.role.permissions.contains(TaskPermission.WRITE))
return NanoHTTPD.newFixedLengthResponse(
NanoHTTPD.Response.Status.FORBIDDEN,
"text/plain",
"Forbidden"
)
return entityApi.delete(httpSession)
}
// other methods are not allowed
else ->
return NanoHTTPD.newFixedLengthResponse(
NanoHTTPD.Response.Status.METHOD_NOT_ALLOWED,
"text/plain",
"Method not allowed"
)
}
}
}

View file

@ -0,0 +1,7 @@
package com.faraphel.tasks_valider.database.api.entities
import com.faraphel.tasks_valider.database.api.entities.base.BaseJsonApi
import com.faraphel.tasks_valider.database.dao.base.BaseDao
import com.faraphel.tasks_valider.database.entities.ClassEntity
class ClassApi(dao: BaseDao<ClassEntity>) : BaseJsonApi<ClassEntity>(dao)

View file

@ -0,0 +1,7 @@
package com.faraphel.tasks_valider.database.api.entities
import com.faraphel.tasks_valider.database.api.entities.base.BaseJsonApi
import com.faraphel.tasks_valider.database.dao.base.BaseDao
import com.faraphel.tasks_valider.database.entities.PersonEntity
class PersonApi(dao: BaseDao<PersonEntity>) : BaseJsonApi<PersonEntity>(dao)

View file

@ -0,0 +1,7 @@
package com.faraphel.tasks_valider.database.api.entities
import com.faraphel.tasks_valider.database.api.entities.base.BaseJsonApi
import com.faraphel.tasks_valider.database.dao.base.BaseDao
import com.faraphel.tasks_valider.database.entities.RelationClassPersonEntity
class RelationClassPersonApi(dao: BaseDao<RelationClassPersonEntity>) : BaseJsonApi<RelationClassPersonEntity>(dao)

View file

@ -0,0 +1,7 @@
package com.faraphel.tasks_valider.database.api.entities
import com.faraphel.tasks_valider.database.api.entities.base.BaseJsonApi
import com.faraphel.tasks_valider.database.dao.base.BaseDao
import com.faraphel.tasks_valider.database.entities.SessionEntity
class SessionApi(dao: BaseDao<SessionEntity>) : BaseJsonApi<SessionEntity>(dao)

View file

@ -0,0 +1,7 @@
package com.faraphel.tasks_valider.database.api.entities
import com.faraphel.tasks_valider.database.api.entities.base.BaseJsonApi
import com.faraphel.tasks_valider.database.dao.base.BaseDao
import com.faraphel.tasks_valider.database.entities.SubjectEntity
class SubjectApi(dao: BaseDao<SubjectEntity>) : BaseJsonApi<SubjectEntity>(dao)

View file

@ -0,0 +1,7 @@
package com.faraphel.tasks_valider.database.api.entities
import com.faraphel.tasks_valider.database.api.entities.base.BaseJsonApi
import com.faraphel.tasks_valider.database.dao.base.BaseDao
import com.faraphel.tasks_valider.database.entities.TaskEntity
class TaskApi(dao: BaseDao<TaskEntity>) : BaseJsonApi<TaskEntity>(dao)

View file

@ -0,0 +1,7 @@
package com.faraphel.tasks_valider.database.api.entities
import com.faraphel.tasks_valider.database.api.entities.base.BaseJsonApi
import com.faraphel.tasks_valider.database.dao.base.BaseDao
import com.faraphel.tasks_valider.database.entities.ValidationEntity
class ValidationApi(dao: BaseDao<ValidationEntity>) : BaseJsonApi<ValidationEntity>(dao)

View file

@ -0,0 +1,32 @@
package com.faraphel.tasks_valider.database.api.entities.base
import fi.iki.elonen.NanoHTTPD
/**
* A base for the API to handle the database operations with an HTTP server.
*/
interface BaseApi {
/**
* Handle the HEAD request
* This is used to check if a data exists in the database
*/
fun head(session: NanoHTTPD.IHTTPSession): NanoHTTPD.Response
/**
* Handle the GET request
* This is used to get data from the database
*/
fun get(session: NanoHTTPD.IHTTPSession): NanoHTTPD.Response
/**
* Handle the POST request
* This is used to insert data into the database
*/
fun post(session: NanoHTTPD.IHTTPSession): NanoHTTPD.Response
/**
* Handle the PUT request
* This is used to delete data from the database
*/
fun delete(session: NanoHTTPD.IHTTPSession): NanoHTTPD.Response
}

View file

@ -0,0 +1,72 @@
package com.faraphel.tasks_valider.database.api.entities.base
import com.faraphel.tasks_valider.database.dao.base.BaseDao
import com.faraphel.tasks_valider.database.entities.base.BaseEntity
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import fi.iki.elonen.NanoHTTPD
/**
* A base for the API to handle the database operations.
* This is preconfigured to handle JSON data.
* @param Entity the entity type to handle
*/
abstract class BaseJsonApi<Entity: BaseEntity>(private val dao: BaseDao<Entity>) : BaseApi {
companion object {
private val parser = Gson() ///< The JSON parser
}
private val entityTypeToken: TypeToken<Entity> = object: TypeToken<Entity>() {} ///< the type of the managed entity
// Requests
override fun head(session: NanoHTTPD.IHTTPSession): NanoHTTPD.Response {
val obj = parser.fromJson<Entity>(
session.inputStream.bufferedReader().readText(),
this.entityTypeToken.type
)
val exists = this.dao.exists(obj)
return NanoHTTPD.newFixedLengthResponse(
if (exists) NanoHTTPD.Response.Status.OK else NanoHTTPD.Response.Status.NOT_FOUND,
"text/plain",
if (exists) "Exists" else "Not found"
)
}
override fun get(session: NanoHTTPD.IHTTPSession): NanoHTTPD.Response {
return NanoHTTPD.newFixedLengthResponse(
NanoHTTPD.Response.Status.OK,
"application/json",
parser.toJson(this.dao.getAll())
)
}
override fun post(session: NanoHTTPD.IHTTPSession): NanoHTTPD.Response {
val obj = parser.fromJson<Entity>(
session.inputStream.bufferedReader().readText(),
this.entityTypeToken.type
)
val id = this.dao.insert(obj)
return NanoHTTPD.newFixedLengthResponse(
NanoHTTPD.Response.Status.CREATED,
"text/plain",
id.toString()
)
}
override fun delete(session: NanoHTTPD.IHTTPSession): NanoHTTPD.Response {
val obj = parser.fromJson<Entity>(
session.inputStream.bufferedReader().readText(),
this.entityTypeToken.type
)
val count = this.dao.delete(obj)
return NanoHTTPD.newFixedLengthResponse(
if (count > 0) NanoHTTPD.Response.Status.OK else NanoHTTPD.Response.Status.NOT_FOUND,
"text/plain",
count.toString()
)
}
}

View file

@ -0,0 +1,40 @@
package com.faraphel.tasks_valider.database.dao
import androidx.room.Dao
import androidx.room.Query
import com.faraphel.tasks_valider.database.dao.base.BaseDao
import com.faraphel.tasks_valider.database.entities.ClassEntity
import com.faraphel.tasks_valider.database.entities.PersonEntity
import com.faraphel.tasks_valider.database.entities.RelationClassPersonEntity
import com.faraphel.tasks_valider.database.entities.SessionEntity
@Dao
interface ClassDao : BaseDao<ClassEntity> {
@Query("SELECT * FROM ${ClassEntity.TABLE_NAME}")
override fun getAll(): List<ClassEntity>
/**
* Get the object from its identifier
*/
@Query("SELECT * FROM ${ClassEntity.TABLE_NAME} WHERE id = :id")
fun getById(id: Long): ClassEntity
/**
* Get all the sessions this class attended
* @param id the id of the class
*/
@Query("SELECT * FROM ${SessionEntity.TABLE_NAME} WHERE class_id = :id")
fun getSessions(id: Long): List<SessionEntity>
/**
* Get all the students in a class
* @param id the id of the class
*/
@Query(
"SELECT * FROM ${PersonEntity.TABLE_NAME} " +
"JOIN ${RelationClassPersonEntity.TABLE_NAME} " +
"ON ${PersonEntity.TABLE_NAME}.id = ${RelationClassPersonEntity.TABLE_NAME}.student_id " +
"WHERE ${RelationClassPersonEntity.TABLE_NAME}.class_id = :id"
)
fun getStudents(id: Long): List<PersonEntity>
}

View file

@ -1,29 +0,0 @@
package com.faraphel.tasks_valider.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import com.faraphel.tasks_valider.database.dao.base.BaseDao
import com.faraphel.tasks_valider.database.entities.Group
import com.faraphel.tasks_valider.database.entities.Student
@Dao
interface GroupDao : BaseDao<Group> {
@Query("SELECT * FROM `groups`")
override fun getAll(): List<Group>
@Query("SELECT * FROM `groups` WHERE id = :id")
fun getById(id: Long): Group
/**
Allow to get all groups with a specific student
*/
@Query(
"SELECT * FROM `groups` " +
"JOIN `group_student` ON `groups`.id = `group_student`.student_id " +
"WHERE `group_student`.student_id = :studentId"
)
@RewriteQueriesToDropUnusedColumns
fun filterByStudentId(studentId: Long): List<Group>
}

View file

@ -1,19 +0,0 @@
package com.faraphel.tasks_valider.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import com.faraphel.tasks_valider.database.dao.base.BaseDao
import com.faraphel.tasks_valider.database.entities.Group
import com.faraphel.tasks_valider.database.entities.GroupStudent
import com.faraphel.tasks_valider.database.entities.Student
@Dao
interface GroupStudentDao : BaseDao<GroupStudent> {
@Query("SELECT * FROM `group_student`")
override fun getAll(): List<GroupStudent>
@Query("SELECT * FROM `group_student` WHERE group_id = :groupId AND student_id = :studentId")
fun getById(groupId: Long, studentId: Long): GroupStudent
}

View file

@ -0,0 +1,44 @@
package com.faraphel.tasks_valider.database.dao
import androidx.room.Dao
import androidx.room.Query
import com.faraphel.tasks_valider.database.dao.base.BaseDao
import com.faraphel.tasks_valider.database.entities.*
@Dao
interface PersonDao : BaseDao<PersonEntity> {
@Query("SELECT * FROM ${PersonEntity.TABLE_NAME}")
override fun getAll(): List<PersonEntity>
/**
* Get the object from its identifier
*/
@Query("SELECT * FROM ${PersonEntity.TABLE_NAME} WHERE id = :id")
fun getById(id: Long): PersonEntity
/**
* Allow to get all the classes the person is attending as a student
*/
@Query(
"SELECT * FROM ${ClassEntity.TABLE_NAME} " +
"JOIN ${RelationClassPersonEntity.TABLE_NAME} " +
"ON ${ClassEntity.TABLE_NAME}.id = ${RelationClassPersonEntity.TABLE_NAME}.student_id " +
"WHERE ${RelationClassPersonEntity.TABLE_NAME}.student_id = :id"
)
fun getClasses(id: Long): List<ClassEntity>
/**
* Get all the tasks this user approved as a teacher
* @param id the id of the person
*/
@Query("SELECT * FROM ${ValidationEntity.TABLE_NAME} WHERE teacher_id = :id")
fun getTasksApproved(id: Long): List<ValidationEntity>
/**
* Get all the tasks this user validated as a student
* @param id the id of the person
*/
@Query("SELECT * FROM ${ValidationEntity.TABLE_NAME} WHERE student_id = :id")
fun getTasksValidated(id: Long): List<ValidationEntity>
}

View file

@ -0,0 +1,23 @@
package com.faraphel.tasks_valider.database.dao
import androidx.room.Dao
import androidx.room.Query
import com.faraphel.tasks_valider.database.dao.base.BaseDao
import com.faraphel.tasks_valider.database.entities.RelationClassPersonEntity
@Dao
interface RelationClassPersonDao : BaseDao<RelationClassPersonEntity> {
@Query("SELECT * FROM ${RelationClassPersonEntity.TABLE_NAME}")
override fun getAll(): List<RelationClassPersonEntity>
/**
* Get the object from its identifiers
*/
@Query(
"SELECT * FROM ${RelationClassPersonEntity.TABLE_NAME} " +
"WHERE " +
"class_id = :classId AND " +
"student_id = :studentId"
)
fun getById(classId: Long, studentId: Long): RelationClassPersonEntity
}

View file

@ -0,0 +1,18 @@
package com.faraphel.tasks_valider.database.dao
import androidx.room.Dao
import androidx.room.Query
import com.faraphel.tasks_valider.database.dao.base.BaseDao
import com.faraphel.tasks_valider.database.entities.SessionEntity
@Dao
interface SessionDao : BaseDao<SessionEntity> {
@Query("SELECT * FROM ${SessionEntity.TABLE_NAME}")
override fun getAll(): List<SessionEntity>
/**
* Get the object from its identifier
*/
@Query("SELECT * FROM ${SessionEntity.TABLE_NAME} WHERE id = :id")
fun getById(id: Long): SessionEntity
}

View file

@ -1,32 +0,0 @@
package com.faraphel.tasks_valider.database.dao
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import com.faraphel.tasks_valider.database.dao.base.BaseDao
import com.faraphel.tasks_valider.database.entities.Group
import com.faraphel.tasks_valider.database.entities.Person
import com.faraphel.tasks_valider.database.entities.Student
@Dao
interface StudentDao : BaseDao<Student> {
@Query("SELECT * FROM `students`")
override fun getAll(): List<Student>
@Query("SELECT * FROM `students` WHERE id = :id")
fun getById(id: Long): Student
/**
Allow to get all the students in a group
*/
@Query(
"SELECT * FROM `students` " +
"JOIN `group_student` ON `students`.id = `group_student`.student_id " +
"WHERE `group_student`.group_id = :groupId"
)
@RewriteQueriesToDropUnusedColumns
fun filterByGroupId(groupId: Long): List<Student>
}

View file

@ -0,0 +1,27 @@
package com.faraphel.tasks_valider.database.dao
import androidx.room.Dao
import androidx.room.Query
import com.faraphel.tasks_valider.database.dao.base.BaseDao
import com.faraphel.tasks_valider.database.entities.SessionEntity
import com.faraphel.tasks_valider.database.entities.SubjectEntity
import com.faraphel.tasks_valider.database.entities.TaskEntity
@Dao
interface SubjectDao : BaseDao<SubjectEntity> {
@Query("SELECT * FROM ${SubjectEntity.TABLE_NAME}")
override fun getAll(): List<SubjectEntity>
/**
* Get the object from its identifier
*/
@Query("SELECT * FROM ${SubjectEntity.TABLE_NAME} WHERE id = :id")
fun getById(id: Long): SubjectEntity
/**
* Get all the tasks available in a subject
* @param id the id of the subject
*/
@Query("SELECT * FROM ${TaskEntity.TABLE_NAME} WHERE subject_id = :id")
fun getSessions(id: Long): List<SessionEntity>
}

View file

@ -2,28 +2,25 @@ package com.faraphel.tasks_valider.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import com.faraphel.tasks_valider.database.dao.base.BaseDao
import com.faraphel.tasks_valider.database.entities.Group
import com.faraphel.tasks_valider.database.entities.Task
import com.faraphel.tasks_valider.database.entities.TaskEntity
import com.faraphel.tasks_valider.database.entities.ValidationEntity
@Dao
interface TaskDao : BaseDao<Task> {
@Query("SELECT * FROM `tasks`")
override fun getAll(): List<Task>
@Query("SELECT * FROM `tasks` WHERE id = :id")
fun getById(id: Long): Task
interface TaskDao : BaseDao<TaskEntity> {
@Query("SELECT * FROM ${TaskEntity.TABLE_NAME}")
override fun getAll(): List<TaskEntity>
/**
Get all the tasks for a specific group
* Get the object from its identifier
*/
@Query(
"SELECT * FROM `tasks` " +
"JOIN `task_group` ON `tasks`.id = `task_group`.task_id " +
"WHERE `task_group`.group_id = :groupId"
)
@RewriteQueriesToDropUnusedColumns
fun filterByGroupId(groupId: Long): List<Task>
@Query("SELECT * FROM ${TaskEntity.TABLE_NAME} WHERE id = :id")
fun getById(id: Long): TaskEntity
/**
* Get all the validations have been approved for this tasks
* @param id the id of the task
*/
@Query("SELECT * FROM ${ValidationEntity.TABLE_NAME} WHERE task_id = :id")
fun getTasksValidated(id: Long): List<ValidationEntity>
}

View file

@ -1,16 +0,0 @@
package com.faraphel.tasks_valider.database.dao
import androidx.room.Dao
import androidx.room.Query
import com.faraphel.tasks_valider.database.dao.base.BaseDao
import com.faraphel.tasks_valider.database.entities.TaskGroup
@Dao
interface TaskGroupDao : BaseDao<TaskGroup> {
@Query("SELECT * FROM `task_group`")
override fun getAll(): List<TaskGroup>
@Query("SELECT * FROM `task_group` WHERE task_id = :taskId AND group_id = :groupId")
fun getById(taskId: Long, groupId: Long): TaskGroup
}

View file

@ -1,18 +0,0 @@
package com.faraphel.tasks_valider.database.dao
import androidx.room.Dao
import androidx.room.Query
import com.faraphel.tasks_valider.database.dao.base.BaseDao
import com.faraphel.tasks_valider.database.entities.Person
import com.faraphel.tasks_valider.database.entities.Student
import com.faraphel.tasks_valider.database.entities.Teacher
@Dao
interface TeacherDao : BaseDao<Teacher> {
@Query("SELECT * FROM `teachers`")
override fun getAll(): List<Teacher>
@Query("SELECT * FROM `teachers` WHERE id = :id")
fun getById(id: Long): Teacher
}

View file

@ -0,0 +1,24 @@
package com.faraphel.tasks_valider.database.dao
import androidx.room.Dao
import androidx.room.Query
import com.faraphel.tasks_valider.database.dao.base.BaseDao
import com.faraphel.tasks_valider.database.entities.ValidationEntity
@Dao
interface ValidationDao : BaseDao<ValidationEntity> {
@Query("SELECT * FROM ${ValidationEntity.TABLE_NAME}")
override fun getAll(): List<ValidationEntity>
/**
* Get the object from its identifiers
*/
@Query(
"SELECT * FROM ${ValidationEntity.TABLE_NAME} " +
"WHERE " +
"teacher_id = :teacherId and " +
"student_id = :studentId and " +
"task_id = :taskId"
)
fun getById(teacherId: Long, studentId: Long, taskId: Long): ValidationEntity
}

View file

@ -2,18 +2,40 @@ package com.faraphel.tasks_valider.database.dao.base
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Update
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
/**
* A base DAO to handle the database operations.
* @param Entity the entity to handle
*/
interface BaseDao<Entity> {
@Insert
/**
* Check if the entities exists in the database.
*/
fun exists(vararg entities: Entity): Boolean {
return this.getAll().containsAll(entities.toList())
}
/**
* Insert the entities into the database.
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg entities: Entity): List<Long>
@Update
fun update(vararg entities: Entity): Int
/**
* Delete the entities from the database.
*/
@Delete
fun delete(vararg entities: Entity): Int
/**
* Get all the entities from the database.
* TODO(Faraphel): support filters ?
*/
fun getAll(): List<Entity>
}

View file

@ -0,0 +1,17 @@
package com.faraphel.tasks_valider.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.faraphel.tasks_valider.database.entities.base.BaseEntity
@Entity(tableName = ClassEntity.TABLE_NAME)
data class ClassEntity (
@ColumnInfo("id") @PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo("name") val name: String,
) : BaseEntity() {
companion object {
const val TABLE_NAME = "classes"
}
}

View file

@ -1,12 +0,0 @@
package com.faraphel.tasks_valider.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "groups")
data class Group (
@ColumnInfo("id") @PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo("name") val name: String? = null,
)

View file

@ -1,18 +0,0 @@
package com.faraphel.tasks_valider.database.entities
import java.util.Locale
open class Person (
open val id: Long = 0,
open val firstName: String,
open val lastName: String,
) {
/**
Get the full name of the person
*/
val fullName: String
get() {
return "${firstName.capitalize(Locale.ROOT)} ${lastName.uppercase()}"
}
}

View file

@ -0,0 +1,19 @@
package com.faraphel.tasks_valider.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.faraphel.tasks_valider.database.entities.base.BaseEntity
import java.util.*
@Entity(tableName = PersonEntity.TABLE_NAME)
data class PersonEntity (
@ColumnInfo("id") @PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo("first_name") val firstName: String,
@ColumnInfo("last_name") val lastName: String,
@ColumnInfo("card_id") val cardId: UUID,
) : BaseEntity() {
companion object {
const val TABLE_NAME = "persons"
}
}

View file

@ -3,31 +3,34 @@ package com.faraphel.tasks_valider.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import com.faraphel.tasks_valider.database.entities.Group
import com.faraphel.tasks_valider.database.entities.Student
import com.faraphel.tasks_valider.database.entities.base.BaseEntity
@Entity(
tableName = "group_student",
tableName = RelationClassPersonEntity.TABLE_NAME,
primaryKeys = [
"group_id",
"student_id"
"student_id",
"class_id",
],
foreignKeys = [
ForeignKey(
entity = Group::class,
parentColumns = ["id"],
childColumns = ["group_id"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = Student::class,
entity = PersonEntity::class,
parentColumns = ["id"],
childColumns = ["student_id"],
onDelete = ForeignKey.CASCADE
)
),
ForeignKey(
entity = ClassEntity::class,
parentColumns = ["id"],
childColumns = ["class_id"],
onDelete = ForeignKey.CASCADE
),
]
)
data class GroupStudent(
@ColumnInfo("group_id", index = true) val groupId: Long,
data class RelationClassPersonEntity (
@ColumnInfo("student_id", index = true) val studentId: Long,
)
@ColumnInfo("class_id", index = true) val classId: Long,
) : BaseEntity() {
companion object {
const val TABLE_NAME = "relation_class_person"
}
}

View file

@ -0,0 +1,31 @@
package com.faraphel.tasks_valider.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import com.faraphel.tasks_valider.database.entities.base.BaseEntity
import java.time.Instant
@Entity(
tableName = SessionEntity.TABLE_NAME,
foreignKeys = [
ForeignKey(
entity = ClassEntity::class,
parentColumns = ["id"],
childColumns = ["class_id"],
onDelete = ForeignKey.CASCADE
),
]
)
data class SessionEntity (
@ColumnInfo("id") @PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo("name") val name: String? = null,
@ColumnInfo("start") val start: Instant,
@ColumnInfo("class_id", index = true) val classId: Long? = null,
) : BaseEntity() {
companion object {
const val TABLE_NAME = "sessions"
}
}

View file

@ -1,12 +0,0 @@
package com.faraphel.tasks_valider.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "students")
class Student(
@ColumnInfo("id") @PrimaryKey(autoGenerate = true) override val id: Long = 0,
@ColumnInfo("first_name") override val firstName: String,
@ColumnInfo("last_name") override val lastName: String
) : Person(id, firstName, lastName)

View file

@ -0,0 +1,16 @@
package com.faraphel.tasks_valider.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.faraphel.tasks_valider.database.entities.base.BaseEntity
@Entity(tableName = SubjectEntity.TABLE_NAME)
data class SubjectEntity (
@ColumnInfo("id") @PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo("name") val name: String,
) : BaseEntity() {
companion object {
const val TABLE_NAME = "subjects"
}
}

View file

@ -1,12 +0,0 @@
package com.faraphel.tasks_valider.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "tasks")
data class Task (
@ColumnInfo("id") @PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo("title") val title: String,
@ColumnInfo("description") val description: String,
)

View file

@ -0,0 +1,30 @@
package com.faraphel.tasks_valider.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import com.faraphel.tasks_valider.database.entities.base.BaseEntity
@Entity(
tableName = TaskEntity.TABLE_NAME,
foreignKeys = [
ForeignKey(
entity = SubjectEntity::class,
parentColumns = ["id"],
childColumns = ["subject_id"],
onDelete = ForeignKey.CASCADE
),
]
)
data class TaskEntity (
@ColumnInfo("id") @PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo("title") val title: String,
@ColumnInfo("description") val description: String? = null,
@ColumnInfo("subject_id", index = true) val subjectId: Long,
) : BaseEntity() {
companion object {
const val TABLE_NAME = "tasks"
}
}

View file

@ -1,42 +0,0 @@
package com.faraphel.tasks_valider.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import java.time.Instant
@Entity(
tableName = "task_group",
primaryKeys = [
"task_id",
"group_id"
],
foreignKeys = [
ForeignKey(
entity = Group::class,
parentColumns = ["id"],
childColumns = ["group_id"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = Task::class,
parentColumns = ["id"],
childColumns = ["task_id"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = Teacher::class,
parentColumns = ["id"],
childColumns = ["approval_teacher_id"],
onDelete = ForeignKey.CASCADE
),
]
)
data class TaskGroup (
@ColumnInfo("task_id") val taskId: Long,
@ColumnInfo("group_id") val groupId: Long,
@ColumnInfo("approval_status") var approvalStatus: Boolean = false,
@ColumnInfo("approval_teacher_id") val approvalTeacherId: Long? = null,
@ColumnInfo("approval_time") val approvalTime: Instant? = null
)

View file

@ -1,12 +0,0 @@
package com.faraphel.tasks_valider.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "teachers")
class Teacher(
@ColumnInfo("id") @PrimaryKey(autoGenerate = true) override val id: Long = 0,
@ColumnInfo("first_name") override val firstName: String,
@ColumnInfo("last_name") override val lastName: String
) : Person(id, firstName, lastName)

View file

@ -0,0 +1,48 @@
package com.faraphel.tasks_valider.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import com.faraphel.tasks_valider.database.entities.base.BaseEntity
import java.time.Instant
@Entity(
tableName = ValidationEntity.TABLE_NAME,
primaryKeys = [
"teacher_id",
"student_id",
"task_id",
],
foreignKeys = [
ForeignKey(
entity = PersonEntity::class,
parentColumns = ["id"],
childColumns = ["teacher_id"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = PersonEntity::class,
parentColumns = ["id"],
childColumns = ["student_id"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = TaskEntity::class,
parentColumns = ["id"],
childColumns = ["task_id"],
onDelete = ForeignKey.CASCADE
),
]
)
data class ValidationEntity (
@ColumnInfo("date") val date: Instant,
@ColumnInfo("teacher_id", index = true) val teacherId: Long,
@ColumnInfo("student_id", index = true) val studentId: Long,
@ColumnInfo("task_id", index = true) val taskId: Long,
) : BaseEntity() {
companion object {
const val TABLE_NAME = "validations"
}
}

View file

@ -0,0 +1,3 @@
package com.faraphel.tasks_valider.database.entities.base
open class BaseEntity

View file

@ -0,0 +1,64 @@
package com.faraphel.tasks_valider.ui.screen.communication.internet.client
import android.app.Activity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.text.input.KeyboardType
import com.faraphel.tasks_valider.connectivity.task.TaskClient
import com.faraphel.tasks_valider.ui.screen.communication.DEFAULT_SERVER_ADDRESS
import com.faraphel.tasks_valider.ui.screen.communication.DEFAULT_SERVER_PORT
import com.faraphel.tasks_valider.ui.screen.communication.RANGE_SERVER_PORT
import com.faraphel.tasks_valider.ui.screen.task.TaskSessionScreen
@Composable
fun CommunicationInternetClientScreen(activity: Activity) {
val client = remember { mutableStateOf<TaskClient?>(null) }
if (client.value == null) CommunicationInternetClientContent(client)
else TaskSessionScreen(activity, client.value!!)
}
@Composable
fun CommunicationInternetClientContent(client: MutableState<TaskClient?>) {
val serverAddress = remember { mutableStateOf(DEFAULT_SERVER_ADDRESS) }
val serverPort = remember { mutableIntStateOf(DEFAULT_SERVER_PORT) }
Column {
// server address
TextField(
value = serverAddress.value,
onValueChange = { text ->
serverAddress.value = text
}
)
// server port
TextField(
value = serverPort.intValue.toString(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
onValueChange = { text ->
val port = text.toInt()
if (port in RANGE_SERVER_PORT) {
serverPort.intValue = port
}
}
)
Button(onClick = {
// TODO(Faraphel): check if the server is reachable
client.value = TaskClient(serverAddress.value, serverPort.intValue)
}) {
Text("Connect")
}
}
}

View file

@ -0,0 +1,36 @@
package com.faraphel.tasks_valider.ui.screen.communication.internet
import android.app.Activity
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.faraphel.tasks_valider.ui.screen.communication.internet.client.CommunicationInternetClientScreen
import com.faraphel.tasks_valider.ui.screen.communication.internet.server.CommunicationInternetServerScreen
@Composable
fun CommunicationInternetScreen(activity: Activity) {
val controller = rememberNavController()
NavHost(navController = controller, startDestination = "mode") {
composable("mode") { CommunicationInternetSelectContent(controller) }
composable("client") { CommunicationInternetClientScreen(activity) }
composable("server") { CommunicationInternetServerScreen(activity) }
}
}
@Composable
fun CommunicationInternetSelectContent(controller: NavController) {
Column {
// client mode
Button(onClick = { controller.navigate("client") }) { Text("Client") }
// server mode
Button(onClick = { controller.navigate("server") }) { Text("Server") }
}
}

View file

@ -0,0 +1,99 @@
package com.faraphel.tasks_valider.ui.screen.communication.internet.server
import android.app.Activity
import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.text.input.KeyboardType
import androidx.room.Room
import com.faraphel.tasks_valider.connectivity.task.TaskClient
import com.faraphel.tasks_valider.connectivity.task.TaskServer
import com.faraphel.tasks_valider.database.TaskDatabase
import com.faraphel.tasks_valider.ui.screen.communication.DEFAULT_SERVER_PORT
import com.faraphel.tasks_valider.ui.screen.communication.RANGE_SERVER_PORT
import com.faraphel.tasks_valider.ui.screen.task.TaskSessionScreen
@Composable
fun CommunicationInternetServerScreen(activity: Activity) {
val client = remember { mutableStateOf<TaskClient?>(null) }
// if the server is not created, prompt the user for the server configuration
if (client.value == null) CommunicationInternetServerContent(activity, client)
// else, go to the base tasks screen
else TaskSessionScreen(activity, client.value!!)
}
@Composable
fun CommunicationInternetServerContent(activity: Activity, client: MutableState<TaskClient?>) {
val expandedStudentList = remember { mutableStateOf(false) }
val serverPort = remember { mutableIntStateOf(DEFAULT_SERVER_PORT) }
Column {
// student list
Button(onClick = { expandedStudentList.value = !expandedStudentList.value }) {
Text(text = "Select Students List")
}
DropdownMenu(
expanded = expandedStudentList.value,
onDismissRequest = { expandedStudentList.value = false }
) {
DropdownMenuItem(
text = { Text("ISRI") },
onClick = {}
)
DropdownMenuItem(
text = { Text("MIAGE") },
onClick = {}
)
// TODO(Faraphel): student lists should be loaded from the database or a file
}
// server port
TextField(
value = serverPort.intValue.toString(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
onValueChange = { text ->
val port = text.toInt()
if (port in RANGE_SERVER_PORT) {
serverPort.intValue = port
}
}
)
Button(onClick = {
// Reset the database | TODO(Faraphel): only for testing purpose
activity.deleteDatabase("local")
// Create the database
val database = Room.databaseBuilder(
activity,
TaskDatabase::class.java,
"local"
).build()
// Create the server
Log.i("room-server", "creating the server")
Thread { // a thread is used for networking
val server = TaskServer(serverPort.intValue, database)
server.start()
// Get the client from the server
client.value = server.getClientAdmin()
}.start()
}) {
Text("Create")
}
}
}

View file

@ -0,0 +1,77 @@
package com.faraphel.tasks_valider.ui.screen.communication
import android.app.Activity
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.faraphel.tasks_valider.connectivity.bwf.BwfManager
import com.faraphel.tasks_valider.ui.screen.communication.internet.CommunicationInternetScreen
import com.faraphel.tasks_valider.ui.screen.communication.wifiP2p.CommunicationWifiP2pScreen
/**
* CommunicationController is the main controller for the communication screen.
* It is responsible for handling the navigation between the different communication methods.
* It is also responsible for initializing the communication methods.
* @param activity: The activity that hosts the communication screen.
*/
@Composable
fun CommunicationScreen(activity: Activity) {
val controller = rememberNavController()
NavHost(
navController = controller,
startDestination = "select"
) {
composable("select") {
CommunicationSelectContent(controller, activity)
}
composable("internet") {
CommunicationInternetScreen(activity)
}
composable("wifi-p2p") {
val bwfManager = BwfManager.fromActivity(activity)
CommunicationWifiP2pScreen(activity, bwfManager)
}
}
}
/**
* Communication screen that allows the user to choose the communication mode
*/
@Composable
fun CommunicationSelectContent(controller: NavController, activity: Activity) {
val isWifiP2pSupported = BwfManager.isSupported(activity)
Column {
// internet communication mode
Button(onClick = { controller.navigate("internet") }) {
Text("Internet")
}
// wifi-direct communication mode
Button(
colors = ButtonDefaults.buttonColors(
// if the WiFi-Direct is not supported, the button is grayed out
containerColor = if (isWifiP2pSupported) Color.Unspecified else Color.Gray
),
onClick = {
// if the WiFi-Direct is supported, navigate to the WiFi-Direct screen
if (isWifiP2pSupported) controller.navigate("wifi-p2p")
// if the WiFi-Direct is not supported, show a toast message
else Toast.makeText(activity, "WiFi-Direct is not supported on this device", Toast.LENGTH_SHORT).show()
}
) {
Text("WiFi-Direct")
}
}
}

View file

@ -0,0 +1,6 @@
package com.faraphel.tasks_valider.ui.screen.communication
const val DEFAULT_SERVER_ADDRESS: String = "127.0.0.1"
const val DEFAULT_SERVER_PORT: Int = 9876
val RANGE_SERVER_PORT: IntRange = 1024..65535

View file

@ -0,0 +1,57 @@
package com.faraphel.tasks_valider.ui.screen.communication.wifiP2p.client
import android.app.Activity
import android.net.wifi.p2p.WifiP2pConfig
import android.net.wifi.p2p.WifiP2pDevice
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import com.faraphel.tasks_valider.connectivity.bwf.BwfManager
import com.faraphel.tasks_valider.ui.widgets.connectivity.WifiP2pDeviceListWidget
@Composable
fun CommunicationWifiP2pClientScreen(activity: Activity, bwfManager: BwfManager) {
val selectedDevice = remember { mutableStateOf<WifiP2pDevice?>(null) }
val isConnected = remember { mutableStateOf(false) }
// if connected, show the task group screen
if (isConnected.value) {
// TaskGroupScreen(activity, null)
// TODO(Faraphel): finish the connection
return
}
// if the device is selected but not connected, try to connect
if (selectedDevice.value != null) {
// TODO(Faraphel): error handling
val config = WifiP2pConfig().apply {
deviceAddress = selectedDevice.value!!.deviceAddress
}
bwfManager.connect(config) {
isConnected.value = true
}
return
}
// display the list of devices
CommunicationWifiP2pClientContent(bwfManager, selectedDevice)
}
@Composable
fun CommunicationWifiP2pClientContent(
bwfManager: BwfManager,
selectedDevice: MutableState<WifiP2pDevice?>
) {
Column {
WifiP2pDeviceListWidget(
peers = bwfManager.statePeers.value,
filter = { device: WifiP2pDevice -> device.isGroupOwner },
selectedDevice,
)
}
}

View file

@ -0,0 +1,37 @@
package com.faraphel.tasks_valider.ui.screen.communication.wifiP2p
import android.app.Activity
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.faraphel.tasks_valider.connectivity.bwf.BwfManager
import com.faraphel.tasks_valider.ui.screen.communication.wifiP2p.client.CommunicationWifiP2pClientScreen
import com.faraphel.tasks_valider.ui.screen.communication.wifiP2p.server.CommunicationWifiP2pServerScreen
@Composable
fun CommunicationWifiP2pScreen(activity: Activity, bwfManager: BwfManager) {
val controller = rememberNavController()
NavHost(navController = controller, startDestination = "mode") {
composable("mode") { CommunicationWifiP2pSelectContent(controller) }
composable("client") { CommunicationWifiP2pClientScreen(activity, bwfManager) }
composable("server") { CommunicationWifiP2pServerScreen(activity, bwfManager) }
}
}
@Composable
fun CommunicationWifiP2pSelectContent(controller: NavController) {
Column {
// client mode
Button(onClick = { controller.navigate("client") }) { Text("Client") }
// server mode
Button(onClick = { controller.navigate("server") }) { Text("Server") }
}
}

View file

@ -0,0 +1,106 @@
package com.faraphel.tasks_valider.ui.screen.communication.wifiP2p.server
import android.app.Activity
import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.text.input.KeyboardType
import androidx.room.Room
import com.faraphel.tasks_valider.connectivity.bwf.BwfManager
import com.faraphel.tasks_valider.connectivity.task.TaskClient
import com.faraphel.tasks_valider.connectivity.task.TaskServer
import com.faraphel.tasks_valider.database.TaskDatabase
import com.faraphel.tasks_valider.ui.screen.communication.DEFAULT_SERVER_PORT
import com.faraphel.tasks_valider.ui.screen.communication.RANGE_SERVER_PORT
import com.faraphel.tasks_valider.ui.screen.task.TaskSessionScreen
@Composable
fun CommunicationWifiP2pServerScreen(activity: Activity, bwfManager: BwfManager) {
val client = remember { mutableStateOf<TaskClient?>(null) }
// if the server is not created, prompt the user for the server configuration
if (client.value == null) CommunicationWifiP2pServerContent(activity, bwfManager, client)
// else, go to the base tasks screen
else TaskSessionScreen(activity, client.value!!)
}
@Composable
fun CommunicationWifiP2pServerContent(
activity: Activity,
bwfManager: BwfManager,
client: MutableState<TaskClient?>
) {
val expandedStudentList = remember { mutableStateOf(false) }
val serverPort = remember { mutableIntStateOf(DEFAULT_SERVER_PORT) }
Column {
// student list
Button(onClick = { expandedStudentList.value = !expandedStudentList.value }) {
Text(text = "Select Students List")
}
DropdownMenu(
expanded = expandedStudentList.value,
onDismissRequest = { expandedStudentList.value = false }
) {
DropdownMenuItem(
text = { Text("ISRI") },
onClick = {}
)
DropdownMenuItem(
text = { Text("MIAGE") },
onClick = {}
)
// TODO(Faraphel): student lists should be loaded from the database or a file
}
// server port
TextField(
value = serverPort.intValue.toString(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
onValueChange = { text ->
val port = text.toInt()
if (port in RANGE_SERVER_PORT) {
serverPort.intValue = port
}
}
)
Button(onClick = {
// TODO(Faraphel): should be merged with the internet server
// Reset the database | TODO(Faraphel): only for testing purpose
activity.deleteDatabase("local")
// Create the database
val database = Room.databaseBuilder(
activity,
TaskDatabase::class.java,
"local"
).build()
bwfManager.recreateGroup {
// Create the server
Log.i("room-server", "creating the server")
val server = TaskServer(serverPort.intValue, database)
server.start()
// Get the client from the server
client.value = server.getClientAdmin()
}
}) {
Text("Create")
}
}
}

View file

@ -0,0 +1,55 @@
package com.faraphel.tasks_valider.ui.screen.task
import android.app.Activity
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.faraphel.tasks_valider.connectivity.task.TaskClient
/**
* This screen represent a session
* @param activity the android activity
* @param client an HTTP client that can communicate with the server
*/
@Composable
fun TaskSessionScreen(activity: Activity, client: TaskClient) {
Text("WIP : Session Screen")
/*
val students = remember { mutableStateOf<List<TaskGroupEntity>?>(null) }
// title
Text(text = "Task Group")
// if the groups are not yet defined, refresh the list
if (groups.value == null) {
Thread { refreshGroups(activity, client, groups) }.start()
return
}
// if the groups have already been defined, display them
for (group in groups.value!!) {
Text(text = group.toString())
}
*/
}
/*
fun refreshGroups(activity: Activity, client: TaskClient, groups: MutableState<List<TaskGroupEntity>?>) {
// try to obtain the list of groups
val response = client.get("entities/group")
// in case of error, notify it
if (!response.isSuccessful) {
Toast.makeText(activity, response.message, Toast.LENGTH_LONG).show()
return
}
// parse the list of groups
groups.value = jsonParser.fromJson(
response.body.toString(),
object : TypeToken<List<TaskGroupEntity>>(){}
)
}
*/

View file

@ -1,18 +0,0 @@
package com.faraphel.tasks_valider.ui.widgets
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.faraphel.tasks_valider.database.entities.Group
@Composable
fun WidgetGroup(group: Group) {
// TODO
Column {
Text(text = group.name!!)
// group.tasks.forEach { task ->
// WidgetTask(task)
// }
}
}

View file

@ -1,15 +0,0 @@
package com.faraphel.tasks_valider.ui.widgets
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.faraphel.tasks_valider.database.entities.Task
@Composable
fun WidgetTask(task: Task) {
// task information
Column {
Text(text = task.title)
Text(text = task.description)
}
}

View file

@ -1,54 +0,0 @@
package com.faraphel.tasks_valider.ui.widgets
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.faraphel.tasks_valider.database.Database
import com.faraphel.tasks_valider.database.entities.TaskGroup
@Composable
fun WidgetTaskStudent(database: Database, taskStudent: TaskGroup) {
val teacherDao = database.teacherDao()
Column {
// row for this task
Row {
// task information
// TODO: WidgetTask(task = taskStudent.task)
// align the other columns to the right
Spacer(modifier = Modifier.weight(1f))
// task status
Checkbox(
checked = taskStudent.approvalStatus,
onCheckedChange = { status -> taskStudent.approvalStatus = status }
)
}
// if the task has been approved
if (taskStudent.approvalStatus) {
Row (
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.Center
) {
// teacher who approved the task
Text(text = teacherDao.getById(taskStudent.approvalTeacherId!!).fullName)
// align the other columns to the right
Spacer(modifier = Modifier.width(16.dp))
// date of approval
Text(text = taskStudent.approvalTime.toString())
}
}
}
}

View file

@ -0,0 +1,39 @@
package com.faraphel.tasks_valider.ui.widgets.connectivity
import android.net.wifi.p2p.WifiP2pDevice
import android.net.wifi.p2p.WifiP2pDeviceList
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
/**
* Represent a list of WiFi-Direct devices.
* @param peers the list of peers to represent
* @param filter a filter for the peers
* @param deviceState a state containing the selected device
*/
@Composable
fun WifiP2pDeviceListWidget(
peers: WifiP2pDeviceList?,
filter: ((WifiP2pDevice) -> Boolean)? = null,
deviceState: MutableState<WifiP2pDevice?>? = null,
) {
Text(text = "Devices (${peers?.deviceList?.size ?: 0})")
Column {
// if there are peers to display
if (peers != null) {
// for every device in the list
for (device in peers.deviceList) {
// if the filter (if set) does not apply to this device, ignore it
if (filter != null && !filter(device))
continue
// create a new object for the device
WifiP2pDeviceWidget(device, deviceState)
}
}
}
}

View file

@ -0,0 +1,44 @@
package com.faraphel.tasks_valider.ui.widgets.connectivity
import android.net.wifi.p2p.WifiP2pDevice
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.graphics.Color
/**
* A widget that represent a WiFi-Direct device.
* @param device the device that should be represented
* @param deviceState a state that will be updated to the device when it is selected
*/
@Composable
fun WifiP2pDeviceWidget(device: WifiP2pDevice, deviceState: MutableState<WifiP2pDevice?>? = null) {
Button(onClick = { if (deviceState != null) deviceState.value = device }) {
Column {
Row {
Text(text = "Name: ")
Text(text = device.deviceName)
}
Row {
Text(text = "Is Owner: ")
Text(text = device.isGroupOwner.toString())
}
Row {
Text(text = "Address: ", color = Color.LightGray)
Text(text = device.deviceAddress, color = Color.LightGray)
}
Row {
Text(text = "Primary Type: ", color = Color.Green)
Text(text = device.primaryDeviceType, color = Color.Green)
}
Row {
Text(text = "Status: ", color = Color.Yellow)
Text(text = device.status.toString(), color = Color.Yellow)
}
}
}
}

View file

@ -1,6 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id("com.android.application") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.0" apply false
id("com.google.devtools.ksp") version "1.9.21-1.0.15" apply false
}
kotlin("android") version "1.9.23" apply false
kotlin("plugin.serialization") version "1.9.23" apply false
}

0
gradlew vendored Executable file → Normal file
View file

178
gradlew.bat vendored
View file

@ -1,89 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega