diff --git a/app/src/main/java/uk/org/openseizuredetector/LogManager.java b/app/src/main/java/uk/org/openseizuredetector/LogManager.java index 66a26c5..b604eb9 100644 --- a/app/src/main/java/uk/org/openseizuredetector/LogManager.java +++ b/app/src/main/java/uk/org/openseizuredetector/LogManager.java @@ -97,7 +97,7 @@ public class LogManager { private boolean mLogRemote; private boolean mLogRemoteMobile; private String mAuthToken; - static private SQLiteDatabase mOsdDb = null; // SQLite Database for data and log entries. + static public SQLiteDatabase mOsdDb = null; // SQLite Database for data and log entries. private RemoteLogTimer mRemoteLogTimer; private boolean mLogNDA; public NDATimer mNDATimer; @@ -568,7 +568,7 @@ public class LogManager { //long endDateMillis = currentDateMillis - 3600*1000* mDataRetentionPeriod; // Using hours rather than days for testing SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); String endDateStr = dateFormat.format(new Date(endDateMillis)); - String[] tableNames = new String[]{mDpTableName, mEventsTableName}; + String[] tableNames = new String[]{mDpTableName}; for (String tableName : tableNames) { Log.i(TAG, "pruneLocalDb - pruning table " + tableName); try { diff --git a/app/src/main/java/uk/org/openseizuredetector/ReportManager.java b/app/src/main/java/uk/org/openseizuredetector/ReportManager.java new file mode 100644 index 0000000..f057cfa --- /dev/null +++ b/app/src/main/java/uk/org/openseizuredetector/ReportManager.java @@ -0,0 +1,779 @@ +package uk.org.openseizuredetector; + +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.util.Log; + +import org.json.JSONObject; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +public class ReportManager { + private static final String TAG = "ReportManager"; + private static final int GROUP_THRESHOLD_SECS = 30; + private static final int MONTHS_TO_SHOW = 12; + + public static String generateHtmlReport(SQLiteDatabase db, int days) { + if (db == null) { + return "

Error: Database not available

"; + } + + // Fetch full calendar months for the report selector. This avoids the month dropdown + // losing the previous month when the app moves into a new month. + String query = "SELECT dataTime, status, type, notes, dataJSON " + + "FROM events " + + "WHERE dataTime >= date('now', 'start of month', '-" + (MONTHS_TO_SHOW - 1) + " months') " + + "AND status IN (1, 2, 3, 5) " + + "ORDER BY dataTime ASC"; + + Cursor cursor = null; + ArrayList rawEvents = new ArrayList<>(); + + try { + cursor = db.rawQuery(query, null); + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.UK); + + while (cursor.moveToNext()) { + SeizureEvent event = new SeizureEvent(); + event.dataTime = cursor.getString(0); + event.status = cursor.getInt(1); + event.type = cursor.getString(2); + event.notes = cursor.getString(3); + + try { + String dataJson = cursor.getString(4); + if (dataJson != null) { + JSONObject jo = new JSONObject(dataJson); + event.hr = jo.optDouble("hr", 0.0); + event.alarmPhrase = jo.optString("alarmPhrase", ""); + event.alarmCause = jo.optString("alarmCause", "").trim(); + } + } catch (Exception e) { + Log.w(TAG, "Error parsing dataJSON: " + e.getMessage()); + } + + try { + event.date = sdf.parse(event.dataTime); + } catch (ParseException e) { + Log.w(TAG, "Error parsing date: " + event.dataTime); + } + + rawEvents.add(event); + } + } catch (Exception e) { + Log.e(TAG, "Error querying database: " + e.getMessage()); + return "

Error querying database: " + e.getMessage() + "

"; + } finally { + if (cursor != null) cursor.close(); + } + + ArrayList groups = groupEvents(rawEvents); + return buildCalendarHtml(groups, days); + } + + private static ArrayList groupEvents(ArrayList events) { + ArrayList groups = new ArrayList<>(); + if (events.isEmpty()) return groups; + + SeizureGroup currentGroup = null; + + for (SeizureEvent event : events) { + if (currentGroup == null) { + currentGroup = new SeizureGroup(event); + } else { + long diffSecs = 0; + if (event.date != null && currentGroup.lastDate != null) { + diffSecs = (event.date.getTime() - currentGroup.lastDate.getTime()) / 1000; + } + + if (diffSecs <= GROUP_THRESHOLD_SECS) { + currentGroup.lastDate = event.date; + currentGroup.lastEvent = event; + if (event.notes != null && event.notes.contains("Duration:")) { + currentGroup.durationStr = extractDuration(event.notes); + } + if (event.hr > 0) { + currentGroup.hr = event.hr; + } + } else { + groups.add(currentGroup); + currentGroup = new SeizureGroup(event); + } + } + } + + if (currentGroup != null) { + groups.add(currentGroup); + } + + return groups; + } + + private static String extractDuration(String notes) { + try { + int start = notes.indexOf("Duration:") + 9; + int end = notes.indexOf("HR:"); + if (end == -1) end = notes.length(); + return notes.substring(start, end).trim(); + } catch (Exception e) { + return "unknown"; + } + } + + private static String getTimeOfDayColor(Date date) { + if (date == null) return "#888888"; + Calendar cal = Calendar.getInstance(); + cal.setTime(date); + int hour = cal.get(Calendar.HOUR_OF_DAY); + if (hour >= 0 && hour < 6) return "#3b4a6b"; // Night - dark blue + if (hour >= 6 && hour < 12) return "#e6a817"; // Morning - yellow + if (hour >= 12 && hour < 18) return "#e07b2a"; // Afternoon - orange + return "#7b4fa6"; // Evening - purple + } + + private static String getTimeOfDayLabel(Date date) { + if (date == null) return ""; + Calendar cal = Calendar.getInstance(); + cal.setTime(date); + int hour = cal.get(Calendar.HOUR_OF_DAY); + if (hour >= 0 && hour < 6) return "Night"; + if (hour >= 6 && hour < 12) return "Morning"; + if (hour >= 12 && hour < 18) return "Afternoon"; + return "Evening"; + } + + private static int getMinutesOfDay(Date date) { + if (date == null) return 0; + Calendar cal = Calendar.getInstance(); + cal.setTime(date); + return cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE); + } + + private static int parseDurationMinutes(String durationStr) { + if (durationStr == null) return 5; + + String s = durationStr.trim().toLowerCase(Locale.UK); + if (s.isEmpty() || s.equals("n/a") || s.equals("unknown")) { + return 5; + } + + try { + // Supports HH:MM:SS + if (s.matches("\\d{1,2}:\\d{1,2}:\\d{1,2}")) { + String[] parts = s.split(":"); + int hours = Integer.parseInt(parts[0]); + int mins = Integer.parseInt(parts[1]); + int secs = Integer.parseInt(parts[2]); + int totalSeconds = hours * 3600 + mins * 60 + secs; + return Math.max(1, (int) Math.ceil(totalSeconds / 60.0)); + } + + // Supports MM:SS + if (s.matches("\\d{1,2}:\\d{1,2}")) { + String[] parts = s.split(":"); + int mins = Integer.parseInt(parts[0]); + int secs = Integer.parseInt(parts[1]); + int totalSeconds = mins * 60 + secs; + return Math.max(1, (int) Math.ceil(totalSeconds / 60.0)); + } + + // Supports text like "2 min 30 sec" or "1 hour 5 min" + int totalMinutes = 0; + String[] tokens = s.replace(",", " ").split("\\s+"); + for (int i = 0; i < tokens.length - 1; i++) { + try { + int value = Integer.parseInt(tokens[i]); + String unit = tokens[i + 1]; + if (unit.startsWith("hour") || unit.startsWith("hr")) { + totalMinutes += value * 60; + } else if (unit.startsWith("min")) { + totalMinutes += value; + } else if (unit.startsWith("sec")) { + if (value > 0) totalMinutes += 1; + } + } catch (NumberFormatException ignored) { + // Keep checking the rest of the duration text. + } + } + + if (totalMinutes > 0) return totalMinutes; + } catch (Exception ignored) { + } + + return 5; + } + + private static String getStatusColor(int status) { + switch (status) { + case 1: return "#f59e0b"; // WARNING + case 2: return "#ef4444"; // ALARM + case 3: return "#8b5cf6"; // FALL + case 5: return "#06b6d4"; // MANUAL + default: return "#6b7280"; + } + } + + private static String formatPct(double value) { + return String.format(Locale.UK, "%.2f", value); + } + + private static String statusToString(int status) { + switch (status) { + case 1: return "WARNING"; + case 2: return "ALARM"; + case 3: return "FALL"; + case 5: return "MANUAL"; + default: return "UNKNOWN"; + } + } + + private static String statusToCssClass(int status) { + switch (status) { + case 1: return "warning"; + case 2: return "alarm"; + case 3: return "fall"; + case 5: return "manual"; + default: return "unknown"; + } + } + + private static String escapeHtml(String value) { + if (value == null) return ""; + return value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + private static void appendSummaryBar(StringBuilder sb, String label, String range, String cssClass, + int count, int maxCount) { + double pct = maxCount > 0 ? (count / (double) maxCount) * 100.0 : 0.0; + sb.append("
"); + sb.append("
").append(label).append("
") + .append(range).append("
"); + sb.append("
"); + sb.append("
").append(count).append("
"); + sb.append("
"); + } + + private static String buildCalendarHtml(ArrayList groups, int days) { + SimpleDateFormat dayKeyFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.UK); + SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm:ss", Locale.UK); + SimpleDateFormat monthFormat = new SimpleDateFormat("MMMM yyyy", Locale.UK); + SimpleDateFormat monthIdFormat = new SimpleDateFormat("yyyy-MM", Locale.UK); + + // Group seizures by day and by month. + Map> byDay = new HashMap<>(); + Map eventsByMonth = new HashMap<>(); + for (SeizureGroup g : groups) { + if (g.startDate != null) { + String dayKey = dayKeyFormat.format(g.startDate); + if (!byDay.containsKey(dayKey)) { + byDay.put(dayKey, new ArrayList()); + } + byDay.get(dayKey).add(g); + + String monthKey = monthIdFormat.format(g.startDate); + Integer count = eventsByMonth.get(monthKey); + eventsByMonth.put(monthKey, count == null ? 1 : count + 1); + } + } + + Calendar todayCal = Calendar.getInstance(); + Date today = todayCal.getTime(); + + // Build a fixed list of recent full months so the dropdown remains useful across + // month boundaries, for example allowing April to be selected after May begins. + Calendar firstMonth = Calendar.getInstance(); + firstMonth.add(Calendar.MONTH, -(MONTHS_TO_SHOW - 1)); + firstMonth.set(Calendar.DAY_OF_MONTH, 1); + firstMonth.set(Calendar.HOUR_OF_DAY, 0); + firstMonth.set(Calendar.MINUTE, 0); + firstMonth.set(Calendar.SECOND, 0); + firstMonth.set(Calendar.MILLISECOND, 0); + + Calendar lastMonth = Calendar.getInstance(); + lastMonth.set(Calendar.DAY_OF_MONTH, 1); + lastMonth.set(Calendar.HOUR_OF_DAY, 0); + lastMonth.set(Calendar.MINUTE, 0); + lastMonth.set(Calendar.SECOND, 0); + lastMonth.set(Calendar.MILLISECOND, 0); + + ArrayList reportMonths = new ArrayList<>(); + Calendar monthCursor = (Calendar) firstMonth.clone(); + while (!monthCursor.after(lastMonth)) { + reportMonths.add((Calendar) monthCursor.clone()); + monthCursor.add(Calendar.MONTH, 1); + } + + String defaultMonthId = monthIdFormat.format(today); + + StringBuilder sb = new StringBuilder(); + sb.append(""); + sb.append(""); + sb.append(""); + sb.append("ClinX02 Seizure Report"); + sb.append(""); + sb.append("
"); + + sb.append("

