package com.ubergeek42.WeechatAndroid.relay; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.text.Spannable; import android.text.SpannableString; import android.text.Spanned; import android.text.style.RelativeSizeSpan; import android.text.style.SuperscriptSpan; import com.ubergeek42.WeechatAndroid.service.P; import com.ubergeek42.weechat.Color; import com.ubergeek42.weechat.relay.protocol.Hashtable; import com.ubergeek42.weechat.relay.protocol.RelayObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedList; public class Buffer { private static Logger logger = LoggerFactory.getLogger("Buffer"); final private static boolean DEBUG_BUFFER = false; final private static boolean DEBUG_LINE = false; final private static boolean DEBUG_NICK = false; final public static int PRIVATE = 2; final public static int CHANNEL = 1; final public static int OTHER = 0; final public static int HARD_HIDDEN = -1; public int maxLines = P.lineIncrement; private BufferEye bufferEye; private BufferNicklistEye bufferNickListEye; public final long pointer; public String fullName, shortName, title; public int number, notifyLevel; public Hashtable localVars; /** the following four variables are needed to determine if the buffer was changed and, ** if not, the last two are subtracted from the newly arrived hotlist data, to make up ** for the lines that was read in relay. ** lastReadLineServer stores id of the last read line *in weechat*. -1 means all lines unread. */ public long lastReadLineServer = -1; public boolean wantsFullHotListUpdate = false; // must be false for buffers without lastReadLineServer! public int totalReadUnreads = 0; public int totalReadHighlights = 0; // see BufferFragment.maybeMoveReadMarker() public long readMarkerLine = -1; public long lastVisibleLine = -1; private LinkedList<Line> lines = new LinkedList<>(); private int visibleLinesCount = 0; private LinkedList<Nick> nicks = new LinkedList<>(); public boolean isOpen = false; public boolean isWatched = false; public boolean holdsAllLines = false; public boolean holdsAllNicks = false; public int type = OTHER; public int unreads = 0; public int highlights = 0; public Spannable printableWithoutTitle = null; // printable buffer without title (for TextView) public Spannable printableWithTitle = null; // printable buffer with title Buffer(long pointer, int number, String fullName, String shortName, String title, int notifyLevel, Hashtable localVars) { this.pointer = pointer; this.number = number; this.fullName = fullName; this.shortName = (shortName != null) ? shortName : fullName; this.title = title; this.notifyLevel = notifyLevel; this.localVars = localVars; processBufferType(); processBufferTitle(); if (P.isBufferOpen(fullName)) setOpen(true); P.restoreLastReadLine(this); if (DEBUG_BUFFER) logger.debug("new Buffer(..., {}, {}, ...) isOpen? {}", number, fullName, isOpen); } public String hexPointer() { return String.format("0x%x", pointer); } //////////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////// LINES //////////////////////////////////////////////////////////////////////////////////////////////// stuff called by the UI //////////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////// /** get a copy of all 200 lines or of lines that are not filtered using weechat filters. ** better call off the main thread */ synchronized public @NonNull Line[] getLinesCopy() { Line[] l; if (!P.filterLines) l = lines.toArray(new Line[lines.size()]); else { l = new Line[visibleLinesCount]; int i = 0; for (Line line: lines) { if (line.visible) l[i++] = line; // if read marker is on a line that is invisible // move it to the previous visible line else if (line.pointer == readMarkerLine && i > 0) readMarkerLine = l[i-1].pointer; } } if (l.length > 0) lastVisibleLine = l[l.length-1].pointer; return l; } /** get a copy of last used nicknames ** to be used by tab completion thingie */ synchronized public @NonNull String[] getLastUsedNicksCopy() { String[] out = new String[nicks.size()]; int i = 0; for (Nick nick : nicks) out[i++] = nick.name; return out; } /** sets buffer as open or closed ** an open buffer is such that: ** has processed lines and processes lines as they come by ** is synced ** is marked as "open" in the buffer list fragment or wherever ** that's it, really ** can be called multiple times without harm ** somewhat heavy, better be called off the main thread */ synchronized public void setOpen(boolean open) { if (DEBUG_BUFFER) logger.debug("{} setOpen({})", shortName, open); if (this.isOpen == open) return; this.isOpen = open; if (open) { BufferList.syncBuffer(this); for (Line line : lines) line.processMessageIfNeeded(); } else { BufferList.desyncBuffer(this); for (Line line : lines) line.eraseProcessedMessage(); if (P.optimizeTraffic) { // if traffic is optimized, the next time we open the buffer, it might have been updated // this presents two problems. first, we will not be able to update if we think // that we have all the lines needed. second, if we have lines and request lines again, // it'd be cumbersome to find the place to put lines. like, for iteration #3, // [[old lines] [#3] [#2] [#1]] unless #3 is inside old lines. hence, reset everything! holdsAllLines = holdsAllNicks = false; lines.clear(); nicks.clear(); visibleLinesCount = 0; maxLines = P.lineIncrement; } } BufferList.notifyBuffersSlightlyChanged(); } /** set buffer eye, i.e. something that watches buffer events ** also requests all lines and nicknames, if needed (usually only done once per buffer) ** we are requesting it here and not in setOpen() because: ** when the process gets killed and restored, we want to receive messages, including ** notifications, for that buffer. BUT the user might not visit that buffer at all. ** so we request lines and nicks upon user actually (getting close to) opening the buffer. ** we are requesting nicks along with the lines because: ** nick completion */ synchronized public void setBufferEye(@Nullable BufferEye bufferEye) { if (DEBUG_BUFFER) logger.debug("{} setBufferEye({})", shortName, bufferEye); this.bufferEye = bufferEye; if (bufferEye != null) { if (!holdsAllLines) BufferList.requestLinesForBufferByPointer(pointer, maxLines); if (!holdsAllNicks) BufferList.requestNicklistForBufferByPointer(pointer); } } synchronized public void requestMoreLines() { holdsAllLines = false; maxLines += P.lineIncrement; BufferList.requestLinesForBufferByPointer(pointer, maxLines); } public enum LINES {FETCHING, CAN_FETCH_MORE, EVERYTHING_FETCHED} synchronized public LINES getLineStatus() { if (!holdsAllLines) return LINES.FETCHING; return maxLines == lines.size() ? LINES.CAN_FETCH_MORE : LINES.EVERYTHING_FETCHED; } /** tells Buffer if it is ACTIVELY display on screen ** affects the way buffer advertises highlights/unreads count and notifications ** can be called multiple times without harm */ synchronized public void setWatched(boolean watched) { if (DEBUG_BUFFER) logger.debug("{} setWatched({})", shortName, watched); if (isWatched == watched) return; isWatched = watched; if (watched) resetUnreadsAndHighlights(); } /** called when options has changed and the messages should be processed */ synchronized public void forceProcessAllMessages() { if (DEBUG_BUFFER) logger.debug("{} forceProcessAllMessages()", shortName); for (Line line : lines) line.processMessage(); } //////////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////// stuff called by message handlers //////////////////////////////////////////////////////////////////////////////////////////////// synchronized public void addLine(final Line line, final boolean isLast) { if (DEBUG_LINE) logger.debug("{} addLine('{}', {})", shortName, line.message, isLast); // check if the line in question is already in the buffer // happens when reverse request throws in lines even though some are already here for (Line l: lines) if (l.pointer == line.pointer) return; // remove a line if we are over the limit and add the new line // correct visibleLinesCount accordingly if (lines.size() >= maxLines) if (lines.removeFirst().visible) visibleLinesCount--; if (isLast) lines.add(line); else lines.addFirst(line); if (line.visible) visibleLinesCount++; // calculate spannable, if needed if (isOpen) line.processMessage(); // notify levels: 0 none 1 highlight 2 message 3 all // treat hidden lines and lines that are not supposed to generate a “notification” as read if (isLast) { if (isWatched || type == HARD_HIDDEN || (P.filterLines && !line.visible) || (notifyLevel == 0) || (notifyLevel == 1 && !line.highlighted)) { if (line.highlighted) totalReadHighlights++; else if (line.type == Line.LINE_MESSAGE) totalReadUnreads++; } else { if (line.highlighted) { highlights++; BufferList.newHotLine(this, line); BufferList.notifyBuffersSlightlyChanged(type == OTHER); } else if (line.type == Line.LINE_MESSAGE) { unreads++; if (type == PRIVATE) BufferList.newHotLine(this, line); BufferList.notifyBuffersSlightlyChanged(type == OTHER); } } } // notify our listener if (isLast) onLinesChanged(); // if current line's an event line and we've got a speaker, move nick to fist position // nick in question is supposed to be in the nicks already, for we only shuffle these // nicks when someone spoke, i.e. NOT when user joins. if (holdsAllNicks && isLast) { String name = line.speakingNick; if (name != null) for (Iterator<Nick> it = nicks.iterator(); it.hasNext(); ) { Nick nick = it.next(); if (name.equals(nick.name)) { it.remove(); nicks.addFirst(nick); break; } } } if (lines.size() >= maxLines) holdsAllLines = true; } /** a buffer NOT will want a complete update if the last line unread stored in weechat buffer ** matches the one stored in our buffer. if they are not equal, the user must've read the buffer ** in weechat. assuming he read the very last line, total old highlights and unreads bear no meaning, ** so they should be erased. */ synchronized public void updateLastReadLine(long linePointer) { wantsFullHotListUpdate = lastReadLineServer != linePointer; if (wantsFullHotListUpdate) { lastReadLineServer = linePointer; readMarkerLine = linePointer; totalReadHighlights = totalReadUnreads = 0; } } /** buffer will want full updates if it doesn't have a last read line ** that can happen if the last read line is so far in the queue it got erased (past 4096 lines or so) ** in most cases, that is OK with us, but in rare cases when the buffer was READ in weechat BUT ** has lost its last read lines again our read count will not have any meaning. AND it might happen ** that our number is actually HIGHER than amount of unread lines in the buffer. as a workaround, ** we check that we are not getting negative numbers. not perfect, but—! */ synchronized public void updateHighlightsAndUnreads(int highlights, int unreads) { if (isWatched) { totalReadUnreads = unreads; totalReadHighlights = highlights; } else { final boolean full_update = wantsFullHotListUpdate || (totalReadUnreads > unreads) || (totalReadHighlights > highlights); if (full_update) { this.unreads = unreads; this.highlights = highlights; totalReadUnreads = totalReadHighlights = 0; } else { this.unreads = unreads - totalReadUnreads; this.highlights = highlights - totalReadHighlights; } int hots = this.highlights; if (type == PRIVATE) hots += this.unreads; BufferList.adjustHotMessagesForBuffer(this, hots); } } synchronized public void onLinesChanged() { if (bufferEye != null) bufferEye.onLinesChanged(); } synchronized public void onLinesListed() { holdsAllLines = true; if (bufferEye != null) bufferEye.onLinesListed(); } synchronized public void onPropertiesChanged() { processBufferType(); processBufferTitle(); if (bufferEye != null) bufferEye.onPropertiesChanged(); } synchronized public void onBufferClosed() { if (DEBUG_BUFFER) logger.debug("{} onBufferClosed()", shortName); BufferList.removeHotMessagesForBuffer(this); setOpen(false); if (bufferEye != null) bufferEye.onBufferClosed(); } //////////////////////////////////////////////////////////////////////////////////////////////// private stuffs /** determine if the buffer is PRIVATE, CHANNEL, OTHER or HARD_HIDDEN ** hard-hidden channels do not show in any way. to hide a channel, ** do "/buffer set localvar_set_relay hard-hide" */ private void processBufferType() { RelayObject t; t = localVars.get("relay"); if (t != null && Arrays.asList(t.asString().split(",")).contains("hard-hide")) type = HARD_HIDDEN; else { t = localVars.get("type"); if (t == null) type = OTHER; else if ("private".equals(t.asString())) type = PRIVATE; else if ("channel".equals(t.asString())) type = CHANNEL; else type = OTHER; } } private final static SuperscriptSpan SUPER = new SuperscriptSpan(); private final static RelativeSizeSpan SMALL1 = new RelativeSizeSpan(0.6f); private final static RelativeSizeSpan SMALL2 = new RelativeSizeSpan(0.6f); private final static int EX = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE; private void processBufferTitle() { Spannable spannable; final String number = Integer.toString(this.number) + " "; spannable = new SpannableString(number + shortName); spannable.setSpan(SUPER, 0, number.length(), EX); spannable.setSpan(SMALL1, 0, number.length(), EX); printableWithoutTitle = spannable; if (title == null || title.equals("")) { printableWithTitle = printableWithoutTitle; } else { spannable = new SpannableString(number + shortName + "\n" + Color.stripEverything(title)); spannable.setSpan(SUPER, 0, number.length(), EX); spannable.setSpan(SMALL1, 0, number.length(), EX); spannable.setSpan(SMALL2, number.length() + shortName.length() + 1, spannable.length(), EX); printableWithTitle = spannable; } } /** sets highlights/unreads to 0 and, ** if something has actually changed, notifies whoever cares about it */ synchronized public void resetUnreadsAndHighlights() { if (DEBUG_BUFFER) logger.debug("{} resetUnreadsAndHighlights()", shortName); if ((unreads | highlights) == 0) return; totalReadUnreads += unreads; totalReadHighlights += highlights; unreads = highlights = 0; BufferList.removeHotMessagesForBuffer(this); BufferList.notifyBuffersSlightlyChanged(type == OTHER); } //////////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////// NICKS //////////////////////////////////////////////////////////////////////////////////////////////// stuff called by the UI //////////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////// /** sets and removes a single nicklist watcher ** used to notify of nicklist changes as new nicks arrive and others quit */ synchronized public void setBufferNicklistEye(@Nullable BufferNicklistEye bufferNickListEye) { if (DEBUG_NICK) logger.debug("{} setBufferNicklistEye({})", shortName, bufferNickListEye); this.bufferNickListEye = bufferNickListEye; } synchronized public @NonNull Nick[] getNicksCopy() { Nick[] n = nicks.toArray(new Nick[nicks.size()]); Arrays.sort(n, sortByNumberPrefixAndNameComparator); return n; } //////////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////// called by event handlers //////////////////////////////////////////////////////////////////////////////////////////////// synchronized public void addNick(long pointer, String prefix, String name, boolean away) { if (DEBUG_NICK) logger.debug("{} addNick({}, {}, {}, {})", shortName, pointer, prefix, name, away); nicks.add(new Nick(pointer, prefix, name, away)); notifyNicklistChanged(); } synchronized public void removeNick(long pointer) { if (DEBUG_NICK) logger.debug("{} removeNick({})", new Object[]{shortName, pointer}); for (Iterator<Nick> it = nicks.iterator(); it.hasNext();) { if (it.next().pointer == pointer) { it.remove(); break; } } notifyNicklistChanged(); } synchronized public void updateNick(long pointer, String prefix, String name, boolean away) { if (DEBUG_NICK) logger.debug("{} updateNick({}, {}, {}, {})", shortName, pointer, prefix, name, away); for (Nick nick: nicks) { if (nick.pointer == pointer) { nick.prefix = prefix; nick.name = name; nick.away = away; break; } } notifyNicklistChanged(); } synchronized public void removeAllNicks() { nicks.clear(); } synchronized public void sortNicksByLines() { if (DEBUG_NICK) logger.debug("{} sortNicksByLines({})", shortName); final HashMap<String, Integer> nameToPosition = new HashMap<>(); for (int i = lines.size() - 1; i >= 0; i--) { String name = lines.get(i).speakingNick; if (name != null && !nameToPosition.containsKey(name)) nameToPosition.put(name, nameToPosition.size()); } Collections.sort(nicks, new Comparator<Nick>() { @Override public int compare(Nick left, Nick right) { Integer l = nameToPosition.get(left.name); Integer r = nameToPosition.get(right.name); if (l == null) l = Integer.MAX_VALUE; if (r == null) r = Integer.MAX_VALUE; return l - r; } }); } //////////////////////////////////////////////////////////////////////////////////////////////// private stuffs synchronized private void notifyNicklistChanged() { if (bufferNickListEye != null) bufferNickListEye.onNicklistChanged(); } /** * this comparator sorts by prefix first */ private final static Comparator<Nick> sortByNumberPrefixAndNameComparator = new Comparator<Nick>() { // Lower values = higher priority private int prioritizePrefix(String p) { if (p.length() == 0) return 100; char c = p.charAt(0); switch(c) { case '~': return 1; // Owners case '&': return 2; // Admins case '@': return 3; // Ops case '%': return 4; // Half-Ops case '+': return 5; // Voiced } return 100; // Other } @Override public int compare(Nick n1, Nick n2) { int p1 = prioritizePrefix(n1.prefix); int p2 = prioritizePrefix(n2.prefix); int diff = p1 - p2; return (diff != 0) ? diff : n1.name.compareToIgnoreCase(n2.name); } }; }