diff --git a/backend/app/src/main/resources/application.yml b/backend/app/src/main/resources/application.yml index b0357bae77d2bb4081d25c4b860efc61468c6bdd..3747b5788a57bef9fd5d877d788956b07a8934b4 100644 --- a/backend/app/src/main/resources/application.yml +++ b/backend/app/src/main/resources/application.yml @@ -38,12 +38,13 @@ springdoc: swagger-ui: path: /swagger-ui.html operations-sorter: method - packages-to-scan: band.effective.office.backend.app.controller, - band.effective.office.backend.feature.authorization.controller, - band.effective.office.backend.feature.booking.core.controller, - band.effective.office.backend.feature.workspace.core.controller, - band.effective.office.backend.feature.duolingo.controller, - band.effective.office.backend.feature.notifications.controller + packages-to-scan: + - band.effective.office.backend.app.controller + - band.effective.office.backend.feature.authorization.controller + - band.effective.office.backend.feature.booking.core.controller + - band.effective.office.backend.feature.workspace.core.controller + - band.effective.office.backend.feature.duolingo.controller + - band.effective.office.backend.feature.notifications.controller management: endpoints: diff --git a/backend/core/repository/src/main/resources/db/migration/V3__add_devices_table.sql b/backend/core/repository/src/main/resources/db/migration/V3__add_devices_table.sql new file mode 100644 index 0000000000000000000000000000000000000000..3596be49b7c4dfc6232ecc52b9f6a82a5ff26775 --- /dev/null +++ b/backend/core/repository/src/main/resources/db/migration/V3__add_devices_table.sql @@ -0,0 +1,23 @@ +-- Creates the table for storing device information +CREATE TABLE devices +( + -- Primary key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- Unique Android device ID + device_id VARCHAR(255) NOT NULL UNIQUE, + -- Tag for device identification (e.g., meeting room name) + tag VARCHAR(255) NOT NULL, + -- Creation timestamp of the record + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Index for faster lookup by device_id +CREATE INDEX idx_devices_device_id ON devices (device_id); + +-- Index for faster lookup by tag +CREATE INDEX idx_devices_tag ON devices (tag); + +-- Comments for table and columns +COMMENT ON TABLE devices IS 'Table for storing information about devices'; +COMMENT ON COLUMN devices.device_id IS 'Unique Android device ID'; +COMMENT ON COLUMN devices.tag IS 'Tag for device identification'; \ No newline at end of file diff --git a/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/config/NotificationsRepositoryConfig.kt b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/config/NotificationsRepositoryConfig.kt new file mode 100644 index 0000000000000000000000000000000000000000..b8020604b56a9cf054a558eed9c93ba58488a1f0 --- /dev/null +++ b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/config/NotificationsRepositoryConfig.kt @@ -0,0 +1,15 @@ +package band.effective.office.backend.feature.notifications.config + +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.context.annotation.Configuration +import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import org.springframework.transaction.annotation.EnableTransactionManagement + +/** + * Configuration for the notifications repository. + */ +@Configuration +@EnableTransactionManagement +@EnableJpaRepositories(basePackages = ["band.effective.office.backend.feature.notifications.repository"]) +@EntityScan(basePackages = ["band.effective.office.backend.feature.notifications.repository.entity"]) +class NotificationsRepositoryConfig \ No newline at end of file diff --git a/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/controller/KioskController.kt b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/controller/KioskController.kt new file mode 100644 index 0000000000000000000000000000000000000000..032f658aa1324e0d15720444db760bf925b50bd8 --- /dev/null +++ b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/controller/KioskController.kt @@ -0,0 +1,199 @@ +package band.effective.office.backend.feature.notifications.controller + +import band.effective.office.backend.core.data.ErrorDto +import band.effective.office.backend.feature.notifications.dto.DeviceDto +import band.effective.office.backend.feature.notifications.dto.KioskMessageDto +import band.effective.office.backend.feature.notifications.dto.KioskToggleRequest +import band.effective.office.backend.feature.notifications.service.DeviceService +import band.effective.office.backend.feature.notifications.service.INotificationSender +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +/** + * REST controller for managing kiosk mode. + */ +@RestController +@RequestMapping("/api/v1/kiosk") +@Tag(name = "Kiosk", description = "API for managing kiosk mode") +class KioskController( + private val notificationSender: INotificationSender, + private val deviceService: DeviceService +) { + companion object { + private const val KIOSK_TOPIC = "kiosk-commands" + private const val MESSAGE_TYPE = "KIOSK_TOGGLE" + } + + /** + * Enables kiosk mode for a specific device. + */ + @PostMapping("/device/enable") + @Operation( + summary = "Enable kiosk mode for specific device", + description = "Enables kiosk mode for a specific device by deviceId", + security = [SecurityRequirement(name = "bearerAuth")] + ) + @ApiResponse(responseCode = "200", description = "Command sent successfully") + @ApiResponse( + responseCode = "404", + description = "Device not found", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ErrorDto::class) + )] + ) + fun enableKioskForDevice(@Valid @RequestBody request: KioskToggleRequest): ResponseEntity { + return toggleKioskForDevice(request.deviceId, true) + } + + /** + * Disables kiosk mode for a specific device. + */ + @PostMapping("/device/disable") + @Operation( + summary = "Disable kiosk mode for specific device", + description = "Disables kiosk mode for a specific device by deviceId", + security = [SecurityRequirement(name = "bearerAuth")] + ) + @ApiResponse(responseCode = "200", description = "Command sent successfully") + @ApiResponse( + responseCode = "404", + description = "Device not found", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ErrorDto::class) + )] + ) + fun disableKioskForDevice(@Valid @RequestBody request: KioskToggleRequest): ResponseEntity { + return toggleKioskForDevice(request.deviceId, false) + } + + /** + * Enables kiosk mode for all registered devices. + */ + @PostMapping("/all/enable") + @Operation( + summary = "Enable kiosk mode for all devices", + description = "Enables kiosk mode for all registered devices in the database", + security = [SecurityRequirement(name = "bearerAuth")] + ) + @ApiResponse( + responseCode = "200", + description = "Command sent successfully", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = KioskMessageDto::class) + )] + ) + @ApiResponse( + responseCode = "400", + description = "No devices found in database", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ErrorDto::class) + )] + ) + fun enableKioskForAllDevices(): ResponseEntity { + return toggleKioskForAllDevices(true) + } + + /** + * Disables kiosk mode for all registered devices. + */ + @PostMapping("/all/disable") + @Operation( + summary = "Disable kiosk mode for all devices", + description = "Disables kiosk mode for all registered devices in the database", + security = [SecurityRequirement(name = "bearerAuth")] + ) + @ApiResponse( + responseCode = "200", + description = "Command sent successfully", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = KioskMessageDto::class) + )] + ) + @ApiResponse( + responseCode = "400", + description = "No devices found in database", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ErrorDto::class) + )] + ) + fun disableKioskForAllDevices(): ResponseEntity { + return toggleKioskForAllDevices(false) + } + + /** + * Toggles kiosk mode for a specific device. + */ + private fun toggleKioskForDevice(deviceId: String, isKioskModeActive: Boolean): ResponseEntity { + if (!deviceService.deviceExists(deviceId)) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ErrorDto(message = "Device with ID $deviceId not found", code = 404)) + } + + val payload = buildMap { + put("type", MESSAGE_TYPE) + put("isKioskModeActive", isKioskModeActive.toString()) + put("deviceId", deviceId) + } + + notificationSender.sendDataMessage(KIOSK_TOPIC, payload) + + val messageText = "Kiosk mode ${if (isKioskModeActive) "enabled" else "disabled"} for device: $deviceId" + return ResponseEntity.ok(KioskMessageDto(messageText)) + } + + /** + * Toggles kiosk mode for all registered devices. + * Sends one command with all device IDs from the database. + */ + private fun toggleKioskForAllDevices(isKioskModeActive: Boolean): ResponseEntity { + val allDevices = deviceService.getAllDevices() + + if (allDevices.isEmpty()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ErrorDto(message = "No devices found in database", code = 400)) + } + + val deviceIds = allDevices.map { it.deviceId } + + val payload = buildMap { + put("type", MESSAGE_TYPE) + put("isKioskModeActive", isKioskModeActive.toString()) + put("deviceIds", deviceIds.joinToString(",")) + } + + notificationSender.sendDataMessage(KIOSK_TOPIC, payload) + + val deviceCount = allDevices.size + val messageText = "Kiosk mode ${if (isKioskModeActive) "enabled" else "disabled"} for $deviceCount devices" + return ResponseEntity.ok(KioskMessageDto(messageText)) + } + + /** + * Retrieves a list of all registered devices. + */ + @GetMapping("/devices") + @Operation( + summary = "Get all devices", + description = "Returns a list of all registered devices", + security = [SecurityRequirement(name = "bearerAuth")] + ) + @ApiResponse(responseCode = "200", description = "List of devices retrieved successfully") + fun getAllDevices(): ResponseEntity> { + val devices = deviceService.getAllDevices().map { DeviceDto.fromEntity(it) } + return ResponseEntity.ok(devices) + } +} \ No newline at end of file diff --git a/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/dto/DeviceDto.kt b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/dto/DeviceDto.kt new file mode 100644 index 0000000000000000000000000000000000000000..b7067e8159a8f7fefaded876304d3e4e5b05ecab --- /dev/null +++ b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/dto/DeviceDto.kt @@ -0,0 +1,38 @@ +package band.effective.office.backend.feature.notifications.dto + +import band.effective.office.backend.feature.notifications.repository.entity.DeviceEntity +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime +import java.util.UUID + +/** + * Data Transfer Object for device information. + */ +@Schema(description = "Device information") +data class DeviceDto( + @Schema(description = "Unique device identifier", example = "123e4567-e89b-12d3-a456-426614174000") + val id: UUID, + + @Schema(description = "Android device ID", example = "7ac6ddd9a731bbeb") + val deviceId: String, + + @Schema(description = "Device tag (e.g., meeting room name)", example = "Meeting Room A") + val tag: String, + + @Schema(description = "Device registration timestamp") + val createdAt: LocalDateTime +) { + companion object { + /** + * Creates a DeviceDto from a DeviceEntity. + */ + fun fromEntity(entity: DeviceEntity): DeviceDto { + return DeviceDto( + id = entity.id, + deviceId = entity.deviceId, + tag = entity.tag, + createdAt = entity.createdAt + ) + } + } +} \ No newline at end of file diff --git a/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/dto/KioskMessageDto.kt b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/dto/KioskMessageDto.kt new file mode 100644 index 0000000000000000000000000000000000000000..23cbe28708b4d99126c527ccbd4a086853db7bb0 --- /dev/null +++ b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/dto/KioskMessageDto.kt @@ -0,0 +1,15 @@ +package band.effective.office.backend.feature.notifications.dto + +import io.swagger.v3.oas.annotations.media.Schema + +/** + * API response for successful operations in notifications module. + */ +@Schema(description = "API response for successful operations") +data class KioskMessageDto( + @Schema( + description = "Message for the client", + example = "Kiosk mode enabled for device: 7ac6ddd9a731bbeb" + ) + val message: String +) \ No newline at end of file diff --git a/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/dto/KioskToggleRequest.kt b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/dto/KioskToggleRequest.kt new file mode 100644 index 0000000000000000000000000000000000000000..b97694203e9359a14a159a6caf87c2cfc630d239 --- /dev/null +++ b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/dto/KioskToggleRequest.kt @@ -0,0 +1,18 @@ +package band.effective.office.backend.feature.notifications.dto + +import io.swagger.v3.oas.annotations.media.Schema + +/** + * Data Transfer Object for kiosk mode toggle requests. + * + * This request allows enabling or disabling kiosk mode for a specific device + */ +@Schema(description = "Request to toggle kiosk mode") +data class KioskToggleRequest( + @Schema( + description = "Unique Android device ID", + example = "7ac6ddd9a731bbeb", + required = false + ) + val deviceId: String +) \ No newline at end of file diff --git a/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/repository/DeviceRepository.kt b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/repository/DeviceRepository.kt new file mode 100644 index 0000000000000000000000000000000000000000..e99d34b512e78754257bc5b78b8a65f35c797aba --- /dev/null +++ b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/repository/DeviceRepository.kt @@ -0,0 +1,19 @@ +package band.effective.office.backend.feature.notifications.repository + +import band.effective.office.backend.feature.notifications.repository.entity.DeviceEntity +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.util.UUID + +/** + * Repository for working with devices + */ +@Repository +interface DeviceRepository : JpaRepository { + + /** + * Check if device exists by device_id + * @return true if device exists, false otherwise + */ + fun existsByDeviceId(deviceId: String): Boolean +} \ No newline at end of file diff --git a/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/repository/entity/DeviceEntity.kt b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/repository/entity/DeviceEntity.kt new file mode 100644 index 0000000000000000000000000000000000000000..69cc7dfed5cd10813b40a9f8cd487ce5441cc4cb --- /dev/null +++ b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/repository/entity/DeviceEntity.kt @@ -0,0 +1,27 @@ +package band.effective.office.backend.feature.notifications.repository.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime +import java.util.UUID + +/** + * JPA entity for devices with kiosk mode functionality. + */ +@Entity +@Table(name = "devices") +class DeviceEntity( + @Id + val id: UUID = UUID.randomUUID(), + + @Column(name = "device_id", nullable = false, unique = true, length = 255) + val deviceId: String, + + @Column(nullable = false, length = 255) + val tag: String, + + @Column(name = "created_at", nullable = false) + val createdAt: LocalDateTime = LocalDateTime.now() +) \ No newline at end of file diff --git a/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/service/DeviceService.kt b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/service/DeviceService.kt new file mode 100644 index 0000000000000000000000000000000000000000..661b4c58e2c571bd79319990d0ff4683a63351b1 --- /dev/null +++ b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/service/DeviceService.kt @@ -0,0 +1,31 @@ +package band.effective.office.backend.feature.notifications.service + +import band.effective.office.backend.feature.notifications.repository.DeviceRepository +import band.effective.office.backend.feature.notifications.repository.entity.DeviceEntity +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * Service for managing devices + */ +@Service +class DeviceService(private val deviceRepository: DeviceRepository) { + + /** + * Retrieves all registered devices + */ + @Transactional(readOnly = true) + fun getAllDevices(): List { + return deviceRepository.findAll() + } + + /** + * Checks if device exists by device ID + * + * @return true if device exists, false otherwise + */ + @Transactional(readOnly = true) + fun deviceExists(deviceId: String): Boolean { + return deviceRepository.existsByDeviceId(deviceId) + } +} \ No newline at end of file diff --git a/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/service/FcmNotificationSender.kt b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/service/FcmNotificationSender.kt index fc47804301e70976e0f67ce039de092ac625935b..5a180aca6de20c22745b00434ee652703781cefe 100644 --- a/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/service/FcmNotificationSender.kt +++ b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/service/FcmNotificationSender.kt @@ -23,4 +23,18 @@ class FcmNotificationSender( .build() fcm.send(msg) } + + override fun sendDataMessage(topic: String, data: Map) { + logger.info("Sending data FCM message on $topic topic: $data") + val msg = Message.builder() + .setTopic(topic) + .putAllData(data) + .build() + try { + val messageId = fcm.send(msg) + logger.info("Successfully sent data message to topic: $topic with data: $data, messageId: $messageId") + } catch (e: Exception) { + logger.error("Failed to send data message to topic $topic: ${e.message}") + } + } } diff --git a/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/service/INotificationSender.kt b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/service/INotificationSender.kt index d90fe5ae645c56f81b5063ebab1c61425fe4a7d5..868dd16931d66acc3c571206bef1fceeef2a4ccb 100644 --- a/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/service/INotificationSender.kt +++ b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/service/INotificationSender.kt @@ -9,4 +9,9 @@ interface INotificationSender { * Sends message about topic modification */ fun sendEmptyMessage(topic: String) + + /** + * Sends message with data about topic modification + */ + fun sendDataMessage(topic: String, data: Map) } \ No newline at end of file diff --git a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.android.kt b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.android.kt index 262007a3a0dabba75d84fcd86fa52385e9617ddc..2b032d2e345d8234ac51aa1cbd5ac09e349499cc 100644 --- a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.android.kt +++ b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.android.kt @@ -1,72 +1,46 @@ package band.effective.office.tablet -import android.app.ActivityOptions -import android.app.admin.DevicePolicyManager -import android.content.Intent -import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.annotation.RequiresApi +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.lifecycle.lifecycleScope import band.effective.office.tablet.root.RootComponent import band.effective.office.tablet.time.TimeReceiver +import band.effective.office.tablet.utils.KioskCommandBus +import band.effective.office.tablet.utils.KioskLifecycleObserver +import band.effective.office.tablet.utils.KioskManager import com.arkivanov.decompose.defaultComponentContext +val LocalKioskManager = staticCompositionLocalOf { null } + class AppActivity : ComponentActivity() { - companion object{ - var isRunKioskMode = false - } + private val timeReceiver by lazy { TimeReceiver(this) } - val timeReceiver by lazy { TimeReceiver(this) } + private val kioskManager by lazy { KioskManager(this) } + private val kioskCommandBus = KioskCommandBus.getInstance() - @RequiresApi(Build.VERSION_CODES.P) override fun onCreate(savedInstanceState: Bundle?) { - runKioskMode() super.onCreate(savedInstanceState) - timeReceiver.register() enableEdgeToEdge() + + lifecycle.addObserver(KioskLifecycleObserver(this, kioskManager, kioskCommandBus, lifecycleScope)) + val root = RootComponent(componentContext = defaultComponentContext()) - setContent { App(root) } + + setContent { + CompositionLocalProvider(LocalKioskManager provides kioskManager) { + App(root) + } + } } override fun onDestroy() { - // Unregister the time receiver timeReceiver.unregister() super.onDestroy() } - - @RequiresApi(Build.VERSION_CODES.P) - private fun runKioskMode(){ - val context = this - val dpm = context.getSystemService(DEVICE_POLICY_SERVICE) - as DevicePolicyManager - val adminName = AdminReceiver.getComponentName(context) - val KIOSK_PACKAGE = "band.effective.office.tablet" - val APP_PACKAGES = arrayOf(KIOSK_PACKAGE) - if (isRunKioskMode || !dpm.isDeviceOwnerApp(adminName.packageName)) return - - val intent = Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN).apply { - putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, adminName) - putExtra(DevicePolicyManager.EXTRA_ADD_EXPLANATION, - "") - } - startActivityForResult(intent, 1) - - dpm.setLockTaskPackages(adminName, APP_PACKAGES) - - // Set an option to turn on lock task mode when starting the activity. - val options = ActivityOptions.makeBasic() - options.setLockTaskEnabled(true) - isRunKioskMode = true - - // Start our kiosk app's main activity with our lock task mode option. - val packageManager = context.packageManager - val launchIntent = packageManager.getLaunchIntentForPackage(KIOSK_PACKAGE) - if (launchIntent != null) { - context.startActivity(launchIntent, options.toBundle()) - } - } -} +} \ No newline at end of file diff --git a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/ServerMessagingService.kt b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/ServerMessagingService.kt index dda94e40332805ac7599b2eaa6597e258cd738bc..33ea8c65ecaf27c9626f7702b0f841f87857ed38 100644 --- a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/ServerMessagingService.kt +++ b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/ServerMessagingService.kt @@ -1,18 +1,69 @@ package band.effective.office.tablet +import android.provider.Settings import android.util.Log import band.effective.office.tablet.core.data.api.Collector +import band.effective.office.tablet.utils.DeviceTargetResolver +import band.effective.office.tablet.utils.KioskCommandBus +import band.effective.office.tablet.utils.KioskCommandMapper +import band.effective.office.tablet.utils.MessageType +import band.effective.office.tablet.utils.MessageValidator import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import org.koin.core.component.KoinComponent import org.koin.core.component.inject class ServerMessagingService() : FirebaseMessagingService(), KoinComponent { private val collector: Collector by inject() + private val serviceScope = CoroutineScope(Dispatchers.Main) + private val kioskCommandBus = KioskCommandBus.getInstance() override fun onMessageReceived(message: RemoteMessage) { - Log.i("ReceivedMessage", message.data.toString()) - collector.emit(message.from?.substringAfter("topics/")?.replace("-test", "") ?: "") + Log.i("FCM_MESSAGE", "From: ${message.from}, Data: ${message.data}") + + when (MessageType.fromString(message.data["type"])) { + MessageType.KIOSK_TOGGLE -> handleKioskCommand(message) + MessageType.UNKNOWN -> { + val topic = message.from?.substringAfter("topics/")?.replace("-test", "") ?: "" + collector.emit(topic) + } + } + } + + /** + * Processes incoming kiosk toggle commands. + * + * Supports both single device (deviceId) and multiple devices (deviceIds) commands. + */ + private fun handleKioskCommand(message: RemoteMessage) { + + if (!MessageValidator.isValidKioskCommand(message)) { + return + } + + val isKioskModeActive = getKioskModeStatus(message) ?: return + + if (shouldExecuteCommand(message)) { + val command = KioskCommandMapper.mapToKioskCommand(isKioskModeActive) + kioskCommandBus.sendCommand(command, serviceScope) + } + } + + private fun getKioskModeStatus(message: RemoteMessage): Boolean? { + val kioskModeValue = message.data["isKioskModeActive"] + val isKioskModeActive = MessageValidator.validateKioskModeValue(kioskModeValue) + return isKioskModeActive + } + + private fun shouldExecuteCommand(message: RemoteMessage): Boolean { + val currentDeviceId = getCurrentDeviceId() + return DeviceTargetResolver.shouldExecuteOnCurrentDevice(message, currentDeviceId) + } + + private fun getCurrentDeviceId(): String { + return Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID) } } \ No newline at end of file diff --git a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/DeviceTargetResolver.kt b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/DeviceTargetResolver.kt new file mode 100644 index 0000000000000000000000000000000000000000..20b0f605b23f5e0236c414f622f90c2e7d2443a9 --- /dev/null +++ b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/DeviceTargetResolver.kt @@ -0,0 +1,48 @@ +package band.effective.office.tablet.utils + +import android.util.Log +import com.google.firebase.messaging.RemoteMessage + +/** + * Resolver for determining if a command should be executed on the current device. + */ +object DeviceTargetResolver { + + private const val TAG = "KIOSK_COMMAND" + private const val KEY_DEVICE_ID = "deviceId" + private const val KEY_DEVICE_IDS = "deviceIds" + /** + * Determines if the current device is a target for the given command. + * + * Supports both single device (`deviceId`) and multiple devices (`deviceIds`) commands. + */ + fun shouldExecuteOnCurrentDevice(message: RemoteMessage, currentDeviceId: String): Boolean { + val data = message.data + + return when { + data.containsKey(KEY_DEVICE_ID) -> { + val targetDeviceId = data[KEY_DEVICE_ID] + val matches = targetDeviceId == currentDeviceId + Log.d(TAG, "Single device → Target: $targetDeviceId, Current: $currentDeviceId, Execute: $matches") + matches + } + + data.containsKey(KEY_DEVICE_IDS) -> { + val targetDeviceIds = parseDeviceIds(data[KEY_DEVICE_IDS]) + val matches = currentDeviceId in targetDeviceIds + Log.d(TAG, "Multiple devices - Targets: $targetDeviceIds, Current: $currentDeviceId") + matches + } + + else -> { + Log.w(TAG, "No deviceId or deviceIds specified in command") + false + } + } + } + + private fun parseDeviceIds(raw: String?): List = + raw?.split(",") + ?.map { it.trim() } + .orEmpty() +} \ No newline at end of file diff --git a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/KioskCommand.kt b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/KioskCommand.kt new file mode 100644 index 0000000000000000000000000000000000000000..a827649b9c5ff37252c54a3b8a7e3744df84afe0 --- /dev/null +++ b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/KioskCommand.kt @@ -0,0 +1,12 @@ +package band.effective.office.tablet.utils + +sealed interface KioskCommand { + object Enable : KioskCommand + object Disable : KioskCommand +} + +object KioskCommandMapper { + fun mapToKioskCommand(isKioskModeActive: Boolean): KioskCommand { + return if (isKioskModeActive) KioskCommand.Enable else KioskCommand.Disable + } +} diff --git a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/KioskCommandBus.kt b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/KioskCommandBus.kt new file mode 100644 index 0000000000000000000000000000000000000000..f9fc2ded8618855a3ce07e81a805f910983aceb9 --- /dev/null +++ b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/KioskCommandBus.kt @@ -0,0 +1,27 @@ +package band.effective.office.tablet.utils + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +class KioskCommandBus { + private val _commandFlow = MutableSharedFlow() + val commandFlow = _commandFlow.asSharedFlow() + + fun sendCommand(command: KioskCommand, scope: CoroutineScope) { + scope.launch { + _commandFlow.emit(command) + } + } + + companion object { + private var instance: KioskCommandBus? = null + + fun getInstance(): KioskCommandBus { + return instance ?: synchronized(this) { + instance ?: KioskCommandBus().also { instance = it } + } + } + } +} \ No newline at end of file diff --git a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/KioskLifecycleObserver.kt b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/KioskLifecycleObserver.kt new file mode 100644 index 0000000000000000000000000000000000000000..ec801f98b35d50e8d5b471952cb2755196377384 --- /dev/null +++ b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/KioskLifecycleObserver.kt @@ -0,0 +1,36 @@ +package band.effective.office.tablet.utils + +import android.app.Activity +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class KioskLifecycleObserver( + private val activity: Activity, + private val kioskManager: KioskManager, + private val kioskCommandBus: KioskCommandBus, + private val coroutineScope: CoroutineScope +) : DefaultLifecycleObserver { + + private var job: Job? = null + + override fun onStart(owner: LifecycleOwner) { + super.onStart(owner) + job = kioskCommandBus.commandFlow + .onEach { command -> + when (command) { + KioskCommand.Enable -> kioskManager.enableKioskMode(activity) + KioskCommand.Disable -> kioskManager.disableKioskMode(activity) + } + } + .launchIn(coroutineScope) + } + + override fun onStop(owner: LifecycleOwner) { + job?.cancel() + job = null + } +} \ No newline at end of file diff --git a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/KioskManager.kt b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/KioskManager.kt new file mode 100644 index 0000000000000000000000000000000000000000..6015e07e9b1a16718ecf585edec04aa3bf0943a7 --- /dev/null +++ b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/KioskManager.kt @@ -0,0 +1,27 @@ +package band.effective.office.tablet.utils + +import android.app.Activity +import android.app.admin.DevicePolicyManager +import android.content.Context +import band.effective.office.tablet.AdminReceiver + +class KioskManager(private val context: Context) { + + private val devicePolicyManager: DevicePolicyManager = + context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + + private val adminComponent = AdminReceiver.Companion.getComponentName(context) + private val packageName = context.packageName + + + fun enableKioskMode(activity: Activity) { + if (!devicePolicyManager.isDeviceOwnerApp(packageName)) return + devicePolicyManager.setLockTaskPackages(adminComponent, arrayOf(packageName)) + activity.startLockTask() + } + + fun disableKioskMode(activity: Activity) { + if (!devicePolicyManager.isDeviceOwnerApp(packageName)) return + activity.stopLockTask() + } +} \ No newline at end of file diff --git a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/MessageType.kt b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/MessageType.kt new file mode 100644 index 0000000000000000000000000000000000000000..76d95392f38db455e19c8c482bca386fe7ed5b3b --- /dev/null +++ b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/MessageType.kt @@ -0,0 +1,12 @@ +package band.effective.office.tablet.utils + +enum class MessageType(val value: String) { + KIOSK_TOGGLE("KIOSK_TOGGLE"), + UNKNOWN("UNKNOWN"); + + companion object { + fun fromString(value: String?): MessageType { + return entries.find { it.value == value } ?: UNKNOWN + } + } +} \ No newline at end of file diff --git a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/MessageValidator.kt b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/MessageValidator.kt new file mode 100644 index 0000000000000000000000000000000000000000..a58b668a890a24afdcf4e528996d6e92f89dc946 --- /dev/null +++ b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/utils/MessageValidator.kt @@ -0,0 +1,27 @@ +package band.effective.office.tablet.utils + +import com.google.firebase.messaging.RemoteMessage + +/** + * Validator for checking the integrity of incoming messages. + */ +object MessageValidator { + /** + * Checks if the message contains valid kiosk command data. + */ + fun isValidKioskCommand(message: RemoteMessage): Boolean { + return message.data.containsKey("isKioskModeActive") && + (message.data.containsKey("deviceId") || message.data.containsKey("deviceIds")) + } + + /** + * Validates and converts the kiosk mode value from a string to a boolean. + */ + fun validateKioskModeValue(value: String?): Boolean? { + return try { + value?.toBooleanStrictOrNull() + } catch (e: IllegalArgumentException) { + null + } + } +} \ No newline at end of file diff --git a/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/di/FirebaseTopicsModule.kt b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/di/FirebaseTopicsModule.kt index ffb896de70ca42cd76240604673657cea474817c..0fbe83160f7d020c630cb39757cdb4cf4baefb83 100644 --- a/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/di/FirebaseTopicsModule.kt +++ b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/di/FirebaseTopicsModule.kt @@ -4,5 +4,5 @@ import org.koin.core.qualifier.named import org.koin.dsl.module val firebaseTopicsModule = module { - single(named("FireBaseTopics")) { listOf("effectiveoffice-workspace", "effectiveoffice-user", "effectiveoffice-booking") } + single(named("FireBaseTopics")) { listOf("effectiveoffice-workspace", "effectiveoffice-user", "effectiveoffice-booking","kiosk-commands") } } \ No newline at end of file