ClinX02 Seizure Report

"); + sb.append("

Calendar timeline overview and detailed event log

"); + + // Summary box + sb.append("
"); + sb.append("
"); + + sb.append("
"); + sb.append("
Calendar range
"); + sb.append("
").append(MONTHS_TO_SHOW).append(" months
"); + sb.append("
"); + + sb.append("
"); + sb.append("
Total seizure events
"); + sb.append("
").append(groups.size()).append("
"); + sb.append("
"); + + sb.append("
"); + sb.append("
Days with seizures
"); + sb.append("
").append(byDay.size()).append("
"); + sb.append("
"); + + sb.append("
"); + sb.append("Download Report"); + sb.append("
"); + + // Legend + sb.append("
"); + sb.append("
Night background (00-06)
"); + sb.append("
Morning background (06-12)
"); + sb.append("
Afternoon background (12-18)
"); + sb.append("
Evening background (18-24)
"); + sb.append("
Warning bar
"); + sb.append("
Alarm bar
"); + sb.append("
Fall bar
"); + sb.append("
Manual bar
"); + sb.append("
"); + + // Month selector + sb.append("
"); + sb.append(""); + sb.append(""); + sb.append(""); + sb.append("
"); + + // Monthly time-of-day summary chart + for (Calendar reportMonth : reportMonths) { + String monthId = monthIdFormat.format(reportMonth.getTime()); + boolean activeMonth = monthId.equals(defaultMonthId); + int nightCount = 0; + int morningCount = 0; + int afternoonCount = 0; + int eveningCount = 0; + + for (SeizureGroup g : groups) { + if (g.startDate != null && monthId.equals(monthIdFormat.format(g.startDate))) { + Calendar eventCal = Calendar.getInstance(); + eventCal.setTime(g.startDate); + int hour = eventCal.get(Calendar.HOUR_OF_DAY); + if (hour < 6) { + nightCount++; + } else if (hour < 12) { + morningCount++; + } else if (hour < 18) { + afternoonCount++; + } else { + eveningCount++; + } + } + } + + int maxTimeCount = Math.max(Math.max(nightCount, morningCount), Math.max(afternoonCount, eveningCount)); + if (maxTimeCount < 1) maxTimeCount = 1; + + sb.append("
"); + sb.append("
Seizures by time of day
"); + appendSummaryBar(sb, "Night", "00-06", "night", nightCount, maxTimeCount); + appendSummaryBar(sb, "Morning", "06-12", "morning", morningCount, maxTimeCount); + appendSummaryBar(sb, "Afternoon", "12-18", "afternoon", afternoonCount, maxTimeCount); + appendSummaryBar(sb, "Evening", "18-24", "evening", eveningCount, maxTimeCount); + sb.append("
"); + } + + // Calendar months + for (Calendar reportMonth : reportMonths) { + String monthId = monthIdFormat.format(reportMonth.getTime()); + boolean activeMonth = monthId.equals(defaultMonthId); + + Calendar cal = (Calendar) reportMonth.clone(); + int currentMonth = cal.get(Calendar.MONTH); + int currentYear = cal.get(Calendar.YEAR); + int daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH); + int firstDayOfWeek = cal.get(Calendar.DAY_OF_WEEK); // 1=Sun, 2=Mon... + + sb.append("
"); + sb.append("

