diff --git a/components/accessSchedule/AccessScheduleTask.bs b/components/accessSchedule/AccessScheduleTask.bs
new file mode 100644
index 000000000..566c2cc93
--- /dev/null
+++ b/components/accessSchedule/AccessScheduleTask.bs
@@ -0,0 +1,28 @@
+import "pkg:/source/api/baserequest.bs"
+import "pkg:/source/utils/accessSchedule.bs"
+import "pkg:/source/utils/misc.bs"
+
+sub init()
+ m.top.functionName = "readSchedule"
+end sub
+
+sub readSchedule()
+ m.top.result = loadSchedule(m.top.userId)
+end sub
+
+function loadSchedule(userId as string) as object
+ failed = { ok: false, exempt: false, entries: [] }
+ if not isValidAndNotEmpty(userId) then return failed
+
+ response = requestResponse(APIRequest("Users/" + userId), "GET")
+ if response.status <> 200 or type(response.data) <> "roAssociativeArray" then return failed
+
+ policy = response.data.Policy
+ if type(policy) <> "roAssociativeArray" then return failed
+
+ exempt = policy.IsAdministrator = true
+ entries = accessSchedule.Read(policy)
+ refused = invalid
+ if not exempt and entries.Count() > 0 then refused = accessSchedule.RefusedBy(requestResponse(APIRequest("Users/Me"), "GET").status)
+ return { ok: true, exempt: exempt, entries: entries, refused: refused }
+end function
diff --git a/components/accessSchedule/AccessScheduleTask.xml b/components/accessSchedule/AccessScheduleTask.xml
new file mode 100644
index 000000000..450f954a2
--- /dev/null
+++ b/components/accessSchedule/AccessScheduleTask.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
diff --git a/components/accessSchedule/AccessScheduleWatcher.bs b/components/accessSchedule/AccessScheduleWatcher.bs
new file mode 100644
index 000000000..c897a7e32
--- /dev/null
+++ b/components/accessSchedule/AccessScheduleWatcher.bs
@@ -0,0 +1,136 @@
+import "pkg:/source/utils/accessSchedule.bs"
+import "pkg:/source/utils/misc.bs"
+
+' How long to wait between looks when no opening or closing is nearer, for a user with a schedule
+' and for one without, and before asking again after a look that failed
+const POLL_SECONDS = 60
+const IDLE_SECONDS = 900
+const RETRY_SECONDS = 15
+' The least time between looks prompted by the server turning a request away, which can come in
+' bursts
+const REFUSAL_SECONDS = 5
+
+sub init()
+ m.timer = m.top.findNode("timer")
+ m.timer.observeField("fire", "onTimer")
+ m.entries = []
+ m.exempt = false
+ m.task = invalid
+ m.lookAgain = false
+ m.lastRefusalLook = 0
+ m.clock = CreateObject("roTimespan")
+ m.top.blocked = false
+
+ if m.global.hasField("accessRefused") then m.global.observeField("accessRefused", "onRefused")
+end sub
+
+sub onUserChange()
+ m.timer.control = "stop"
+ ' A look still under way for the previous user must not answer for the next
+ cancelTask()
+ m.entries = []
+ m.exempt = false
+ m.lookAgain = false
+ setBlocked(false, invalid)
+ if m.top.userId <> "" then look()
+end sub
+
+sub recheck()
+ if m.top.userId <> "" then look()
+end sub
+
+sub onRefused()
+ ' While blocked every request is refused, the server check included, and a look under way
+ ' already brings the newest answer
+ if m.top.userId = "" or m.top.blocked or m.task <> invalid then return
+ now = m.clock.TotalMilliseconds()
+ if m.lastRefusalLook <> 0 and now - m.lastRefusalLook < REFUSAL_SECONDS * 1000 then return
+ m.lastRefusalLook = now
+ look()
+end sub
+
+sub onTimer()
+ look()
+end sub
+
+sub cancelTask()
+ if m.task <> invalid
+ m.task.unobserveField("result")
+ m.task.control = "stop"
+ m.task = invalid
+ end if
+end sub
+
+' Reads the schedule again. One look at a time: a request that arrives while one is under way
+' asks for another when it ends.
+sub look()
+ m.timer.control = "stop"
+ if m.task <> invalid
+ m.lookAgain = true
+ return
+ end if
+ m.task = CreateObject("roSGNode", "AccessScheduleTask")
+ m.task.userId = m.top.userId
+ m.task.observeField("result", "onResult")
+ m.task.control = "run"
+end sub
+
+sub onResult(event as object)
+ result = event.getData()
+ m.task.unobserveField("result")
+ m.task = invalid
+
+ if result.ok
+ m.entries = result.entries
+ m.exempt = result.exempt
+ apply(result.refused)
+ else
+ ' A schedule that couldn't be read leaves the last answer in place
+ startTimer(RETRY_SECONDS)
+ end if
+
+ if m.lookAgain
+ m.lookAgain = false
+ look()
+ end if
+end sub
+
+sub apply(refused as dynamic)
+ now = CreateObject("roDateTime")
+ now.ToLocalTime()
+ day = now.GetDayOfWeek()
+ secondOfDay = now.GetHours() * 3600 + now.GetMinutes() * 60 + now.GetSeconds()
+
+ status = accessSchedule.Status(m.entries, refused, day, secondOfDay)
+ if m.exempt or not status.blocked
+ setBlocked(false, invalid)
+ else
+ setBlocked(true, status.next)
+ end if
+
+ ' An administrator has no schedule to enforce, so only a refused request looks again
+ if m.exempt then return
+ delay = POLL_SECONDS
+ if m.entries.Count() = 0 then delay = IDLE_SECONDS
+ boundary = accessSchedule.SecondsToBoundary(m.entries, day, secondOfDay)
+ if boundary <> invalid and boundary < delay then delay = boundary
+ startTimer(delay)
+end sub
+
+sub startTimer(seconds as integer)
+ if seconds < 1 then seconds = 1
+ m.timer.duration = seconds
+ m.timer.control = "start"
+end sub
+
+' Told only when the answer changes, so the app stops playback once for each time the user is
+' turned away. The next opening is kept current while blocked, since a changed schedule moves it.
+sub setBlocked(blocked as boolean, upcoming as dynamic)
+ if blocked = m.top.blocked
+ if blocked then m.top.next = upcoming
+ return
+ end if
+ m.top.next = upcoming
+ m.top.blocked = blocked
+ m.top.accessStatus = blocked
+end sub
diff --git a/components/accessSchedule/AccessScheduleWatcher.xml b/components/accessSchedule/AccessScheduleWatcher.xml
new file mode 100644
index 000000000..f8d65097d
--- /dev/null
+++ b/components/accessSchedule/AccessScheduleWatcher.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/accessSchedule/TimesUp.bs b/components/accessSchedule/TimesUp.bs
new file mode 100644
index 000000000..1c517cb01
--- /dev/null
+++ b/components/accessSchedule/TimesUp.bs
@@ -0,0 +1,122 @@
+import "pkg:/source/utils/themeApply.bs"
+
+const CARD_WIDTH = 640
+const CARD_PAD = 32
+const GAP = 14
+const BUTTON_WIDTH = 260
+const BUTTON_HEIGHT = 64
+
+sub init()
+ m.top.optionsAvailable = false
+ ' The screen owns the whole display, so the nav bar is hidden
+ m.top.overhangVisible = false
+ m.card = m.top.findNode("card")
+ m.cardFill = m.top.findNode("cardFill")
+ m.cardBorder = m.top.findNode("cardBorder")
+ m.title = m.top.findNode("title")
+ m.message = m.top.findNode("message")
+ m.nextLabel = m.top.findNode("next")
+ m.switchUser = m.top.findNode("switchUser")
+
+ ' The theme replaces the labels' fonts, so the sizes are set after it
+ themeApply.ApplyToItem(m.top)
+ width = CARD_WIDTH - CARD_PAD * 2
+ m.title.font.size = 48
+ m.title.width = width
+ m.title.text = tr("Time's up")
+ m.message.font.size = 28
+ m.message.width = width
+ m.message.text = tr("This account isn't available right now.")
+ m.nextLabel.font.size = 28
+ m.nextLabel.width = width
+
+ m.switchUser.text = tr("Switch User")
+ m.switchUser.width = BUTTON_WIDTH
+ m.switchUser.height = BUTTON_HEIGHT
+ m.switchUser.observeField("buttonSelected", "onSwitchUser")
+
+ layoutCard()
+end sub
+
+sub OnScreenShown(_isReturning = false as boolean)
+ layoutCard()
+ m.switchUser.setFocus(true)
+end sub
+
+sub onNextChange()
+ m.nextLabel.text = describeNext(m.top.next)
+ layoutCard()
+end sub
+
+' When the user can come back, in words. Empty when no entry opens soon.
+function describeNext(opening as dynamic) as string
+ if type(opening) <> "roAssociativeArray" then return ""
+ time = timeOfDayText(opening.secondOfDay)
+ if opening.dayOffset = 0 then return Substitute(tr("You can use it again today at {0}."), time)
+ if opening.dayOffset = 1 then return Substitute(tr("You can use it again tomorrow at {0}."), time)
+ days = [tr("Sunday"), tr("Monday"), tr("Tuesday"), tr("Wednesday"), tr("Thursday"), tr("Friday"), tr("Saturday")]
+ ' A week out is the same weekday as today, which would read as today, so the date is named too
+ if opening.dayOffset >= 7
+ months = [tr("Jan"), tr("Feb"), tr("Mar"), tr("Apr"), tr("May"), tr("Jun"), tr("Jul"), tr("Aug"), tr("Sep"), tr("Oct"), tr("Nov"), tr("Dec")]
+ date = CreateObject("roDateTime")
+ date.FromSeconds(date.AsSeconds() + opening.seconds)
+ date.ToLocalTime()
+ return Substitute(tr("You can use it again on {0}, {1} {2} at {3}."), days[opening.day], months[date.GetMonth() - 1], date.GetDayOfMonth().ToStr(), time)
+ end if
+ return Substitute(tr("You can use it again on {0} at {1}."), days[opening.day], time)
+end function
+
+' Hours and minutes in the clock style the device is set to
+function timeOfDayText(secondOfDay as integer) as string
+ hours = secondOfDay \ 3600
+ minutes = (secondOfDay mod 3600) \ 60
+ mm = minutes.ToStr()
+ if minutes < 10 then mm = "0" + mm
+ if CreateObject("roDeviceInfo").GetClockFormat() = "12h"
+ suffix = "AM"
+ if hours >= 12 then suffix = "PM"
+ shown = hours mod 12
+ if shown = 0 then shown = 12
+ return shown.ToStr() + ":" + mm + " " + suffix
+ end if
+ hh = hours.ToStr()
+ if hours < 10 then hh = "0" + hh
+ return hh + ":" + mm
+end function
+
+' The line about the next opening only takes room while it has something to say
+sub layoutCard()
+ y = CARD_PAD
+
+ m.title.translation = [CARD_PAD, y]
+ y += m.title.boundingRect().height + GAP
+
+ m.message.translation = [CARD_PAD, y]
+ y += m.message.boundingRect().height
+
+ m.nextLabel.visible = m.nextLabel.text <> ""
+ if m.nextLabel.visible
+ y += GAP
+ m.nextLabel.translation = [CARD_PAD, y]
+ y += m.nextLabel.boundingRect().height
+ end if
+
+ y += CARD_PAD
+ m.switchUser.translation = [(CARD_WIDTH - BUTTON_WIDTH) / 2, y]
+ y += BUTTON_HEIGHT + CARD_PAD
+
+ for each layer in [m.cardFill, m.cardBorder]
+ layer.width = CARD_WIDTH
+ layer.height = y
+ end for
+ m.card.translation = [(1920 - CARD_WIDTH) / 2, (1080 - y) / 2]
+end sub
+
+sub onSwitchUser()
+ m.top.switchUser = true
+end sub
+
+' Nothing gets past the screen to the one behind it, and Back does not leave it
+function onKeyEvent(key as string, press as boolean) as boolean
+ return true
+end function
diff --git a/components/accessSchedule/TimesUp.xml b/components/accessSchedule/TimesUp.xml
new file mode 100644
index 000000000..7053b1ae2
--- /dev/null
+++ b/components/accessSchedule/TimesUp.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/video/VideoPlayerView.bs b/components/video/VideoPlayerView.bs
index 8b3e9eb90..1f7b7edcf 100644
--- a/components/video/VideoPlayerView.bs
+++ b/components/video/VideoPlayerView.bs
@@ -1951,6 +1951,10 @@ sub onState()
errorInfo = FormatJson(m.top.errorInfo ?? {})
diagnostics.Playback(`Player error ${m.top.errorCode} ${m.top.errorMsg}, ${m.top.errorStr}, ${errorInfo}, during ${m.streamSummary}`, diagnostics.LEVEL_WARN)
+ ' A user whose hours have just ended is refused the stream too, and the player does not say
+ ' why, so a failure is a cue to look at the schedule
+ noteAccessRefused()
+
if isValid(m.liveChannel)
onLiveChannelError()
else if isValid(m.channelSwitch)
diff --git a/locale/en_US/translations.ts b/locale/en_US/translations.ts
index 84ad9e625..5e8b2f130 100644
--- a/locale/en_US/translations.ts
+++ b/locale/en_US/translations.ts
@@ -7125,5 +7125,40 @@
This account can't sign in right now. It may be outside its allowed hours, locked or disabled. Try again later or ask an administrator.Shown when the server refuses a sign in because the account can't be used right now, for example outside its access schedule
+
+ Time's up
+ Time's up
+ Title of the screen shown when the signed in user's access schedule has closed
+
+
+ This account isn't available right now.
+ This account isn't available right now.
+ Shown on the Time's up screen
+
+
+ You can use it again today at {0}.
+ You can use it again today at {0}.
+ Shown on the Time's up screen; {0} is a time of day
+
+
+ You can use it again tomorrow at {0}.
+ You can use it again tomorrow at {0}.
+ Shown on the Time's up screen; {0} is a time of day
+
+
+ You can use it again on {0} at {1}.
+ You can use it again on {0} at {1}.
+ Shown on the Time's up screen; {0} is a day of the week and {1} a time of day
+
+
+ Switch User
+ Switch User
+ Button on the Time's up screen that opens the list of users
+
+
+ You can use it again on {0}, {1} {2} at {3}.
+ You can use it again on {0}, {1} {2} at {3}.
+ Shown on the Time's up screen when the next opening is a week or more away; {0} is a day of the week, {1} a month, {2} the day of the month and {3} a time of day
+
diff --git a/source/Main.bs b/source/Main.bs
index 757ad0575..7b63858d8 100644
--- a/source/Main.bs
+++ b/source/Main.bs
@@ -76,8 +76,12 @@ sub Main (args as dynamic) as void
audioMiniPlayer.callFunc("setup")
end if
+ m.accessWatcher = CreateObject("roSGNode", "AccessScheduleWatcher")
+ m.accessWatcher.observeField("accessStatus", m.port)
+
app_start:
stopAchievementsPoll()
+ m.accessWatcher.userId = ""
' Hide both nav elements before login to prevent flash/overlap
overhang.visible = false
@@ -90,6 +94,9 @@ sub Main (args as dynamic) as void
parentalControls.Activate()
if not RunPinGate() then return
+ ' Jellyfin turns a user away outside their access schedule, so the app says so when it closes
+ if not isEmbyServer() then m.accessWatcher.userId = chainLookupReturn(m.global.session, "user.id", "")
+
'Pull user configuration from server and apply settings
'The server reports no language preference as an empty string. The client uses "anyLanguage"
subtitleMode = chainLookupReturn(m.global.session, "user.Configuration.SubtitleMode", "Default")
@@ -277,6 +284,17 @@ sub Main (args as dynamic) as void
onButtonSelectedEvent(msg)
else if isNodeEvent(msg, "content")
onContentEvent(msg)
+ else if isNodeEvent(msg, "accessStatus")
+ ' The event may be an old one by now, so the watcher is asked how things stand
+ if m.accessWatcher.blocked
+ timesUpResult = RunTimesUp(m.accessWatcher)
+ if timesUpResult = "exit" then return
+ ' The account stays saved, so it can be picked again from the list
+ if timesUpResult = "switch" then SignOut(false)
+ setUserColors()
+ sceneManager.callFunc("clearScenes")
+ goto app_start
+ end if
else if isNodeEvent(msg, "accountAction")
accountDialog.visible = false
action = msg.getData()
diff --git a/source/ShowScenes.bs b/source/ShowScenes.bs
index 802ab5008..151701274 100644
--- a/source/ShowScenes.bs
+++ b/source/ShowScenes.bs
@@ -594,6 +594,47 @@ function RunPinGate() as boolean
return true
end function
+' Covers the app while the signed in user's access schedule is closed, after stopping what was
+' playing. Returns "switch" to pick another user, "open" when the hours open again, or "exit" when
+' the app is closed from under it.
+function RunTimesUp(watcher as object) as string
+ m.global.queueManager.callFunc("clear")
+ m.global.audioPlayer.control = "stop"
+ active = m.global.sceneManager.callFunc("getActiveScene")
+ if isValid(active) and active.isSubType("VideoPlayerView") then active.control = "stop"
+
+ stopLoadingSpinner()
+ screen = CreateObject("roSGNode", "TimesUp")
+ screen.next = watcher.next
+ m.global.sceneManager.callFunc("clearScenes")
+ m.global.sceneManager.callFunc("pushScene", screen)
+ port = CreateObject("roMessagePort")
+ screen.observeField("switchUser", port)
+ watcher.observeField("accessStatus", port)
+ watcher.observeField("next", port)
+
+ result = "exit"
+ while true
+ msg = wait(0, port)
+ if type(msg) = "roSGScreenEvent" and msg.isScreenClosed()
+ exit while
+ else if isNodeEvent(msg, "switchUser")
+ result = "switch"
+ exit while
+ else if isNodeEvent(msg, "accessStatus") and not watcher.blocked
+ result = "open"
+ exit while
+ else if isNodeEvent(msg, "next") and watcher.blocked
+ screen.next = watcher.next
+ end if
+ end while
+
+ watcher.unobserveField("accessStatus")
+ watcher.unobserveField("next")
+ watcher.observeField("accessStatus", m.port)
+ return result
+end function
+
function CreateHomeGroup()
' Main screen after logging in. Shows the user's libraries
group = CreateObject("roSGNode", "Home")
diff --git a/source/api/baserequest.bs b/source/api/baserequest.bs
index 1c4c129ef..7eb3d2ea0 100644
--- a/source/api/baserequest.bs
+++ b/source/api/baserequest.bs
@@ -119,6 +119,17 @@ function APIRequest(url as string, params = {} as object) as dynamic
return req
end function
+' Lets the access schedule watcher know the server turned a request away with a 403. A user whose
+' hours have just ended is refused everything, and nothing else would tell the app.
+sub noteAccessRefused()
+ if type(m.global) <> "roSGNode" or not m.global.hasField("accessRefused") then return
+ m.global.accessRefused = m.global.accessRefused + 1
+end sub
+
+sub noteRefusalIn(req as object)
+ if InStr(1, req.GetFailureReason(), "403") > 0 then noteAccessRefused()
+end sub
+
' APIRequest hands back invalid when there is no server to build a URL against,
' so callers can reach here before a server has been picked
function getJson(req)
@@ -128,12 +139,14 @@ function getJson(req)
data = req.GetToString()
if data = invalid or data = ""
+ noteRefusalIn(req)
diagnostics.EndTrace(started, "no body", diagnostics.LEVEL_WARN)
return invalid
end if
json = ParseJson(data)
if json = invalid
+ noteRefusalIn(req)
diagnostics.EndTrace(started, "unparseable", diagnostics.LEVEL_WARN)
return invalid
end if
@@ -666,6 +679,7 @@ function requestResponse(req as object, method = "GET" as string, body = "" as s
event = wait(100, port)
if type(event) = "roUrlEvent"
code = event.GetResponseCode()
+ if code = 403 then noteAccessRefused()
data = invalid
answer = event.GetString()
' A 204 has no body, and parsing nothing logs an error
diff --git a/source/utils/accessSchedule.bs b/source/utils/accessSchedule.bs
new file mode 100644
index 000000000..21aaea686
--- /dev/null
+++ b/source/utils/accessSchedule.bs
@@ -0,0 +1,126 @@
+' A user's access schedule as Jellyfin keeps it: the days an entry covers and the hours of those
+' days the user may use the server. Jellyfin applies it by the rules below, so the app does too,
+' to say so when the time is up instead of letting everything start failing.
+'
+' Times are given as a day of the week (0 is Sunday) and the second of that day, on this device's
+' clock. The server checks on its own clock, which can be in another time zone, so Status lets its
+' answer decide when there is one.
+
+namespace accessSchedule
+
+ const SECONDS_PER_DAY = 86400
+ const LOOK_AHEAD_DAYS = 8
+
+ ' The entries of a policy as plain { days, start, end } records, with the days as the list of
+ ' weekday numbers the entry covers and the hours as seconds into the day. Anything that isn't
+ ' an entry is left out, and so is a day name the server doesn't use.
+ function Read(policy as dynamic) as object
+ entries = []
+ if type(policy) <> "roAssociativeArray" then return entries
+ raw = policy.AccessSchedules
+ if type(raw) <> "roArray" then return entries
+ for each item in raw
+ if type(item) = "roAssociativeArray"
+ days = accessSchedule.DaysOf(item.DayOfWeek)
+ entries.push({
+ days: days,
+ start: accessSchedule.HourToSeconds(item.StartHour),
+ end: accessSchedule.HourToSeconds(item.EndHour)
+ })
+ end if
+ end for
+ return entries
+ end function
+
+ function HourToSeconds(hours as dynamic) as integer
+ if type(hours) <> "roInt" and type(hours) <> "roFloat" and type(hours) <> "roDouble" and type(hours) <> "Integer" and type(hours) <> "Float" and type(hours) <> "Double" then return 0
+ return Cint(hours * 3600)
+ end function
+
+ function DaysOf(name as dynamic) as object
+ if type(name) <> "roString" and type(name) <> "String" then return []
+ if name = "Everyday" then return [0, 1, 2, 3, 4, 5, 6]
+ if name = "Weekday" then return [1, 2, 3, 4, 5]
+ if name = "Weekend" then return [0, 6]
+ names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
+ for index = 0 to 6
+ if names[index] = name then return [index]
+ end for
+ return []
+ end function
+
+ function Covers(entry as object, day as integer) as boolean
+ for each covered in entry.days
+ if covered = day then return true
+ end for
+ return false
+ end function
+
+ ' Whether the user may use the server at that moment: an entry allows it when it covers the
+ ' day and the time of day is between its start and its end, both included. A user with no
+ ' entries is always allowed. Administrators are exempt, which is for the caller to check.
+ function IsAllowed(entries as object, day as integer, secondOfDay as integer) as boolean
+ if entries.Count() = 0 then return true
+ for each entry in entries
+ if accessSchedule.Covers(entry, day) and secondOfDay >= entry.start and secondOfDay <= entry.end then return true
+ end for
+ return false
+ end function
+
+ ' What the server's answer to /Users/Me says: true when it turns the user away, false when it
+ ' lets them in, and invalid when it gave no answer
+ function RefusedBy(code as integer) as dynamic
+ if code = 403 then return true
+ if code >= 200 and code < 300 then return false
+ return invalid
+ end function
+
+ ' Whether the user is turned away now and, while they are, when they're next let in. The
+ ' opening is worked out on this device's clock, so it's only named when that clock agrees.
+ function Status(entries as object, refused as dynamic, day as integer, secondOfDay as integer) as object
+ allowed = accessSchedule.IsAllowed(entries, day, secondOfDay)
+ if type(refused) = "roBoolean" or type(refused) = "Boolean" then allowed = not refused
+ if allowed then return { blocked: false, next: invalid }
+ return { blocked: true, next: accessSchedule.NextAccess(entries, day, secondOfDay) }
+ end function
+
+ ' When the user is next let in, as { seconds, dayOffset, secondOfDay, day }: how long from now,
+ ' how many days ahead that day is, and the time of day it opens. Invalid when they are allowed
+ ' now or no entry opens in the next week.
+ function NextAccess(entries as object, day as integer, secondOfDay as integer) as dynamic
+ if accessSchedule.IsAllowed(entries, day, secondOfDay) then return invalid
+ best = invalid
+ for offset = 0 to accessSchedule.LOOK_AHEAD_DAYS - 1
+ target = (day + offset) mod 7
+ for each entry in entries
+ if accessSchedule.Covers(entry, target)
+ seconds = offset * accessSchedule.SECONDS_PER_DAY + entry.start - secondOfDay
+ if seconds > 0 and (best = invalid or seconds < best.seconds)
+ best = { seconds: seconds, dayOffset: offset, secondOfDay: entry.start, day: target }
+ end if
+ end if
+ end for
+ end for
+ return best
+ end function
+
+ ' How long until the answer of IsAllowed could change, in seconds: every start, and the second
+ ' after every end, since the end itself is still allowed. Invalid when no entry has one in the
+ ' next week.
+ function SecondsToBoundary(entries as object, day as integer, secondOfDay as integer) as dynamic
+ best = invalid
+ for offset = 0 to accessSchedule.LOOK_AHEAD_DAYS - 1
+ target = (day + offset) mod 7
+ for each entry in entries
+ if accessSchedule.Covers(entry, target)
+ for each moment in [entry.start, entry.end + 1]
+ seconds = offset * accessSchedule.SECONDS_PER_DAY + moment - secondOfDay
+ if seconds > 0 and (best = invalid or seconds < best) then best = seconds
+ end for
+ end if
+ end for
+ end for
+ return best
+ end function
+
+end namespace
diff --git a/source/utils/globals.bs b/source/utils/globals.bs
index d348729fb..ad58d1b8e 100644
--- a/source/utils/globals.bs
+++ b/source/utils/globals.bs
@@ -44,6 +44,9 @@ sub setConstants()
' What the friends screens ask of those reads: one now, which chat is open, or whether message
' banners are wanted
globals.addField("socialRequest", "assocarray", true)
+ ' Counts the times the server turned a request away with a 403, which is what a user sees when
+ ' their access schedule closes while they are using the app
+ globals.addField("accessRefused", "integer", true)
end sub
' Save information from roAppInfo to m.global.app
diff --git a/test/access-schedule.bs b/test/access-schedule.bs
new file mode 100644
index 000000000..d3505ba7d
--- /dev/null
+++ b/test/access-schedule.bs
@@ -0,0 +1,90 @@
+sub testAccessSchedule()
+ HOUR = 3600
+ MON = 1
+ TUE = 2
+ FRI = 5
+ SAT = 6
+ SUN = 0
+ entry = function(name, startHour, endHour)
+ return { DayOfWeek: name, StartHour: startHour, EndHour: endHour }
+ end function
+ read = function(entries)
+ return accessSchedule.Read({ AccessSchedules: entries })
+ end function
+
+ window = read([entry("Monday", 8, 20)])
+ assertEqual(accessSchedule.IsAllowed([], MON, 3 * HOUR), true, "A user with no entries is always allowed")
+ assertEqual(accessSchedule.IsAllowed(window, MON, 12 * HOUR), true, "Allowed inside the hours")
+ assertEqual(accessSchedule.IsAllowed(window, MON, 8 * HOUR), true, "The start itself is allowed")
+ assertEqual(accessSchedule.IsAllowed(window, MON, 20 * HOUR), true, "The end itself is allowed")
+ assertEqual(accessSchedule.IsAllowed(window, MON, 8 * HOUR - 1), false, "Not before the start")
+ assertEqual(accessSchedule.IsAllowed(window, MON, 20 * HOUR + 1), false, "Not after the end")
+ assertEqual(accessSchedule.IsAllowed(window, TUE, 12 * HOUR), false, "Not on another day")
+
+ halves = read([entry("Monday", 8.5, 17.5)])
+ assertEqual(accessSchedule.IsAllowed(halves, MON, 8 * HOUR + 1799), false, "Half hours: before 8:30")
+ assertEqual(accessSchedule.IsAllowed(halves, MON, 8 * HOUR + 1800), true, "Half hours: at 8:30")
+ assertEqual(accessSchedule.IsAllowed(halves, MON, 17 * HOUR + 1800), true, "Half hours: at 17:30")
+ assertEqual(accessSchedule.IsAllowed(halves, MON, 17 * HOUR + 1801), false, "Half hours: after 17:30")
+
+ for each day in [MON, FRI, SAT, SUN]
+ assertEqual(accessSchedule.IsAllowed(read([entry("Everyday", 8, 20)]), day, 12 * HOUR), true, "Everyday covers every day")
+ end for
+ assertEqual(accessSchedule.IsAllowed(read([entry("Weekday", 8, 20)]), FRI, 12 * HOUR), true, "Weekday covers Friday")
+ assertEqual(accessSchedule.IsAllowed(read([entry("Weekday", 8, 20)]), SAT, 12 * HOUR), false, "Weekday leaves out Saturday")
+ assertEqual(accessSchedule.IsAllowed(read([entry("Weekend", 8, 20)]), SUN, 12 * HOUR), true, "Weekend covers Sunday")
+ assertEqual(accessSchedule.IsAllowed(read([entry("Weekend", 8, 20)]), MON, 12 * HOUR), false, "Weekend leaves out Monday")
+
+ two = read([entry("Monday", 8, 10), entry("Monday", 18, 20)])
+ assertEqual(accessSchedule.IsAllowed(two, MON, 9 * HOUR), true, "Any one entry is enough")
+ assertEqual(accessSchedule.IsAllowed(two, MON, 12 * HOUR), false, "Between two entries is closed")
+
+ toMidnight = read([entry("Monday", 18, 24)])
+ assertEqual(accessSchedule.IsAllowed(toMidnight, MON, 24 * HOUR - 1), true, "An end of 24 runs to the end of the day")
+ assertEqual(accessSchedule.IsAllowed(read([entry("Someday", 0, 24)]), MON, 12 * HOUR), false, "A day name the server doesn't use covers nothing")
+
+ assertEqual(accessSchedule.Read(invalid).Count(), 0, "No policy reads as no entries")
+ assertEqual(accessSchedule.Read({}).Count(), 0, "No schedules read as no entries")
+ assertEqual(accessSchedule.Read({ AccessSchedules: "nonsense" }).Count(), 0, "Nonsense reads as no entries")
+ assertEqual(accessSchedule.Read({ AccessSchedules: [invalid, 5, "x", entry("Monday", 1, 2)] }).Count(), 1, "What is not an entry is left out")
+
+ ' When the user is next let in
+ assertEqual(accessSchedule.NextAccess(window, MON, 12 * HOUR), invalid, "Nothing to wait for while allowed")
+ sameDay = accessSchedule.NextAccess(window, MON, 7 * HOUR)
+ assertEqual(sameDay.seconds, HOUR, "A later start the same day")
+ assertEqual(sameDay.dayOffset, 0, "Opens today")
+ assertEqual(sameDay.secondOfDay, 8 * HOUR, "Opens at 8:00")
+ nextDay = accessSchedule.NextAccess(read([entry("Monday", 8, 20), entry("Tuesday", 9, 20)]), MON, 21 * HOUR)
+ assertEqual(nextDay.dayOffset, 1, "The next day an entry covers")
+ assertEqual(nextDay.day, TUE, "Opens on Tuesday")
+ assertEqual(nextDay.seconds, 12 * HOUR, "From 21:00 to 9:00 the next day")
+ weekend = accessSchedule.NextAccess(read([entry("Weekday", 8, 20)]), FRI, 21 * HOUR)
+ assertEqual(weekend.dayOffset, 3, "The weekend is skipped")
+ assertEqual(weekend.day, MON, "Opens on Monday")
+ nextWeek = accessSchedule.NextAccess(window, MON, 21 * HOUR)
+ assertEqual(nextWeek.dayOffset, 7, "An entry that only covers today opens a week on")
+ earliest = accessSchedule.NextAccess(read([entry("Monday", 15, 20), entry("Monday", 9, 10)]), MON, 6 * HOUR)
+ assertEqual(earliest.secondOfDay, 9 * HOUR, "The earliest of several entries")
+ assertEqual(accessSchedule.NextAccess(read([entry("Someday", 8, 20)]), MON, 6 * HOUR), invalid, "No entry opens, so no time to name")
+
+ ' When the answer could change
+ assertEqual(accessSchedule.SecondsToBoundary(window, MON, 12 * HOUR), 8 * HOUR + 1, "The second after the end while allowed")
+ assertEqual(accessSchedule.SecondsToBoundary(read([entry("Monday", 8, 20), entry("Tuesday", 8, 20)]), MON, 21 * HOUR), 11 * HOUR, "The next start while closed")
+ assertEqual(accessSchedule.SecondsToBoundary([], MON, 12 * HOUR), invalid, "No entries have no boundary")
+
+ ' Whose answer decides
+ assertEqual(accessSchedule.RefusedBy(403), true, "A 403 from /Users/Me is the server turning the user away")
+ assertEqual(accessSchedule.RefusedBy(200), false, "An answer from /Users/Me lets the user in")
+ for each status in [0, 401, 500]
+ assertEqual(accessSchedule.RefusedBy(status), invalid, "Anything else is no answer")
+ end for
+ assertEqual(accessSchedule.Status(window, false, MON, 21 * HOUR).blocked, false, "The server letting the user in wins over this device's clock")
+ refusedHere = accessSchedule.Status(window, true, MON, 12 * HOUR)
+ assertEqual(refusedHere.blocked, true, "The server turning the user away wins over this device's clock")
+ assertEqual(refusedHere.next, invalid, "No opening is named while this device's clock says the hours are open")
+ assertEqual(accessSchedule.Status(window, true, MON, 7 * HOUR).next.secondOfDay, 8 * HOUR, "The opening is named when both clocks agree")
+ assertEqual(accessSchedule.Status(window, invalid, MON, 12 * HOUR).blocked, false, "Without an answer this device's clock decides, open")
+ noAnswer = accessSchedule.Status(window, invalid, MON, 21 * HOUR)
+ assertEqual(noAnswer.blocked, true, "Without an answer this device's clock decides, closed")
+ assertEqual(noAnswer.next.dayOffset, 7, "And names the next opening")
+end sub
diff --git a/test/review-regressions.bs b/test/review-regressions.bs
index 989183341..6ba1eec91 100644
--- a/test/review-regressions.bs
+++ b/test/review-regressions.bs
@@ -16,6 +16,9 @@ function testUrlTransfer() as object
end sub,
GetToString: function() as string
return m.fixtureBody
+ end function,
+ GetFailureReason: function() as string
+ return ""
end function
}
end function
diff --git a/test/server-compatibility.bs b/test/server-compatibility.bs
index 0d825dfa5..0e6ef3653 100644
--- a/test/server-compatibility.bs
+++ b/test/server-compatibility.bs
@@ -134,6 +134,7 @@ sub Main()
testAccentFolding()
testQuickConnectUrl()
testSignInRefused()
+ testAccessSchedule()
testLogRedaction()
testYoutubeTrailer()
testPlaybackFallback()
diff --git a/test/server-compatibility.mjs b/test/server-compatibility.mjs
index 236af5f91..c9bf9a4b9 100644
--- a/test/server-compatibility.mjs
+++ b/test/server-compatibility.mjs
@@ -76,6 +76,7 @@ const selections = {
'source/utils/homePreview.bs': null,
'source/utils/remoteSubtitles.bs': null,
'source/utils/quickConnectUrl.bs': null,
+ 'source/utils/accessSchedule.bs': null,
'components/extras/collectionLookup.bs': ['findParents', 'namedCollection', 'namedFirst', 'membership'],
'components/extras/LoadExtrasTask.bs': ['personRoleIn'],
'components/details/detailTrackHost.bs': ['SetUpVideoOptions', 'SetUpAudioOptions', 'detailServerSubtitleIndex', 'audioOrdinal', 'audioStreamPosition'],
@@ -106,7 +107,7 @@ const selections = {
'components/account/AccountDialog.bs': ['accountImageURL'],
'components/config/SigninScene.bs': ['checkQuickConnectEnabled'],
'source/utils/parsedUrl.bs': ['ParsedUrl', '__ParsedUrl_ToString'],
- 'source/api/baserequest.bs': ['buildParams', 'buildServerURL', 'buildURL', 'buildURLForServer', 'buildAuthHeader', 'buildAuthHeaderForServer', 'APIRequest', 'APIRequestForServer', 'authRequest', 'authRequestForServer', 'setCertificateAuthority', 'getJson', 'postPlaybackInfo', 'requestCanceled'],
+ 'source/api/baserequest.bs': ['buildParams', 'buildServerURL', 'buildURL', 'buildURLForServer', 'buildAuthHeader', 'buildAuthHeaderForServer', 'APIRequest', 'APIRequestForServer', 'authRequest', 'authRequestForServer', 'setCertificateAuthority', 'getJson', 'noteAccessRefused', 'noteRefusalIn', 'postPlaybackInfo', 'requestCanceled'],
};
let source = 'namespace bslib\n' + bslib.source + '\nend namespace\n' + await readFile('source/enums/String.bs', 'utf8');
for (const [file, names] of Object.entries(selections)) {
@@ -132,6 +133,7 @@ source += '\n' + await readFile('test/review-regressions.bs', 'utf8');
source += '\n' + await readFile('test/accent-folding.bs', 'utf8');
source += '\n' + await readFile('test/quick-connect-url.bs', 'utf8');
source += '\n' + await readFile('test/sign-in-refused.bs', 'utf8');
+source += '\n' + await readFile('test/access-schedule.bs', 'utf8');
source += '\n' + await readFile('test/log-redaction.bs', 'utf8');
source += '\n' + await readFile('test/youtube-trailer.bs', 'utf8');
source += '\n' + await readFile('test/playback-fallback.bs', 'utf8');
@@ -806,3 +808,13 @@ assert.equal(cornerPng.readUInt32BE(16), 8, 'The corner piece is 8 wide');
assert.equal(cornerPng.readUInt32BE(20), 8, 'The corner piece is 8 high');
assert.ok(cornerPng.length < 1024, 'The corner piece is small');
process.stdout.write('PASS: home preview wiring (28 checks)\n');
+
+// The server's answer to /Users/Me decides whether an access schedule has closed
+const scheduleTask = await readFile('components/accessSchedule/AccessScheduleTask.bs', 'utf8');
+assert.match(scheduleTask, /if not exempt and entries\.Count\(\) > 0 then refused = accessSchedule\.RefusedBy\(requestResponse\(APIRequest\("Users\/Me"\), "GET"\)\.status\)/, 'Only a user with a schedule who isnt an administrator asks the server');
+const scheduleWatcher = await readFile('components/accessSchedule/AccessScheduleWatcher.bs', 'utf8');
+assert.match(scheduleWatcher, /status = accessSchedule\.Status\(m\.entries, refused, day, secondOfDay\)/, 'The server answer decides whether the user is blocked');
+assert.match(scheduleWatcher, /sub onRefused\(\)(?:\s+'[^\n]*)*\s+if m\.top\.userId = "" or m\.top\.blocked or m\.task <> invalid then return/, 'A refused request starts no look while blocked or while one is under way');
+assert.match(scheduleWatcher, /if result\.ok[^]*?else(?:\s+'[^\n]*)*\s+startTimer\(RETRY_SECONDS\)/, 'A failed read keeps the last answer and asks again');
+assert.match(scheduleWatcher, /if m\.exempt then return\s+delay = POLL_SECONDS\s+if m\.entries\.Count\(\) = 0 then delay = IDLE_SECONDS/, 'An administrator isnt polled and a user without a schedule is polled every 15 minutes');
+process.stdout.write('PASS: access schedule wiring (5 checks)\n');