Коммит 73cd0334 создал по автору Vitaly.Smirnov's avatar Vitaly.Smirnov Зафиксировано автором Radch-enko
Просмотр файлов

feat: Implement Kiosk Mode Toggle for Client and Backend

(cherry picked from commit d750c079aa8f2e372a72530e79d9556552c32029)
владелец 47d246d8
......@@ -38,10 +38,12 @@ 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
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.notifications.controller
management:
endpoints:
......
-- 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';
\ Нет новой строки в конце файла
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
\ Нет новой строки в конце файла
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<Any> {
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<Any> {
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<Any> {
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<Any> {
return toggleKioskForAllDevices(false)
}
/**
* Toggles kiosk mode for a specific device.
*/
private fun toggleKioskForDevice(deviceId: String, isKioskModeActive: Boolean): ResponseEntity<Any> {
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<Any> {
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<List<DeviceDto>> {
val devices = deviceService.getAllDevices().map { DeviceDto.fromEntity(it) }
return ResponseEntity.ok(devices)
}
}
\ Нет новой строки в конце файла
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
)
}
}
}
\ Нет новой строки в конце файла
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
)
\ Нет новой строки в конце файла
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
)
\ Нет новой строки в конце файла
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<DeviceEntity, UUID> {
/**
* Check if device exists by device_id
* @return true if device exists, false otherwise
*/
fun existsByDeviceId(deviceId: String): Boolean
}
\ Нет новой строки в конце файла
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()
)
\ Нет новой строки в конце файла
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<DeviceEntity> {
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)
}
}
\ Нет новой строки в конце файла
......@@ -23,4 +23,18 @@ class FcmNotificationSender(
.build()
fcm.send(msg)
}
override fun sendDataMessage(topic: String, data: Map<String, String>) {
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}")
}
}
}
......@@ -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<String, String>)
}
\ Нет новой строки в конце файла
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<KioskManager?> { 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())
}
}
}
}
\ Нет новой строки в конце файла
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<String> 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)
}
}
\ Нет новой строки в конце файла
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<String> =
raw?.split(",")
?.map { it.trim() }
.orEmpty()
}
\ Нет новой строки в конце файла
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
}
}
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<KioskCommand>()
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 }
}
}
}
}
\ Нет новой строки в конце файла
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
}
}
\ Нет новой строки в конце файла
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()
}
}
\ Нет новой строки в конце файла
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
}
}
}
\ Нет новой строки в конце файла
Поддерживает Markdown
0% или .
You are about to add 0 people to the discussion. Proceed with caution.
Сначала завершите редактирование этого сообщения!
Пожалуйста, зарегистрируйтесь или чтобы прокомментировать