").append(monthFormat.format(reportMonth.getTime())).append("

"); + sb.append("
"); + + String[] dayNames = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}; + for (String d : dayNames) { + sb.append("
").append(d).append("
"); + } + + for (int i = 1; i < firstDayOfWeek; i++) { + sb.append("
"); + } + + Calendar today2 = Calendar.getInstance(); + for (int day = 1; day <= daysInMonth; day++) { + String dayKey = String.format(Locale.UK, "%04d-%02d-%02d", currentYear, currentMonth + 1, day); + ArrayList dayGroups = byDay.get(dayKey); + boolean isToday = (day == today2.get(Calendar.DAY_OF_MONTH) + && currentMonth == today2.get(Calendar.MONTH) + && currentYear == today2.get(Calendar.YEAR)); + + String boxClass = "day-box"; + if (isToday) boxClass += " today"; + if (dayGroups != null && !dayGroups.isEmpty()) boxClass += " has-seizures"; + + sb.append("
"); + sb.append("
").append(day).append("
"); + + if (dayGroups != null && !dayGroups.isEmpty()) { + sb.append("
"); + sb.append("
"); + sb.append("
"); + sb.append("
"); + sb.append("
"); + sb.append("
"); + sb.append("
"); + sb.append("
"); + sb.append(""); + sb.append(""); + sb.append(""); + sb.append("
"); + + for (SeizureGroup g : dayGroups) { + int startMinutes = getMinutesOfDay(g.startDate); + int durationMinutes = parseDurationMinutes(g.durationStr); + + double leftPct = (startMinutes / 1440.0) * 100.0; + double widthPct = Math.max((durationMinutes / 1440.0) * 100.0, 1.4); + + if (leftPct + widthPct > 100.0) { + widthPct = Math.max(0.8, 100.0 - leftPct); + } + + String timeStr = g.startDate != null ? timeFormat.format(g.startDate) : ""; + String label = getTimeOfDayLabel(g.startDate); + String tip = label + " " + timeStr + " - " + statusToString(g.firstEvent.status) + + " (" + g.durationStr + ")"; + + sb.append("
"); + } + + sb.append("
"); + sb.append("
0006121824
"); + sb.append("
").append(dayGroups.size()) + .append(dayGroups.size() == 1 ? " event" : " events").append("
"); + } + + sb.append("
"); + } + + sb.append("
"); // calendar + sb.append("
"); // month-panel + } + + // Detailed table below. JavaScript filters this table to the selected month. + sb.append("

