diff --git a/android/app/build.gradle b/android/app/build.gradle index 5b47490c..6d16b3ae 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -94,4 +94,5 @@ dependencies { androidTestImplementation 'com.android.support:multidex:2.0.1' implementation "androidx.core:core:1.9.0" implementation 'androidx.appcompat:appcompat:1.6.0' + compileOnly 'com.datalogic:datalogic-android-sdk:1.50' } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e21a83bf..cba340f0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -15,11 +15,22 @@ + + + + + + + + + diff --git a/android/app/src/main/java/inventree/inventree_app/MainActivity.java b/android/app/src/main/java/inventree/inventree_app/MainActivity.java index 6d88807f..232e24f2 100644 --- a/android/app/src/main/java/inventree/inventree_app/MainActivity.java +++ b/android/app/src/main/java/inventree/inventree_app/MainActivity.java @@ -1,6 +1,172 @@ package inventree.inventree_app; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.RestrictionsManager; +import android.os.Build; +import android.os.Bundle; + +import androidx.annotation.NonNull; + +import java.util.HashMap; +import java.util.Map; + import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.engine.FlutterEngine; +import io.flutter.plugin.common.EventChannel; +import io.flutter.plugin.common.MethodChannel; public class MainActivity extends FlutterActivity { + + static final String ACTION_CUSTOM_SCAN = "inventree.inventree_app.SCAN"; + + // Datalogic default wedge settings + static final String ACTION_DATALOGIC_DECODE = "com.datalogic.decodewedge.decode_action"; + static final String CATEGORY_DATALOGIC_DECODE = "com.datalogic.decodewedge.decode_category"; + + private static final String EVENT_CHANNEL = "inventree/datawedge_scans"; + private static final String MANAGED_CONFIG_CHANNEL = "inventree/managed_config"; + private static final String WEDGE_CHANNEL = "inventree/wedge"; + + private EventChannel.EventSink eventSink; + + private final BroadcastReceiver scanReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null) return; + + String action = intent.getAction(); + if (action == null) return; + + // Check if it matches one of the strings + if (!ACTION_CUSTOM_SCAN.equals(action) && !ACTION_DATALOGIC_DECODE.equals(action)) { + return; + } + + String data = intent.getStringExtra("com.symbol.datawedge.data_string"); + if (data == null || data.isEmpty()) { + data = intent.getStringExtra("com.datalogic.decode.intentwedge.barcode_string"); + } + if (data == null || data.isEmpty()) return; + if (eventSink == null) return; + + Map payload = new HashMap<>(); + payload.put("data", data); + payload.put("action", action); + eventSink.success(payload); + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Auto-configure the datawedge for Zebra/Datalogic + WedgeConfigurator.configure(this); + } + + @Override + public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { + super.configureFlutterEngine(flutterEngine); + + new EventChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), EVENT_CHANNEL) + .setStreamHandler(new EventChannel.StreamHandler() { + @Override + public void onListen(Object arguments, EventChannel.EventSink events) { + eventSink = events; + } + + @Override + public void onCancel(Object arguments) { + eventSink = null; + } + }); + + new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), MANAGED_CONFIG_CHANNEL) + .setMethodCallHandler((call, result) -> { + if ("getManagedConfiguration".equals(call.method)) { + Map config = new HashMap<>(); + + RestrictionsManager rm = + (RestrictionsManager) getSystemService(Context.RESTRICTIONS_SERVICE); + + if (rm != null) { + Bundle restrictions = rm.getApplicationRestrictions(); + + if (restrictions != null) { + for (String key : restrictions.keySet()) { + @SuppressWarnings("deprecation") + Object value = restrictions.get(key); + + if (value instanceof Boolean + || value instanceof Integer + || value instanceof Long + || value instanceof Double + || value instanceof String) { + config.put(key, value); + } else if (value instanceof String[]) { + config.put(key, java.util.Arrays.asList((String[]) value)); + } else if (value != null) { + config.put(key, value.toString()); + } + } + } + } + + result.success(config); + } else { + result.notImplemented(); + } + }); + + new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), WEDGE_CHANNEL) + .setMethodCallHandler((call, result) -> { + if ("detectWedge".equals(call.method)) { + String vendor = WedgeConfigurator.detectVendor(this); + + Map info = new HashMap<>(); + info.put("vendor", vendor); + info.put("supported", !WedgeConfigurator.VENDOR_NONE.equals(vendor)); + + result.success(info); + } else { + result.notImplemented(); + } + }); + } + + @Override + public void cleanUpFlutterEngine(@NonNull FlutterEngine flutterEngine) { + eventSink = null; + super.cleanUpFlutterEngine(flutterEngine); + } + + @Override + protected void onResume() { + super.onResume(); + + IntentFilter filter = new IntentFilter(); + filter.addAction(ACTION_CUSTOM_SCAN); + filter.addAction(ACTION_DATALOGIC_DECODE); + filter.addCategory(CATEGORY_DATALOGIC_DECODE); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(scanReceiver, filter, Context.RECEIVER_EXPORTED); + } else { + registerReceiver(scanReceiver, filter); + } + } + + @Override + protected void onPause() { + try { + unregisterReceiver(scanReceiver); + } catch (IllegalArgumentException ignored) { + // Receiver was never registered + } + super.onPause(); + } + } diff --git a/android/app/src/main/java/inventree/inventree_app/WedgeConfigurator.java b/android/app/src/main/java/inventree/inventree_app/WedgeConfigurator.java new file mode 100644 index 00000000..2af774fe --- /dev/null +++ b/android/app/src/main/java/inventree/inventree_app/WedgeConfigurator.java @@ -0,0 +1,225 @@ +package inventree.inventree_app; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import com.datalogic.decode.BarcodeManager; +import com.datalogic.decode.configuration.IntentDeliveryMode; +import com.datalogic.decode.configuration.ScannerProperties; +import com.datalogic.device.ErrorManager; +import com.datalogic.device.configuration.ConfigException; + +public class WedgeConfigurator { + + // Zebra specific settings + private static final String DATAWEDGE_PROFILE = "InvenTree"; + public static final String ZEBRA_BROADCAST_INTENT = "2"; + private static final String DATAWEDGE_API_ACTION = "com.symbol.datawedge.api.ACTION"; + private static final String DATAWEDGE_SET_CONFIG = "com.symbol.datawedge.api.SET_CONFIG"; + + // Datalogic retry settings + private static final int DATALOGIC_MAX_ATTEMPTS = 5; + private static final long DATALOGIC_RETRY_DELAY_MS = 2000; + + // Vendor identifiers + public static final String VENDOR_ZEBRA = "zebra"; + public static final String VENDOR_DATALOGIC = "datalogic"; + public static final String VENDOR_NONE = "none"; + + private static final Map VENDOR_PACKAGES = buildVendorPackages(); + + // Spawn a new thread for Datalogic calls + private static final ExecutorService DATALOGIC_EXECUTOR = Executors.newSingleThreadExecutor(); + + private static Map buildVendorPackages() { + Map map = new LinkedHashMap<>(); + + map.put(VENDOR_ZEBRA, new String[] {"com.symbol.datawedge"}); + + map.put( + VENDOR_DATALOGIC, + new String[] { + "com.datalogic.decode", + "com.datalogic.decodewedge", + "com.datalogic.systemsettings" + }); + + return map; + } + + public static String detectVendor(Context context) { + try { + PackageManager pm = context.getPackageManager(); + + if (pm == null) { + return VENDOR_NONE; + } + + for (Map.Entry entry : VENDOR_PACKAGES.entrySet()) { + for (String packageName : entry.getValue()) { + if (isPackageInstalled(pm, packageName)) { + return entry.getKey(); + } + } + } + } catch (Throwable e) { + // PackageManager lookup failed + } + + return detectVendorByManufacturer(); + } + + private static String detectVendorByManufacturer() { + String manufacturer = Build.MANUFACTURER == null ? "" : Build.MANUFACTURER.toLowerCase(); + + if (manufacturer.contains("zebra") || manufacturer.contains("symbol")) { + return VENDOR_ZEBRA; + } + + if (manufacturer.contains("datalogic")) { + return VENDOR_DATALOGIC; + } + + return VENDOR_NONE; + } + + private static boolean isPackageInstalled(PackageManager pm, String packageName) { + try { + pm.getPackageInfo(packageName, 0); + return true; + } catch (PackageManager.NameNotFoundException e) { + return false; + } catch (Throwable e) { + return false; + } + } + + public static void configure(Context context) { + try { + String vendor = detectVendor(context); + + if (VENDOR_ZEBRA.equals(vendor)) { + configureZebra(context); + } else if (VENDOR_DATALOGIC.equals(vendor)) { + scheduleDatalogicConfig(1); + } + } catch (Throwable e) { + // Vendor detection was unsuccessful + } + } + + /** + * Schedule the Datalogic wedge configuration. + * @param attempt Current attempt number + */ + private static void scheduleDatalogicConfig(int attempt) { + new Handler(Looper.getMainLooper()) + .postDelayed( + () -> DATALOGIC_EXECUTOR.execute(() -> { + try { + configureDatalogic(); + } catch (Throwable e) { + if (attempt < DATALOGIC_MAX_ATTEMPTS) { + scheduleDatalogicConfig(attempt + 1); + } + } + }), + DATALOGIC_RETRY_DELAY_MS); + } + + /** + * Configure Zebra datawedge by creating a custom profile for the app. + * @param context MainActivity instance passed in as context + */ + private static void configureZebra(Context context) { + Bundle profileConfig = new Bundle(); + profileConfig.putString("PROFILE_NAME", DATAWEDGE_PROFILE); + profileConfig.putString("PROFILE_ENABLED", "true"); + profileConfig.putString("CONFIG_MODE", "CREATE_IF_NOT_EXIST"); + + Bundle appConfig = new Bundle(); + appConfig.putString("PACKAGE_NAME", context.getPackageName()); + appConfig.putStringArray("ACTIVITY_LIST", new String[] {"*"}); + profileConfig.putParcelableArray("APP_LIST", new Bundle[] {appConfig}); + + ArrayList plugins = new ArrayList<>(); + + Bundle barcodePlugin = new Bundle(); + barcodePlugin.putString("PLUGIN_NAME", "BARCODE"); + barcodePlugin.putString("RESET_CONFIG", "true"); + + Bundle barcodeProps = new Bundle(); + barcodeProps.putString("scanner_input_enabled", "true"); + barcodeProps.putString("scanner_selection", "auto"); + barcodePlugin.putBundle("PARAM_LIST", barcodeProps); + plugins.add(barcodePlugin); + + Bundle intentPlugin = new Bundle(); + intentPlugin.putString("PLUGIN_NAME", "INTENT"); + intentPlugin.putString("RESET_CONFIG", "true"); + + Bundle intentProps = new Bundle(); + intentProps.putString("intent_output_enabled", "true"); + intentProps.putString("intent_action", MainActivity.ACTION_CUSTOM_SCAN); + intentProps.putString("intent_delivery", ZEBRA_BROADCAST_INTENT); + intentPlugin.putBundle("PARAM_LIST", intentProps); + plugins.add(intentPlugin); + + Bundle keystrokePlugin = new Bundle(); + keystrokePlugin.putString("PLUGIN_NAME", "KEYSTROKE"); + keystrokePlugin.putString("RESET_CONFIG", "true"); + + Bundle keystrokeProps = new Bundle(); + keystrokeProps.putString("keystroke_output_enabled", "false"); + keystrokePlugin.putBundle("PARAM_LIST", keystrokeProps); + plugins.add(keystrokePlugin); + + profileConfig.putParcelableArrayList("PLUGIN_CONFIG", plugins); + + Intent intent = new Intent(); + intent.setAction(DATAWEDGE_API_ACTION); + intent.putExtra(DATAWEDGE_SET_CONFIG, profileConfig); + context.sendBroadcast(intent); + } + + /** + * Attempt to apply Datalogic wedge configuration. + */ + private static void configureDatalogic() { + ErrorManager.enableExceptions(false); + + BarcodeManager manager = new BarcodeManager(); + + ScannerProperties configuration = ScannerProperties.edit(manager); + + if (configuration == null) { + throw new IllegalStateException(); + } + + // Keyboard wedge only on focus of a text field + configuration.keyboardWedge.onlyOnFocus.set(true); + configuration.intentWedge.enable.set(true); + // Default action, category + configuration.intentWedge.action.set(MainActivity.ACTION_DATALOGIC_DECODE); + configuration.intentWedge.category.set(MainActivity.CATEGORY_DATALOGIC_DECODE); + // Enable broadcast + configuration.intentWedge.deliveryMode.set(IntentDeliveryMode.BROADCAST); + + int result = configuration.store(manager, true); + + if (result != ConfigException.SUCCESS) { + throw new IllegalStateException(); + } + } +} diff --git a/lib/barcode/barcode.dart b/lib/barcode/barcode.dart index d4a45fbc..1ae45478 100644 --- a/lib/barcode/barcode.dart +++ b/lib/barcode/barcode.dart @@ -18,6 +18,7 @@ import "package:inventree/api.dart"; import "package:inventree/l10.dart"; import "package:inventree/barcode/camera_controller.dart"; +import "package:inventree/barcode/intent_controller.dart"; import "package:inventree/barcode/wedge_controller.dart"; import "package:inventree/barcode/controller.dart"; import "package:inventree/barcode/handler.dart"; @@ -68,6 +69,53 @@ Future barcodeFailure(String msg, dynamic extra) async { ); } +void initGlobalIntentListener() { + bool _processing = false; + + datawedgeStream.listen((event) async { + if (intentScannerActive) return; + if (_processing) return; + + _processing = true; + + try { + final int controllerType = + await InvenTreeSettingsManager().getValue( + INV_BARCODE_SCAN_TYPE, + BARCODE_CONTROLLER_CAMERA, + ) + as int; + if (controllerType != BARCODE_CONTROLLER_INTENT) return; + + if (!InvenTreeAPI().isConnected()) return; + + String barcode = ""; + if (event is Map) { + final map = Map.from(event); + barcode = (map["data"] ?? "").toString(); + } else if (event is String) { + barcode = event; + } + + if (barcode.isEmpty) return; + + if (!OneContext.hasContext) return; + + await OneContext().navigator.push( + PageRouteBuilder( + pageBuilder: (context, _, _) => IntentBarcodeController( + BarcodeScanHandler(), + initialBarcode: barcode, + ), + opaque: false, + ), + ); + } finally { + _processing = false; + } + }); +} + /* * Launch a barcode scanner with a particular context and handler. * @@ -93,6 +141,8 @@ Future scanBarcode( as int; switch (barcodeControllerType) { + case BARCODE_CONTROLLER_INTENT: + controller = IntentBarcodeController(handler); case BARCODE_CONTROLLER_WEDGE: controller = WedgeBarcodeController(handler); case BARCODE_CONTROLLER_CAMERA: diff --git a/lib/barcode/intent_controller.dart b/lib/barcode/intent_controller.dart new file mode 100644 index 00000000..83cbc3a4 --- /dev/null +++ b/lib/barcode/intent_controller.dart @@ -0,0 +1,154 @@ +import "dart:async"; + +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; +import "package:inventree/app_colors.dart"; +import "package:inventree/barcode/controller.dart"; +import "package:inventree/barcode/handler.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/widget/progress.dart"; + +bool intentScannerActive = false; + +final Stream datawedgeStream = const EventChannel( + "inventree/datawedge_scans", +).receiveBroadcastStream(); + +class IntentBarcodeController extends InvenTreeBarcodeController { + const IntentBarcodeController( + BarcodeHandler handler, { + super.key, + this.initialBarcode, + }) : super(handler); + + final String? initialBarcode; + + bool get isBackgroundScan => (initialBarcode ?? "").isNotEmpty; + + @override + State createState() => _IntentBarcodeControllerState(); +} + +class _IntentBarcodeControllerState extends InvenTreeBarcodeControllerState { + StreamSubscription? _sub; + + bool canScan = true; + bool get scanning => mounted && canScan; + + bool get isBackgroundScan => + (widget as IntentBarcodeController).isBackgroundScan; + + @override + void initState() { + super.initState(); + intentScannerActive = true; + + if (isBackgroundScan) { + WidgetsBinding.instance.addPostFrameCallback((_) { + handleBackgroundScan( + (widget as IntentBarcodeController).initialBarcode ?? "", + ); + }); + + return; + } + + _sub = datawedgeStream.listen((event) { + if (!scanning) return; + + if (event is Map) { + final map = Map.from(event); + final data = (map["data"] ?? "").toString(); + if (data.isNotEmpty) { + handleBarcodeData(data); + } + } else if (event is String && event.isNotEmpty) { + handleBarcodeData(event); + } + }); + } + + Future handleBackgroundScan(String barcode) async { + if (!mounted || barcode.isEmpty) return; + + final NavigatorState navigator = Navigator.of(context); + final ModalRoute? route = ModalRoute.of(context); + + showLoadingOverlay(); + + try { + await widget.handler.processBarcode(barcode); + } finally { + hideLoadingOverlay(); + + if (route != null && route.isActive) { + navigator.removeRoute(route); + } + } + } + + @override + void dispose() { + intentScannerActive = false; + _sub?.cancel(); + _sub = null; + super.dispose(); + } + + @override + Future pauseScan() async { + if (!mounted) return; + setState(() { + canScan = false; + }); + } + + @override + Future resumeScan() async { + if (!mounted) return; + setState(() { + canScan = true; + }); + } + + @override + Widget build(BuildContext context) { + if (isBackgroundScan) { + return const AbsorbPointer(child: SizedBox.expand()); + } + + return Scaffold( + appBar: AppBar(title: Text(L10().scanBarcode)), + backgroundColor: Colors.black.withValues(alpha: 0.9), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(flex: 5), + const Icon(TablerIcons.barcode, size: 64), + const Spacer(flex: 5), + SizedBox( + width: 64, + height: 64, + child: CircularProgressIndicator( + color: scanning ? COLOR_ACTION : COLOR_PROGRESS, + ), + ), + const Spacer(flex: 5), + Padding( + padding: const EdgeInsets.all(20), + child: Text( + widget.handler.getOverlayText(context), + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/barcode/wedge_detect.dart b/lib/barcode/wedge_detect.dart new file mode 100644 index 00000000..c515ef82 --- /dev/null +++ b/lib/barcode/wedge_detect.dart @@ -0,0 +1,67 @@ +import "dart:io"; + +import "package:flutter/foundation.dart"; +import "package:flutter/services.dart"; +import "package:inventree/preferences.dart"; + +const MethodChannel _channel = MethodChannel("inventree/wedge"); + +class WedgeDetectionResult { + const WedgeDetectionResult({this.vendor = "none", this.supported = false}); + + final String vendor; + + final bool supported; +} + +Future detectWedgeScanner() async { + if (kIsWeb || !Platform.isAndroid) { + return const WedgeDetectionResult(); + } + + try { + final Map? result = await _channel + .invokeMapMethod("detectWedge"); + + if (result == null) return const WedgeDetectionResult(); + + return WedgeDetectionResult( + vendor: (result["vendor"] ?? "none").toString(), + supported: result["supported"] == true, + ); + } catch (error) { + return const WedgeDetectionResult(); + } +} + +Future applyWedgeDetection(WedgeDetectionResult result) async { + if (result.supported) { + await InvenTreeSettingsManager().setValue( + INV_BARCODE_SCAN_TYPE, + BARCODE_CONTROLLER_INTENT, + ); + } + + await InvenTreeSettingsManager().setValue(INV_BARCODE_WEDGE_DETECTED, true); +} + +Future initWedgeScannerDefault() async { + if (await InvenTreeSettingsManager().getBool( + INV_BARCODE_WEDGE_DETECTED, + false, + )) { + return; + } + + final dynamic existing = await InvenTreeSettingsManager().getValue( + INV_BARCODE_SCAN_TYPE, + null, + ); + + if (existing != null) { + await InvenTreeSettingsManager().setValue(INV_BARCODE_WEDGE_DETECTED, true); + return; + } + + await applyWedgeDetection(await detectWedgeScanner()); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b333be89..194452eb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1440,6 +1440,12 @@ "scannerExternalDetail": "Use external scanner to read barcodes (wedge mode)", "@scannerExternalDetail": {}, + "scannerIntent": "Intent Wedge", + "@scannerIntent": {}, + + "scannerIntentDetail": "Receive scans via Android intents", + "@scannerIntentDetail": {}, + "scanReceivedParts": "Scan Received Parts", "@scanReceivedParts": {}, diff --git a/lib/main.dart b/lib/main.dart index 35c9b10e..73eba6e6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,16 +1,21 @@ import "dart:async"; +import "dart:io"; +import "package:flutter/foundation.dart"; import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "package:adaptive_theme/adaptive_theme.dart"; import "package:flutter_localizations/flutter_localizations.dart"; import "package:flutter_localized_locales/flutter_localized_locales.dart"; +import "package:inventree/barcode/barcode.dart"; +import "package:inventree/barcode/wedge_detect.dart"; import "package:one_context/one_context.dart"; import "package:package_info_plus/package_info_plus.dart"; import "package:sentry_flutter/sentry_flutter.dart"; import "package:inventree/dsn.dart"; +import "package:inventree/managed_config.dart"; import "package:inventree/preferences.dart"; import "package:inventree/inventree/sentry.dart"; import "package:inventree/l10n/supported_locales.dart"; @@ -22,7 +27,7 @@ import "package:inventree/widget/home.dart"; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - final savedThemeMode = await AdaptiveTheme.getThemeMode(); + AdaptiveThemeMode? savedThemeMode = await AdaptiveTheme.getThemeMode(); await runZonedGuarded>( () async { @@ -58,6 +63,14 @@ Future main() async { ); }; + // Get any config provided by the MDM server + await initManagedConfiguration(); + + final AdaptiveThemeMode? managedThemeMode = + await getPendingManagedThemeMode(); + + if (managedThemeMode != null) savedThemeMode = managedThemeMode; + final int orientation = await InvenTreeSettingsManager().getValue( INV_SCREEN_ORIENTATION, @@ -116,6 +129,27 @@ class InvenTreeAppState extends State { // Run some async init tasks runInitTasks(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + applyPendingThemeMode(); + }); + } + + Future applyPendingThemeMode() async { + final AdaptiveThemeMode? mode = await getPendingManagedThemeMode(); + + if (mode == null) return; + + final BuildContext? ctx = OneContext().context; + + if (ctx == null) return; + + final manager = AdaptiveTheme.maybeOf(ctx); + + if (manager == null) return; + + manager.setThemeMode(mode); + await clearPendingManagedThemeMode(); } // Run app init routines in the background @@ -124,6 +158,12 @@ class InvenTreeAppState extends State { Locale? locale = await InvenTreeSettingsManager().getSelectedLocale(); setLocale(locale); + await initWedgeScannerDefault(); + + if (!kIsWeb && Platform.isAndroid) { + initGlobalIntentListener(); + } + // First-run only: seed a demo server profile if none are configured await UserProfileDBManager().seedDemoProfileIfNeeded(); diff --git a/lib/managed_config.dart b/lib/managed_config.dart new file mode 100644 index 00000000..5a293cba --- /dev/null +++ b/lib/managed_config.dart @@ -0,0 +1,379 @@ +import "dart:io"; +import "dart:ui"; + +import "package:adaptive_theme/adaptive_theme.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter/services.dart"; +import "package:inventree/l10n/supported_locales.dart"; +import "package:inventree/preferences.dart"; +import "package:inventree/user_profile.dart"; + +const String MANAGED_CONFIG_SERVER_KEY = "server"; +const String MANAGED_CONFIG_NAME_KEY = "name"; +const String MANAGED_CONFIG_TOKEN_KEY = "token"; + +const String MANAGED_CONFIG_THEME_KEY = "themeMode"; +const String MANAGED_CONFIG_LANGUAGE_KEY = "language"; + +const String _MANAGED_CONFIG_APPLIED_KEY = "managedConfigApplied"; +const String _MANAGED_SETTINGS_APPLIED_KEY = "managedSettingsApplied"; + +const String INV_MANAGED_THEME_MODE = "managedThemeMode"; + +const MethodChannel _channel = MethodChannel("inventree/managed_config"); + +enum ManagedSettingType { boolean, integer, choice } + +class ManagedSetting { + const ManagedSetting( + this.key, + this.type, { + this.choices = const {}, + this.minimum, + this.maximum, + }); + + final String key; + + final ManagedSettingType type; + + final Map choices; + + final int? minimum; + final int? maximum; + + dynamic parse(dynamic raw) { + if (raw == null) return null; + + switch (type) { + case ManagedSettingType.boolean: + return _parseBool(raw); + case ManagedSettingType.integer: + return _parseInt(raw); + case ManagedSettingType.choice: + return _parseChoice(raw); + } + } + + bool? _parseBool(dynamic raw) { + if (raw is bool) return raw; + + switch (raw.toString().trim().toLowerCase()) { + case "true": + case "1": + case "yes": + case "on": + return true; + case "false": + case "0": + case "no": + case "off": + return false; + default: + return null; + } + } + + int? _parseInt(dynamic raw) { + int? value; + + if (raw is int) { + value = raw; + } else if (raw is num) { + value = raw.toInt(); + } else { + value = int.tryParse(raw.toString().trim()); + } + + if (value == null) return null; + + final int? min = minimum; + final int? max = maximum; + + if (min != null && value < min) value = min; + if (max != null && value > max) value = max; + + return value; + } + + int? _parseChoice(dynamic raw) { + final String name = raw.toString().trim().toLowerCase(); + + for (final entry in choices.entries) { + if (entry.key.toLowerCase() == name) return entry.value; + } + + final int? value = raw is int ? raw : int.tryParse(name); + + if (value != null && choices.containsValue(value)) { + return value; + } + + return null; + } +} + +const List MANAGED_SETTINGS = [ + // App settings + ManagedSetting( + INV_SCREEN_ORIENTATION, + ManagedSettingType.choice, + choices: { + "system": SCREEN_ORIENTATION_SYSTEM, + "portrait": SCREEN_ORIENTATION_PORTRAIT, + "landscape": SCREEN_ORIENTATION_LANDSCAPE, + }, + ), + ManagedSetting(INV_ENABLE_LABEL_PRINTING, ManagedSettingType.boolean), + ManagedSetting(INV_STRICT_HTTPS, ManagedSettingType.boolean), + ManagedSetting(INV_REPORT_ERRORS, ManagedSettingType.boolean), + ManagedSetting(INV_SOUNDS_BARCODE, ManagedSettingType.boolean), + ManagedSetting(INV_SOUNDS_SERVER, ManagedSettingType.boolean), + ManagedSetting(INV_SHOW_PK, ManagedSettingType.boolean), + + // Home Screen settings + ManagedSetting(INV_HOME_SHOW_SUBSCRIBED, ManagedSettingType.boolean), + ManagedSetting(INV_HOME_SHOW_PO, ManagedSettingType.boolean), + ManagedSetting(INV_HOME_SHOW_SO, ManagedSettingType.boolean), + ManagedSetting(INV_HOME_SHOW_SHIPMENTS, ManagedSettingType.boolean), + ManagedSetting(INV_HOME_SHOW_BUILD, ManagedSettingType.boolean), + ManagedSetting(INV_HOME_SHOW_MANUFACTURERS, ManagedSettingType.boolean), + ManagedSetting(INV_HOME_SHOW_CUSTOMERS, ManagedSettingType.boolean), + ManagedSetting(INV_HOME_SHOW_SUPPLIERS, ManagedSettingType.boolean), + ManagedSetting(INV_HOME_SHOW_TRANSFER, ManagedSettingType.boolean), + + // Barcode settings + ManagedSetting( + INV_BARCODE_SCAN_TYPE, + ManagedSettingType.choice, + choices: { + "camera": BARCODE_CONTROLLER_CAMERA, + "wedge": BARCODE_CONTROLLER_WEDGE, + "intent": BARCODE_CONTROLLER_INTENT, + }, + ), + ManagedSetting( + INV_BARCODE_SCAN_DELAY, + ManagedSettingType.integer, + minimum: 100, + maximum: 2500, + ), + ManagedSetting(INV_BARCODE_SCAN_SINGLE, ManagedSettingType.boolean), + + // Part settings + ManagedSetting(INV_PART_SHOW_BOM, ManagedSettingType.boolean), + ManagedSetting(INV_PART_SHOW_PRICING, ManagedSettingType.boolean), + ManagedSetting(INV_PART_SHOW_REQUIREMENTS, ManagedSettingType.boolean), + + // Stock settings + ManagedSetting(INV_STOCK_SHOW_HISTORY, ManagedSettingType.boolean), + ManagedSetting(INV_STOCK_SHOW_TESTS, ManagedSettingType.boolean), + ManagedSetting(INV_STOCK_CONFIRM_SCAN, ManagedSettingType.boolean), + + // Purchase Order settings + ManagedSetting(INV_PO_ENABLE, ManagedSettingType.boolean), + ManagedSetting(INV_PO_SHOW_CAMERA, ManagedSettingType.boolean), + ManagedSetting(INV_PO_CONFIRM_SCAN, ManagedSettingType.boolean), + + // Sales Order settings + ManagedSetting(INV_SO_ENABLE, ManagedSettingType.boolean), + ManagedSetting(INV_SO_SHOW_CAMERA, ManagedSettingType.boolean), +]; + +const Map MANAGED_THEME_MODES = { + "system": AdaptiveThemeMode.system, + "light": AdaptiveThemeMode.light, + "dark": AdaptiveThemeMode.dark, +}; + +Future initManagedConfiguration() async { + if (kIsWeb || !Platform.isAndroid) return; + + Map? config; + + try { + config = await _channel.invokeMapMethod( + "getManagedConfiguration", + ); + } catch (error) { + return; + } + + await applyManagedConfiguration(config); +} + +Future applyManagedConfiguration(Map? config) async { + if (config == null || config.isEmpty) return; + + await _applyManagedProfile(config); + await _applyManagedSettings(config); +} + +Future _applyManagedProfile(Map config) async { + final String server = (config[MANAGED_CONFIG_SERVER_KEY] ?? "") + .toString() + .trim(); + + // Server address is required + if (server.isEmpty) return; + + String name = (config[MANAGED_CONFIG_NAME_KEY] ?? "").toString().trim(); + + // Profile name is required + if (name.isEmpty) return; + + final String token = (config[MANAGED_CONFIG_TOKEN_KEY] ?? "") + .toString() + .trim(); + + final String fingerprint = "${name}|${server}"; + + final String applied = + await InvenTreeSettingsManager().getValue(_MANAGED_CONFIG_APPLIED_KEY, "") + as String; + + final manager = UserProfileDBManager(); + + UserProfile? profile = await manager.getProfileByName(name); + + // Compare token against stored token + final bool tokenChanged = token.isNotEmpty && profile?.token != token; + + // Check if anything actually changed + if (applied == fingerprint && !tokenChanged) return; + + // No demo profile + await InvenTreeSettingsManager().setValue("demo_profile_added", true); + + if (profile == null) { + profile = UserProfile(name: name, server: server, token: token); + + await manager.addProfile(profile); + } else { + profile.server = server; + + if (token.isNotEmpty) profile.token = token; + + await manager.updateProfile(profile); + } + + // Select the profile + await manager.selectProfileByName(name); + + await InvenTreeSettingsManager().setValue( + _MANAGED_CONFIG_APPLIED_KEY, + fingerprint, + ); +} + +Future _applyManagedSettings(Map config) async { + final Map values = {}; + + for (final setting in MANAGED_SETTINGS) { + if (!config.containsKey(setting.key)) continue; + + final dynamic value = setting.parse(config[setting.key]); + + if (value == null) continue; + + values[setting.key] = value; + } + + AdaptiveThemeMode? themeMode; + + if (config.containsKey(MANAGED_CONFIG_THEME_KEY)) { + final String mode = config[MANAGED_CONFIG_THEME_KEY] + .toString() + .trim() + .toLowerCase(); + + themeMode = MANAGED_THEME_MODES[mode]; + } + + bool applyLocale = false; + Locale? locale; + + if (config.containsKey(MANAGED_CONFIG_LANGUAGE_KEY)) { + final String language = config[MANAGED_CONFIG_LANGUAGE_KEY] + .toString() + .trim(); + + if (language.isEmpty) { + applyLocale = true; + } else { + locale = matchSupportedLocale(language); + + if (locale != null) applyLocale = true; + } + } + + if (values.isEmpty && themeMode == null && !applyLocale) return; + + final List elements = values.keys.toList()..sort(); + + final String fingerprint = [ + ...elements.map((key) => "${key}=${values[key]}"), + if (themeMode != null) "${MANAGED_CONFIG_THEME_KEY}=${themeMode.name}", + if (applyLocale) + "${MANAGED_CONFIG_LANGUAGE_KEY}=${locale?.toString() ?? ""}", + ].join("|"); + + final String applied = + await InvenTreeSettingsManager().getValue( + _MANAGED_SETTINGS_APPLIED_KEY, + "", + ) + as String; + + if (applied == fingerprint) return; + + for (final entry in values.entries) { + await InvenTreeSettingsManager().setValue(entry.key, entry.value); + } + + if (themeMode != null) { + await InvenTreeSettingsManager().setValue( + INV_MANAGED_THEME_MODE, + themeMode.name, + ); + } + + if (applyLocale) { + await InvenTreeSettingsManager().setSelectedLocale(locale); + } + + await InvenTreeSettingsManager().setValue( + _MANAGED_SETTINGS_APPLIED_KEY, + fingerprint, + ); +} + +Locale? matchSupportedLocale(String name) { + final String value = name.trim().replaceAll("-", "_"); + + if (value.isEmpty) return null; + + for (final locale in supported_locales) { + if (locale.toString().toLowerCase() == value.toLowerCase()) return locale; + } + + for (final locale in supported_locales) { + if (locale.languageCode.toLowerCase() == value.toLowerCase()) return locale; + } + + return null; +} + +Future getPendingManagedThemeMode() async { + final String mode = + await InvenTreeSettingsManager().getValue(INV_MANAGED_THEME_MODE, "") + as String; + + if (mode.isEmpty) return null; + + return MANAGED_THEME_MODES[mode.toLowerCase()]; +} + +Future clearPendingManagedThemeMode() async { + await InvenTreeSettingsManager().setValue(INV_MANAGED_THEME_MODE, ""); +} diff --git a/lib/preferences.dart b/lib/preferences.dart index 04a2ff67..70c81db1 100644 --- a/lib/preferences.dart +++ b/lib/preferences.dart @@ -64,6 +64,10 @@ const String INV_BARCODE_SCAN_SINGLE = "barcodeScanSingle"; // Barcode scanner types const int BARCODE_CONTROLLER_CAMERA = 0; const int BARCODE_CONTROLLER_WEDGE = 1; +const int BARCODE_CONTROLLER_INTENT = 2; + +// Whether one-time intent wedge detection has run +const String INV_BARCODE_WEDGE_DETECTED = "barcodeWedgeDetected"; /* * Class for storing InvenTree preferences in a NoSql DB diff --git a/lib/settings/barcode_settings.dart b/lib/settings/barcode_settings.dart index a3fc4282..c9c5ed14 100644 --- a/lib/settings/barcode_settings.dart +++ b/lib/settings/barcode_settings.dart @@ -1,3 +1,5 @@ +import "dart:io"; + import "package:flutter/material.dart"; import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; @@ -112,6 +114,8 @@ class _InvenTreeBarcodeSettingsState Widget? barcodeInputIcon; switch (barcodeScanType) { + case BARCODE_CONTROLLER_INTENT: + barcodeInputIcon = Icon(TablerIcons.arrow_badge_right); case BARCODE_CONTROLLER_WEDGE: barcodeInputIcon = Icon(Icons.barcode_reader); case BARCODE_CONTROLLER_CAMERA: @@ -143,6 +147,12 @@ class _InvenTreeBarcodeSettingsState subtitle: Text(L10().scannerExternalDetail), leading: Icon(Icons.barcode_reader), ), + if (Platform.isAndroid) + ListTile( + title: Text(L10().scannerIntent), + subtitle: Text(L10().scannerIntentDetail), + leading: Icon(TablerIcons.arrow_badge_right), + ), ], onSelected: (idx) async { barcodeScanType = idx as int;