[안드로이드] Wifi기반 체류 시간 앱 - 5

잔여 작업

1. datastore로 response data 저장 로직(user_id 혹은 log_id)

2. 네트워크가 없을 시 (offline) 네트워크가 연결 되었을때 즉시 log 종료 호출

3. 백그라운드 실행(로그)

4. 통계 분석 API 개발

5. 통계 시각화

6. 클라우드 배포

 

다음의 작업 중에서 우선 앱 디자인을 먼저 하였다

 

 

 

다음의 형식으로 디자인을 하였으며

 

이후 통계 분석 API 개발하였다

 

from sqlalchemy.orm import Session
import models
from datetime import datetime, timedelta, timezone
from typing import Dict

# 한국 시간대 설정
KST = timezone(timedelta(hours=9))

def get_weekly_statistics(db: Session, user_id: int) -> Dict:
    # 오늘 날짜
    today = datetime.now(KST).date()
    
    # 이번 주의 월요일 찾기
    monday = today - timedelta(days=today.weekday())
    sunday = monday + timedelta(days=6)
    
    # 해당 사용자의 이번 주 로그 조회
    logs = db.query(models.WifiLog).filter(
        models.WifiLog.user_id == user_id,
        models.WifiLog.start_time >= datetime.combine(monday, datetime.min.time()).replace(tzinfo=KST),
        models.WifiLog.start_time <= datetime.combine(sunday, datetime.max.time()).replace(tzinfo=KST)
    ).all()
    
    # 일별 체류 시간 계산 (월요일부터 일요일까지)
    daily_hours = {}
    for i in range(7):
        current_date = (monday + timedelta(days=i)).strftime('%Y-%m-%d')
        daily_hours[current_date] = 0
    
    current_time = datetime.now(KST)
    
    # 각 로그별 체류 시간 계산
    for log in logs:
        log_date = log.start_time.strftime('%Y-%m-%d')
        
        # 종료 시간이 없는 경우 현재 시간을 기준으로 계산
        end_time = log.end_time if log.end_time else current_time
        time_long = (end_time - log.start_time).total_seconds() / 3600
        
        # 24시간을 넘지 않도록 보정
        time_long = min(time_long, 24.0)
        daily_hours[log_date] = round(time_long + daily_hours[log_date], 1) 
    
    # 주간 평균 계산
    weekly_total = sum(daily_hours.values())
    weekly_average = weekly_total / 7
    
    return {
        "week_start": monday.strftime('%Y-%m-%d'),
        "week_end": sunday.strftime('%Y-%m-%d'),
        "daily_hours": daily_hours,
        "weekly_total": round(weekly_total, 1),
        "weekly_average": round(weekly_average, 1)
    }

 

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from database import get_db
from api import statistics_api
from typing import Dict

statistics_router = APIRouter(prefix="/statistics", tags=["statistics"])

@statistics_router.get("/weekly/{user_id}")
def get_weekly_statistics(user_id: int, db: Session = Depends(get_db)) -> Dict:
    try:
        return statistics_api.get_weekly_statistics(db=db, user_id=user_id)
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

 

다음의 형식으로 현재의 주간을 기준으로 주간 로그를 계산하여 주간 평균과 총 체류 시간을 출력하는 API를 제작하였다

 

이후의 작업으로는  datastore로 response data 저장 로직을 구현하였다

다음의 공식 문서를 참고하였다

https://developer.android.com/topic/libraries/architecture/datastore?hl=ko

 

앱 아키텍처: 데이터 영역 - Datastore - Android 개발자  |  App architecture  |  Android Developers

데이터 영역 라이브러리에 관한 이 앱 아키텍처 가이드를 통해 Preferences DataStore 및 Proto DataStore, 설정 등을 알아보세요.

developer.android.com

 

package com.example.app.data

import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

val Context.dataStore by preferencesDataStore(name = "stay_home_prefs")

object PrefsKeys {
    val USER_ID = intPreferencesKey("user_id")
    val HOME_SSID = stringPreferencesKey("home_ssid")
    val HOME_BSSID = stringPreferencesKey("home_bssid")
    val CURRENT_LOG_ID = intPreferencesKey("current_log_id")
}

suspend fun saveUserId(context: Context, userId: Int) {
    context.dataStore.edit { prefs ->
        prefs[PrefsKeys.USER_ID] = userId
    }
}

fun userIdFlow(context: Context): Flow<Int?> =
    context.dataStore.data.map { prefs -> prefs[PrefsKeys.USER_ID] }

suspend fun clearUserId(context: Context) {
    context.dataStore.edit { prefs ->
        prefs.remove(PrefsKeys.USER_ID)
    }
}

suspend fun saveHomeWifi(context: Context, ssid: String, bssid: String) {
    context.dataStore.edit { prefs ->
        prefs[PrefsKeys.HOME_SSID] = ssid
        prefs[PrefsKeys.HOME_BSSID] = bssid
    }
}

fun homeWifiFlow(context: Context): Flow<Pair<String?, String?>> =
    context.dataStore.data.map { prefs ->
        Pair(prefs[PrefsKeys.HOME_SSID], prefs[PrefsKeys.HOME_BSSID])
    }