Event Details

"); + if (groups.isEmpty()) { + sb.append("

No seizure events recorded in this period.

"); + } else { + sb.append("

No seizure events recorded for the selected month.

"); + sb.append("
"); + sb.append("
"); + sb.append("
"); + sb.append("
"); + sb.append(""); + sb.append(""); + sb.append(""); + sb.append(""); + sb.append("
"); + sb.append("
"); + sb.append("
"); + sb.append("
"); + sb.append(""); + SimpleDateFormat displaySdf = new SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.UK); + int i = 1; + for (SeizureGroup g : groups) { + String timeStr = g.startDate != null ? displaySdf.format(g.startDate) : g.firstEvent.dataTime; + String statusStr = statusToString(g.firstEvent.status); + String cssClass = statusToCssClass(g.firstEvent.status); + String hrStr = g.hr > 0 ? String.format(Locale.UK, "%.1f bpm", g.hr) : "N/A"; + String cause = g.firstEvent.alarmCause != null && !g.firstEvent.alarmCause.isEmpty() + ? g.firstEvent.alarmCause : "Unknown"; + String dotColor = getTimeOfDayColor(g.startDate); + String eventMonth = g.startDate != null ? monthIdFormat.format(g.startDate) : ""; + + sb.append(""); + sb.append(""); + sb.append(""); + sb.append(""); + sb.append(""); + sb.append(""); + sb.append(""); + sb.append(""); + } + sb.append("
#Date & TimeStatusDurationHeart RateCause
").append(i++).append("").append(timeStr).append("").append(statusStr).append("").append(escapeHtml(g.durationStr)).append("").append(hrStr).append("").append(escapeHtml(cause)).append("
"); + } + + sb.append(""); + sb.append("
"); + + sb.append(""); + sb.append(""); + + return sb.toString(); + } + + static class SeizureEvent { + String dataTime; + int status; + String type; + String notes; + String alarmPhrase; + String alarmCause; + double hr = 0.0; + Date date; + } + + static class SeizureGroup { + SeizureEvent firstEvent; + SeizureEvent lastEvent; + Date startDate; + Date lastDate; + String durationStr = "N/A"; + double hr = 0.0; + + SeizureGroup(SeizureEvent first) { + this.firstEvent = first; + this.lastEvent = first; + this.startDate = first.date; + this.lastDate = first.date; + if (first.notes != null && first.notes.contains("Duration:")) { + this.durationStr = extractDuration(first.notes); + } + this.hr = first.hr; + } + } +} diff --git a/app/src/main/java/uk/org/openseizuredetector/SdWebServer.java b/app/src/main/java/uk/org/openseizuredetector/SdWebServer.java index d777932..5d300c7 100644 --- a/app/src/main/java/uk/org/openseizuredetector/SdWebServer.java +++ b/app/src/main/java/uk/org/openseizuredetector/SdWebServer.java @@ -26,11 +26,17 @@ import fi.iki.elonen.NanoHTTPD; */ public class SdWebServer extends NanoHTTPD { private String TAG = "WebServer"; + private static final String REPORT_TOKEN = "clinx02secure"; private SdData mSdData; private SdServer mSdServer; private Context mContext; private Handler mHandler; private OsdUtil mUtil; + + private boolean isValidToken(Map parameters) { + String token = parameters.get("token"); + return REPORT_TOKEN.equals(token); + } public SdWebServer(Context context, SdData sdData, SdServer sdServer) { // Set the port to listen on (8080) @@ -191,6 +197,33 @@ public class SdWebServer extends NanoHTTPD { mSdServer.acceptAlarm(); answer = "{'msg' : 'Alarm Accepted'}"; break; + + case "/report": + if (!isValidToken(parameters)) { + return new NanoHTTPD.Response(NanoHTTPD.Response.Status.FORBIDDEN, + "text/html", "

403 Forbidden - Invalid or missing token

"); + } + String days = parameters.get("days"); + if (days == null) days = "1"; + answer = ReportManager.generateHtmlReport( + LogManager.mOsdDb, Integer.parseInt(days)); + responseMimeType = "text/html"; + break; + + case "/report/download": + if (!isValidToken(parameters)) { + return new NanoHTTPD.Response(NanoHTTPD.Response.Status.FORBIDDEN, + "text/html", "

403 Forbidden - Invalid or missing token

"); + } + String dlDays = parameters.get("days"); + if (dlDays == null) dlDays = "1"; + String reportHtml = ReportManager.generateHtmlReport( + LogManager.mOsdDb, Integer.parseInt(dlDays)); + NanoHTTPD.Response dlRes = new NanoHTTPD.Response( + NanoHTTPD.Response.Status.OK, "text/html", reportHtml); + dlRes.addHeader("Content-Disposition", + "attachment; filename=\"seizure_report.html\""); + return dlRes; default: if (uri.startsWith("/index.html") ||