This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

superconfig / tools / hermes-voice / android / src / us / lassul / assist / Conversation.java
10 kB 270 lines
1package us.lassul.assist; 2 3import android.content.Context; 4import android.content.SharedPreferences; 5import android.graphics.Color; 6import android.graphics.Typeface; 7import android.text.SpannableStringBuilder; 8import android.text.Spanned; 9import android.text.style.ForegroundColorSpan; 10import android.text.style.StyleSpan; 11import android.util.AtomicFile; 12import android.util.Log; 13import java.io.File; 14import java.io.FileOutputStream; 15import java.io.IOException; 16import java.nio.charset.StandardCharsets; 17import java.security.SecureRandom; 18import java.text.SimpleDateFormat; 19import java.util.ArrayList; 20import java.util.Arrays; 21import java.util.Collections; 22import java.util.Comparator; 23import java.util.Date; 24import java.util.List; 25import java.util.Locale; 26import java.util.TimeZone; 27import org.json.JSONArray; 28import org.json.JSONException; 29import org.json.JSONObject; 30 31/** 32 * A conversation with Hermes across calls, like the web page's localStorage state: the 33 * transcript, how much of it went to Hermes already, and Hermes's session id. Every conversation 34 * is a file in conversations/; the current one continues with the next call unless the last call 35 * is over an hour old or "New conversation" was chosen. 36 */ 37final class Conversation { 38 static final int ACCENT = Color.parseColor("#4fb477"); 39 static final int LIVE = Color.parseColor("#d9534f"); 40 static final int DIM = Color.parseColor("#8a8a8a"); 41 private static final long NEW_AFTER_MS = 60 * 60 * 1000; 42 private static final String PREFS = "conversation"; 43 44 static final class Line { 45 final String speaker; 46 String text; 47 48 Line(String speaker, String text) { 49 this.speaker = speaker; 50 this.text = text; 51 } 52 } 53 54 final String hermesSession; 55 final long started; 56 final List<Line> lines = new ArrayList<>(); 57 // Lines before this index went to Hermes (delegation or hang-up) already. 58 int delegated; 59 double billedSeconds; 60 private final Context context; 61 62 private Conversation(Context context, String hermesSession, long started) { 63 this.context = context.getApplicationContext(); 64 this.hermesSession = hermesSession; 65 this.started = started; 66 } 67 68 /** The conversation the next call continues. */ 69 static Conversation load(Context context) { 70 SharedPreferences prefs = prefs(context); 71 String current = prefs.getString("current", null); 72 if (current != null 73 && System.currentTimeMillis() - prefs.getLong("last_used", 0) < NEW_AFTER_MS) { 74 Conversation saved = read(context, current); 75 if (saved != null) { 76 return saved; 77 } 78 } 79 return new Conversation(context, newSessionId(), System.currentTimeMillis()); 80 } 81 82 /** The conversation shown on the main screen: the current one, however old; null if none. */ 83 static Conversation current(Context context) { 84 String current = prefs(context).getString("current", null); 85 return current == null ? null : read(context, current); 86 } 87 88 /** The next call starts a new conversation (and Hermes session); the old one stays listed. */ 89 static void startNew(Context context) { 90 prefs(context).edit().remove("current").apply(); 91 Live.changed(); 92 } 93 94 /** All conversations, newest first. */ 95 static List<Conversation> all(Context context) { 96 File[] files = directory(context).listFiles(); 97 List<Conversation> out = new ArrayList<>(); 98 for (File file : files == null ? new File[0] : files) { 99 String name = file.getName(); 100 if (name.endsWith(".json")) { 101 Conversation conversation = 102 read(context, name.substring(0, name.length() - ".json".length())); 103 if (conversation != null) { 104 out.add(conversation); 105 } 106 } 107 } 108 Collections.sort(out, new Comparator<Conversation>() { 109 @Override 110 public int compare(Conversation a, Conversation b) { 111 return Long.compare(b.started, a.started); 112 } 113 }); 114 return out; 115 } 116 117 static Conversation read(Context context, String hermesSession) { 118 File file = file(context, hermesSession); 119 if (!file.exists()) { 120 return null; 121 } 122 try { 123 JSONObject state = new JSONObject( 124 new String(new AtomicFile(file).readFully(), StandardCharsets.UTF_8)); 125 Conversation conversation = new Conversation(context, hermesSession, 126 state.optLong("started", file.lastModified())); 127 JSONArray lines = state.getJSONArray("lines"); 128 for (int i = 0; i < lines.length(); i++) { 129 JSONObject line = lines.getJSONObject(i); 130 conversation.lines.add(new Line(line.getString("speaker"), line.getString("text"))); 131 } 132 conversation.delegated = state.optInt("delegated", conversation.lines.size()); 133 conversation.billedSeconds = state.optDouble("billed_seconds", 0); 134 return conversation; 135 } catch (IOException | JSONException error) { 136 Log.w(CallService.TAG, "unreadable conversation " + hermesSession, error); 137 return null; 138 } 139 } 140 141 /** voice-YYYYMMDD-<hex>, like the web page's. */ 142 private static String newSessionId() { 143 SimpleDateFormat day = new SimpleDateFormat("yyyyMMdd", Locale.ROOT); 144 day.setTimeZone(TimeZone.getTimeZone("UTC")); 145 String random = Integer.toHexString(new SecureRandom().nextInt()); 146 return "voice-" + day.format(new Date()) + "-" + random; 147 } 148 149 void save() { 150 AtomicFile file = new AtomicFile(file(context, hermesSession)); 151 FileOutputStream out = null; 152 try { 153 JSONObject state = new JSONObject() 154 .put("started", started) 155 .put("lines", toJson(lines)) 156 .put("delegated", delegated) 157 .put("billed_seconds", billedSeconds); 158 directory(context).mkdirs(); 159 out = file.startWrite(); 160 out.write(state.toString().getBytes(StandardCharsets.UTF_8)); 161 file.finishWrite(out); 162 } catch (IOException | JSONException error) { 163 file.failWrite(out); 164 Log.w(CallService.TAG, "conversation not saved", error); 165 return; 166 } 167 prefs(context).edit() 168 .putString("current", hermesSession) 169 .putLong("last_used", System.currentTimeMillis()) 170 .apply(); 171 Live.changed(); 172 } 173 174 void add(String speaker, String text) { 175 lines.add(new Line(speaker, text)); 176 save(); 177 } 178 179 /** Lines since the last hand-over to Hermes, of the given speakers. */ 180 List<Line> undelegated(String... speakers) { 181 List<Line> out = new ArrayList<>(); 182 List<String> wanted = Arrays.asList(speakers); 183 for (Line line : lines.subList(Math.min(delegated, lines.size()), lines.size())) { 184 if (wanted.contains(line.speaker)) { 185 out.add(line); 186 } 187 } 188 return out; 189 } 190 191 /** The first thing the user said, for the session list. */ 192 String title() { 193 for (Line line : lines) { 194 if (line.speaker.equals("user") && !line.text.trim().isEmpty()) { 195 return line.text.trim(); 196 } 197 } 198 return "(nothing said)"; 199 } 200 201 /** The transcript as the web page shows it: colored speaker labels, Hermes dimmed. */ 202 CharSequence render() { 203 SpannableStringBuilder out = new SpannableStringBuilder(); 204 for (Line line : lines) { 205 String text = line.text.trim(); 206 if (text.isEmpty()) { 207 continue; 208 } 209 if (out.length() > 0) { 210 out.append("\n\n"); 211 } 212 String label; 213 int labelColor; 214 int textColor = 0; 215 switch (line.speaker) { 216 case "user": 217 label = "you: "; 218 labelColor = ACCENT; 219 break; 220 case "assistant": 221 label = "voice: "; 222 labelColor = DIM; 223 break; 224 case "error": 225 label = "error: "; 226 labelColor = LIVE; 227 textColor = LIVE; 228 break; 229 default: 230 label = line.speaker + ": "; 231 labelColor = ACCENT; 232 textColor = DIM; 233 break; 234 } 235 int start = out.length(); 236 out.append(label); 237 out.setSpan(new ForegroundColorSpan(labelColor), start, out.length(), 238 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 239 out.setSpan(new StyleSpan(Typeface.BOLD), start, out.length(), 240 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 241 int body = out.length(); 242 out.append(text); 243 if (textColor != 0) { 244 out.setSpan(new ForegroundColorSpan(textColor), body, out.length(), 245 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 246 } 247 } 248 return out; 249 } 250 251 static JSONArray toJson(List<Line> lines) throws JSONException { 252 JSONArray out = new JSONArray(); 253 for (Line line : lines) { 254 out.put(new JSONObject().put("speaker", line.speaker).put("text", line.text)); 255 } 256 return out; 257 } 258 259 private static SharedPreferences prefs(Context context) { 260 return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); 261 } 262 263 private static File directory(Context context) { 264 return new File(context.getFilesDir(), "conversations"); 265 } 266 267 private static File file(Context context, String hermesSession) { 268 return new File(directory(context), hermesSession + ".json"); 269 } 270}