From 48e66a539ea21f8dd494bbc5a6c03da07025f667 Mon Sep 17 00:00:00 2001 From: lucasroyerdev Date: Thu, 15 Jan 2026 13:25:56 +0100 Subject: [PATCH] feat: add local web server control --- app/build.gradle | 7 +- app/src/main/AndroidManifest.xml | 2 + .../com/stock/pignon/CartActionHelper.java | 7 +- .../java/com/stock/pignon/ControlServer.java | 216 ++++++++++++++++++ .../java/com/stock/pignon/MainActivity.java | 74 +++++- app/src/main/res/values-night/themes.xml | 6 +- app/src/main/res/values/colors.xml | 6 +- app/src/main/res/values/themes.xml | 8 +- 8 files changed, 301 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/stock/pignon/ControlServer.java diff --git a/app/build.gradle b/app/build.gradle index e8d865b..8580aa1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,8 @@ android { applicationId "com.stock.pignon" minSdkVersion 17 targetSdkVersion 36 - versionCode 1 - versionName "0.1.0" + versionCode 2 + versionName "0.2.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -41,6 +41,9 @@ dependencies { // GIF implementation 'com.github.bumptech.glide:glide:3.8.0' + // NanoHTTPD + implementation 'org.nanohttpd:nanohttpd:2.3.1' + // Tests testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3e38fd8..8a3bec6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,8 @@ + + cartItems) { - File stockFile = new File(Environment.getExternalStorageDirectory(), Config.STOCK_FILE_NAME); + File dir = new File(Environment.getExternalStorageDirectory(), Config.EXTERNAL_DIR_NAME); + File stockFile = new File(dir, Config.STOCK_FILE_NAME); + Gson gson = new Gson(); Map stockMap; diff --git a/app/src/main/java/com/stock/pignon/ControlServer.java b/app/src/main/java/com/stock/pignon/ControlServer.java new file mode 100644 index 0000000..def4ca4 --- /dev/null +++ b/app/src/main/java/com/stock/pignon/ControlServer.java @@ -0,0 +1,216 @@ +// ControlServer.java +package com.stock.pignon; + +import android.os.Environment; + +import fi.iki.elonen.NanoHTTPD; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.HashMap; +import java.util.Map; +import java.util.List; + +/** + * Create a web server to remote management of app assets. + */ +// Inherit what is needed to build a web server +public class ControlServer extends NanoHTTPD { + + // Assets folders + private final File storageRoot = Environment.getExternalStorageDirectory(); + private final File baseDir = new File(storageRoot, Config.EXTERNAL_DIR_NAME); + private final File imagesDir = new File(baseDir, Config.IMAGES_SUBDIR_NAME); + + public ControlServer(int port) { + super(port); + } + + /** + * Main method which receive remote request + */ + @Override + public Response serve(IHTTPSession session) { + // Get requested address + String uri = session.getUri(); + + // Adapt URL to find the file : /stock.json become stock.json + if (uri.equals("/download_orders")) { + return downloadFile(Config.STOCK_FILE_NAME); + } + if (uri.equals("/download_json")) { + return downloadFile(Config.PIECES_FILE_NAME); + } + + // File upload management + if (session.getMethod() == Method.POST) { + if (uri.equals("/upload_json")) { + return handleJsonUpload(session); + } else if (uri.equals("/upload_images")) { + return handleImagesUpload(session); + } + } + + // Return UI + return newFixedLengthResponse(getHtmlResponse()); + } + + /** + * HTML UI for users + */ + private String getHtmlResponse() { + return "" + + "" + + "" + + "" + + "" + + "" + + "" + + "

Application : stock-pignon

" + + + "

Gestion des stocks

" + + "
" + + "

Récupérer les sacoches des adhérent·es

" + + "

Télécharger le décompte des pièces sorties de l'atelier.

" + + "Télécharger stock.json" + + "
" + + + "

Modifier le catalogue

" + + "
" + + "

Gestion du fichier JSON

" + + "

Catalogue actuel : Télécharger pieces.json

" + + "
" + + "
" + + "

Envoyer un nouveau catalogue :

" + + "
" + + "" + + "
" + + "
" + + + "
" + + "

Gestion des images

" + + "
" + + "

Ajouter/Modifier des images (PNG/JPG) :

" + + "
" + + "" + + "
" + + "
" + + + "" + + ""; + } + + /** + * Manage JSON sent by remote. + */ + private Response handleJsonUpload(IHTTPSession session) { + Map tmpFiles = new HashMap<>(); + try { + session.parseBody(tmpFiles); + // NanoHTTPD stocke le fichier avec le nom du champ HTML (json_file) + String tmpPath = tmpFiles.get("json_file"); + if (tmpPath == null) return newFixedLengthResponse("❌ Aucun fichier reçu."); + + File src = new File(tmpPath); + File dest = new File(baseDir, Config.PIECES_FILE_NAME); + + copyFile(src, dest); + return newFixedLengthResponse("✅ Catalogue mis à jour ! Retour"); + } catch (Exception e) { + return newFixedLengthResponse("❌ Erreur JSON : " + e.getMessage()); + } + } + + /** + * Manage multiple images sent by remote. + */ + private Response handleImagesUpload(IHTTPSession session) { + Map tmpFiles = new HashMap<>(); + try { + session.parseBody(tmpFiles); + + if (!imagesDir.exists() && !imagesDir.mkdirs()) { + return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, + "❌ Erreur : Impossible de créer le dossier des images sur la tablette."); + } + + int count = 0; + + // On parcourt les fichiers temporaires créés par NanoHTTPD + for (Map.Entry entry : tmpFiles.entrySet()) { + // NanoHTTPD indexe les envois multiples (images, images1, images2...) + if (entry.getKey().startsWith("images")) { + String tmpPath = entry.getValue(); + + // Récupération sécurisée des paramètres pour obtenir le nom original + List params = session.getParameters().get(entry.getKey()); + + if (params != null && !params.isEmpty()) { + // Nettoyage du nom de fichier (pour ne garder que "image.jpg" sans le chemin PC) + String originalName = new File(params.get(0)).getName(); + + File src = new File(tmpPath); + File dest = new File(imagesDir, originalName); + + copyFile(src, dest); + count++; + } + } + } + + return newFixedLengthResponse("✅ " + count + " images enregistrées ! Retour"); + + } catch (Exception e) { + return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, + "❌ Erreur serveur : " + e.getMessage()); + } + } + + /** + * Prepare local file to send it to remote. + */ + private Response downloadFile(String filename) { + File file = new File(baseDir, filename); + if (!file.exists()) { + return newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "❌ Fichier non trouvé : " + filename); + } + try { + InputStream is = new FileInputStream(file); + Response res = newChunkedResponse(Response.Status.OK, "application/octet-stream", is); + + // Force file download name + res.addHeader("Content-Disposition", "attachment; filename=\"" + filename + "\""); + + return res; + } catch (IOException e) { + return newFixedLengthResponse("❌ Erreur de lecture."); + } + } + + /** + * Utility method to copy file byte per byte. + * Not always possible to simply move a file on Android. + */ + private void copyFile(File src, File dst) throws IOException { + try (InputStream in = new FileInputStream(src); + OutputStream out = new FileOutputStream(dst)) { + byte[] buf = new byte[1024]; + int len; + while ((len = in.read(buf)) > 0) { + out.write(buf, 0, len); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/stock/pignon/MainActivity.java b/app/src/main/java/com/stock/pignon/MainActivity.java index 139fa67..d79fa8e 100644 --- a/app/src/main/java/com/stock/pignon/MainActivity.java +++ b/app/src/main/java/com/stock/pignon/MainActivity.java @@ -1,3 +1,4 @@ +// MainActivity.java package com.stock.pignon; import android.graphics.Color; @@ -31,7 +32,7 @@ public class MainActivity extends AppCompatActivity { private static final String TAG = "MainActivity"; - // UI Components + // UI components private LinearLayout cartList; private LinearLayout homeLayout; private LinearLayout categoryItemsLayout; @@ -39,6 +40,9 @@ public class MainActivity extends AppCompatActivity { private GridLayout gridPieces; private ImageView mainImage; + // Server component + private ControlServer server; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -74,6 +78,45 @@ public class MainActivity extends AppCompatActivity { // Initial cart visual sync CartViewHelper.updateCartView(cartList, this); + + // Action bar + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayShowHomeEnabled(true); + getSupportActionBar().setTitle(" 🚲 Atelier du Pignon - Gestion du stock à prix libre"); + } + + // Launch server + server = new ControlServer(8080); + try { + server.start(); + String url = "http://" + getDeviceIP() + ":8080"; + + // On met l'URL directement dans le sous-titre de l'ActionBar + if (getSupportActionBar() != null) { + getSupportActionBar().setSubtitle("🟢 Serveur actif : " + url); + } + Log.i(TAG, "Serveur démarré sur : " + url); + } catch (IOException e) { + if (getSupportActionBar() != null) { + getSupportActionBar().setSubtitle("🔴 Erreur serveur : Port 8080 occupé"); + } + } + } + + @Override + protected void onResume() { + super.onResume(); + // Ensure cart is up to date if returning to the activity + CartViewHelper.updateCartView(cartList, this); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (server != null) { + server.stop(); + Log.i(TAG, "Serveur arrêté."); + } } /** @@ -239,13 +282,6 @@ public class MainActivity extends AppCompatActivity { return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics()); } - @Override - protected void onResume() { - super.onResume(); - // Ensure cart is up to date if returning to the activity - CartViewHelper.updateCartView(cartList, this); - } - private void copyAssetsIfEmpty() { File folder = new File(Environment.getExternalStorageDirectory(), Config.EXTERNAL_DIR_NAME); @@ -301,4 +337,26 @@ public class MainActivity extends AppCompatActivity { Log.e("MainActivity", "Erreur copie asset: " + assetName, e); } } + + /** + * Get device IP to print it to users for easy remote control + */ + private String getDeviceIP() { + try { + java.util.Enumeration interfaces = java.net.NetworkInterface.getNetworkInterfaces(); + while (interfaces.hasMoreElements()) { + java.net.NetworkInterface iface = interfaces.nextElement(); + java.util.Enumeration addresses = iface.getInetAddresses(); + while (addresses.hasMoreElements()) { + java.net.InetAddress addr = addresses.nextElement(); + if (!addr.isLoopbackAddress() && addr instanceof java.net.Inet4Address) { + return addr.getHostAddress(); + } + } + } + } catch (Exception e) { + Log.e(TAG, "Erreur IP", e); + } + return "127.0.0.1"; + } } \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 6492ac8..2faca90 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -2,9 +2,9 @@ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index dd69282..3ebe33e 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,6 +1,6 @@ - #FF6200EE - #FF3700B3 - #FF03DAC5 + #0049AF + #003580 + #FFFFFF \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index d402b39..2faca90 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -2,11 +2,9 @@