suspend fun saveCurrentLogId(context: Context, logId: Int) {
    context.dataStore.edit { prefs ->
        prefs[PrefsKeys.CURRENT_LOG_ID] = logId
    }
}

suspend fun clearCurrentLogId(context: Context) {
    context.dataStore.edit { prefs ->
        prefs.remove(PrefsKeys.CURRENT_LOG_ID)
    }
}

fun currentLogIdFlow(context: Context): Flow<Int?> =
    context.dataStore.data.map { prefs -> prefs[PrefsKeys.CURRENT_LOG_ID] }

 

다음과 같이 user_id, wifi의 ssid, bssid, 그리고 log_id를 앱(기기)에 저장되게 하였다

그리고 저장된 user_id를 로드하여 이를 활용하도록 하였다

// 저장된 user_id 로드하여 표시
fun loadSavedUserId(context: Context) {
    viewModelScope.launch {
        userIdFlow(context).collect { id ->
            if (id != null) {
                userId = id
                _uiState.update { it.copy(savedUserId = id) }
            }
        }
    }
}

 

그리고 kotlin에서 비동기작업을 처리하는 방식은 크게 3가지가 있는데

즉시 실행할 필요는 없지만, 나중에라도 꼭 실행되어야 하는 작업에서 활용되는 WorkManager

사용자가 앱의 활동을 인지하고 있으며, 시스템에 의해 종료될 확률이 낮은 Foreground Service

특정 시스템 이벤트에 반응하여 짧은 작업을 해야 할 때 BroadcastReceiver

Wifi 기반의 서비스인 만큼 WorkManager 나 BroadcastReceiver를 사용할 수 있겠으며, 

그 중 android에서 추천하기도하고, 처리하는 방식이 간단한 workmanager를 채택하였다

https://developer.android.com/topic/libraries/architecture/workmanager?hl=ko

 

앱 아키텍처: 데이터 영역 - WorkManager로 작업 예약 - Android 개발자  |  App architecture  |  Android Devel

데이터 영역 라이브러리에 관한 이 앱 아키텍처 가이드를 통해 지속적인 작업 유형과 기능 등을 알아보세요.

developer.android.com

다음의 공식문서를 참고하였으며, 우선 1분마다 Wifi 정보를 불러와 현재 Wifi의 정보와 datastore의 저장되어 있는 wifi의 정보를 비교하여 wifi의 체류시간을 정하는 기술을 채택하였다

 

package com.example.app.worker

import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.wifi.WifiManager
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.example.app.data.*
import com.example.app.data.LogResponse
import com.example.app.data.StartLogRequest
import com.example.app.network.RetrofitClient
import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit

class WifiMonitorWorker(
    appContext: Context,
    params: WorkerParameters
) : CoroutineWorker(appContext, params) {

    override suspend fun doWork(): Result {
        try {
            val ctx = applicationContext

            val uid = userIdFlow(ctx).first() ?: return scheduleNext()
            val (homeSsid, homeBssid) = homeWifiFlow(ctx).first()
            if (homeSsid.isNullOrBlank() || homeBssid.isNullOrBlank()) return scheduleNext()

            val (curSsid, curBssid, isWifi) = getCurrentWifi(ctx)
            val isHome = isWifi && curSsid == homeSsid && curBssid.equals(homeBssid, ignoreCase = true)

            val currentLogId = currentLogIdFlow(ctx).first()

            if (isHome) {
                if (currentLogId == null) {
                    try {
                        val log: LogResponse = RetrofitClient.instance.startLog(StartLogRequest(uid))
                        saveCurrentLogId(ctx, log.id)
                    } catch (_: Exception) { /* keep silent; will retry next tick */ }
                }
            } else {
                if (currentLogId != null) {
                    try {
                        RetrofitClient.instance.endLog(currentLogId)
                    } catch (_: Exception) { /* keep silent */ }
                    clearCurrentLogId(ctx)
                }
            }

            return scheduleNext()
        } catch (_: Exception) {
            return scheduleNext()
        }
    }

    private fun scheduleNext(): Result {
        val req = OneTimeWorkRequestBuilder<WifiMonitorWorker>()
            .setInitialDelay(1, TimeUnit.MINUTES)
            .build()
        WorkManager.getInstance(applicationContext)
            .enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, req)
        return Result.success()
    }

    private fun getCurrentWifi(context: Context): Triple<String, String, Boolean> {
        val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
        val network = cm.activeNetwork
        val caps = cm.getNetworkCapabilities(network)
        val isWifi = caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true

        if (!isWifi) return Triple("", "", false)
        val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
        @Suppress("DEPRECATION")
        val info = wifiManager.connectionInfo
        val ssid = info?.ssid?.removeSurrounding("\"") ?: ""
        val bssid = info?.bssid ?: ""
        return Triple(ssid, bssid, true)
    }

    companion object {
        const val WORK_NAME = "wifi_monitor_worker"
    }
}

 

이후의 작업으로는

1. 통계의 시각화

2. 디자인 적용

 

다음의 사항들이 예정되어 있으며 네트워크가 없을 시 (offline) 네트워크가 연결 되었을때 즉시 log 종료 호출의 상황도 고려를 하겠다