/* * Overchan Android (Meta Imageboard Client) * Copyright (C) 2014-2016 miku-nyan <https://github.com/miku-nyan> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package nya.miku.wishmaster.ui.presentation; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.TimeZone; import org.apache.commons.lang3.tuple.Triple; import nya.miku.wishmaster.api.interfaces.CancellableTask; import nya.miku.wishmaster.api.models.AttachmentModel; import nya.miku.wishmaster.api.models.PostModel; import nya.miku.wishmaster.api.models.ThreadModel; import nya.miku.wishmaster.api.models.UrlPageModel; import nya.miku.wishmaster.api.util.ChanModels; import nya.miku.wishmaster.cache.SerializablePage; import nya.miku.wishmaster.common.Logger; import nya.miku.wishmaster.common.MainApplication; import nya.miku.wishmaster.lib.org_json.JSONArray; import nya.miku.wishmaster.ui.Database; import nya.miku.wishmaster.ui.Database.IsHiddenDelegate; import nya.miku.wishmaster.ui.presentation.ClickableURLSpan.URLSpanClickListener; import nya.miku.wishmaster.ui.presentation.FlowTextHelper.FloatingModel; import nya.miku.wishmaster.ui.presentation.HtmlParser.ImageGetter; import nya.miku.wishmaster.ui.settings.AutohideActivity; import nya.miku.wishmaster.ui.theme.ThemeUtils; import android.content.res.Resources.Theme; /** * Объект - готовая к показу (без ресурсоёмких вычислений) модель страницы.<br> * Список готовых к показу объектов {@link PresentationItemModel} - {@link #presentationList} передаётся адаптеру для отображения постов. * @author miku-nyan * */ public class PresentationModel { private static final String TAG = "PresentationModel"; /** Исходная страница - объект {@link SerializablePage} */ public final SerializablePage source; /** обработчик ссылок URL внутри постов, ссылок на другие посты, а также ссылок на e-mail (mailto) */ public final URLSpanClickListener spanClickListener; /** загрузчик картинок, содержащихся в тексте постов (например, смайлики) */ public final ImageGetter imageGetter; private final Theme theme; private FloatingModel[] floatingModels; private final DateFormat dateFormat; private final boolean reduceNames; private final IsHiddenDelegate isHiddenDelegate; private final List<AutohideActivity.CompiledAutohideRule> autohideRules; /** * Примерный размер данного объекта в памяти в байтах. * Неизменяемый параметр, измеряется один раз - при создании объекта. * После обновления страницы, перед помещением в LRU кэш необходимь создать новый объект, * с помощью конструктора {@link #PresentationModel(PresentationModel)} */ public final int size; /** * Список готовых к показу объектов для загрузки в адаптер. * Необходимо обновить (построить) данный список методом {@link #updateViewModels()} перед использованием, * или при обновлении содержания (постов) в {@link #source} */ public List<PresentationItemModel> presentationList = null; private ArrayList<Triple<AttachmentModel, String, String>> attachments = null; private Object lock = new Object(); private HashMap<String, Integer> postNumbersMap = new HashMap<String, Integer>(); private volatile boolean notReady; /** * Конструктор * @param source исходная страница - объект {@link SerializablePage} * @param localTime определяет, будет ли использоваться локальное время телефона (если true), или часовой пояс имиджборды (если false) * @param reduceNames если true, имена пользователя по умолчанию (напр. Аноним) не будут показываться * @param spanClickListener обработчик ссылок URL внутри постов, ссылок на другие посты, а также ссылок на e-mail (mailto) * @param imageGetter загрузчик картинок, содержащихся в тексте постов (например, смайлики) * @param theme текущая тема * @param floatingModels массив из двух моделей обтекания картинка текстом. Первый элемент - обычная * превьюшка, второй - со строкой с дополнительной информацией о вложении (gif, видео, аудио). * Допустимо значение null, если обтекание не нужно вообще. */ public PresentationModel(SerializablePage source, boolean localTime, boolean reduceNames, URLSpanClickListener spanClickListener, ImageGetter imageGetter, Theme theme, FloatingModel[] floatingModels) { if (source.pageModel.type == UrlPageModel.TYPE_OTHERPAGE) throw new IllegalArgumentException(); this.source = source; this.spanClickListener = spanClickListener; this.imageGetter = imageGetter; this.theme = theme; this.floatingModels = floatingModels; this.reduceNames = reduceNames; Database database = MainApplication.getInstance().database; this.isHiddenDelegate = source.pageModel.type == UrlPageModel.TYPE_THREADPAGE ? database.getCachedIsHiddenDelegate(source.pageModel.chanName, source.pageModel.boardName, source.pageModel.threadNumber) : database.getDefaultIsHiddenDelegate(); this.autohideRules = new ArrayList<AutohideActivity.CompiledAutohideRule>(); try { JSONArray autohideJson = new JSONArray(MainApplication.getInstance().settings.getAutohideRulesJson()); for (int i=0; i<autohideJson.length(); ++i) { AutohideActivity.AutohideRule rule = AutohideActivity.AutohideRule.fromJson(autohideJson.getJSONObject(i)); if (rule.matches(source.pageModel.chanName, source.pageModel.boardName, source.pageModel.threadNumber)) { this.autohideRules.add(new AutohideActivity.CompiledAutohideRule(rule)); } } } catch (Exception e) { Logger.e(TAG, "error while processing regex autohide rules", e); } AndroidDateFormat.initPattern(); String datePattern = AndroidDateFormat.getPattern(); DateFormat dateFormat = datePattern == null ? DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) : new SimpleDateFormat(datePattern, Locale.US); dateFormat.setTimeZone(localTime ? TimeZone.getDefault() : TimeZone.getTimeZone(source.boardModel.timeZoneId)); this.dateFormat = dateFormat; this.size = getSerializablePageSize(source); } /** * Конструктор создаёт такой же объект (с теми же ссылками), с обновлённым значением {@link #size} * @param model исходный объект */ public PresentationModel(PresentationModel model) { source = model.source; spanClickListener = model.spanClickListener; imageGetter = model.imageGetter; theme = model.theme; floatingModels = model.floatingModels; dateFormat = model.dateFormat; reduceNames = model.reduceNames; isHiddenDelegate = model.isHiddenDelegate; autohideRules = model.autohideRules; size = getSerializablePageSize(source); presentationList = model.presentationList; attachments = model.attachments; postNumbersMap = model.postNumbersMap; notReady = model.notReady; } public interface RebuildCallback { void onRebuild(); } /** * Определение примерного размера данной страницы в памяти в байтах */ private static int getSerializablePageSize(SerializablePage page) { int size = 32, noPresentationSize = 0; if (page.posts != null) { size += (12 + (page.posts.length * 4)); for (PostModel post : page.posts) size += ChanModels.getPostModelSize(post); } if (page.threads != null) { size += (12 + (page.threads.length * 4)); for (ThreadModel threadModel : page.threads) { size += (32 + (threadModel.threadNumber == null ? 0 : (40 + (threadModel.threadNumber.length() * 2)))); size += (12 + (threadModel.posts.length * 4)); if (threadModel.posts.length > 0) size += ChanModels.getPostModelSize(threadModel.posts[0]); for (int i=1; i<threadModel.posts.length; ++i) noPresentationSize += ChanModels.getPostModelSize(threadModel.posts[i]); } } return size * 3 + noPresentationSize; } /** * Обновляет (или перестраивает) презентационные модели * @param showIndex добавлять индекс (номер по порядку) в заголовок элемента * @param task отменяемая задача * @param rebuildCallback интерфейс (делегат) вызвающий метод в случае, если список перестраивается */ public synchronized void updateViewModels(boolean showIndex, CancellableTask task, RebuildCallback rebuildCallback) { PostModel[] posts = source.posts; if (posts == null) { posts = new PostModel[source.threads.length]; for (int i=0; i<source.threads.length; ++i) { posts[i] = source.threads[i].posts[0]; } } try { updateViewModels(posts, showIndex, task, rebuildCallback); } catch (OutOfMemoryError oom) { MainApplication.freeMemory(); Logger.e(TAG, oom); if (task != null && task.isCancelled()) return; try { updateViewModels(posts, showIndex, task, rebuildCallback); } catch (OutOfMemoryError oom1) { MainApplication.freeMemory(); Logger.e(TAG, oom1); } } } /** * Установить новые модели обтекания картинки текстом (и перестроить текст комментария в случае необходимости) * @param floatingModels массив из двух моделей обтекания картинки текстом */ public void setFloatingModels(FloatingModel[] models) { if (!FlowTextHelper.IS_AVAILABLE) return; if (models == null || models.length != 2 || models[0] == null || models[1] == null) return; if (floatingModels == null || floatingModels.length != 2 || floatingModels[0] == null || floatingModels[1] == null) return; if (models[0].equals(floatingModels[0]) && models[1].equals(floatingModels[1])) return; try { this.floatingModels = models; int size = presentationList.size(); for (int i=0; i<size; ++i) presentationList.get(i).changeFloatingModels(models); } catch (Exception e) { Logger.e(TAG, e); } } private synchronized void updateViewModels(PostModel[] posts, boolean showIndex, CancellableTask task, RebuildCallback rebuildCallback) { if (task == null) task = CancellableTask.NOT_CANCELLABLE; synchronized (lock) { notReady = true; } if (presentationList == null) { presentationList = new ArrayList<PresentationItemModel>(posts.length); // нужен ли синхронизированный лист(?) attachments = new ArrayList<Triple<AttachmentModel, String, String>>(); } if (task.isCancelled()) return; String[] subscriptions = null; if (source.pageModel.type == UrlPageModel.TYPE_THREADPAGE && MainApplication.getInstance().settings.highlightSubscriptions()) { subscriptions = MainApplication.getInstance().subscriptions.getSubscriptions( source.pageModel.chanName, source.pageModel.boardName, source.pageModel.threadNumber); } boolean headersRebuilding = false; int indexCounter = 0; boolean rebuild = false; if (posts.length < presentationList.size()) { rebuild = true; Logger.d(TAG, "rebuild: new list is shorter"); } else { for (int i=0, size=presentationList.size(); i<size; ++i) { if (!presentationList.get(i).sourceModel.number.equals(posts[i].number) || ChanModels.hashPostModel(posts[i]) != presentationList.get(i).sourceModelHash) { rebuild = true; Logger.d(TAG, "rebuild: changed item "+i); break; } if (showIndex) { if (!posts[i].deleted) ++indexCounter; if (headersRebuilding |= presentationList.get(i).isDeleted != posts[i].deleted) { presentationList.get(i).buildSpannedHeader(!posts[i].deleted ? indexCounter : -1, source.boardModel.bumpLimit, reduceNames ? source.boardModel.defaultUserName : null, source.pageModel.type == UrlPageModel.TYPE_SEARCHPAGE ? posts[i].parentThread : null, subscriptions != null ? Arrays.binarySearch(subscriptions, posts[i].number) >= 0 : false); } } presentationList.get(i).isDeleted = posts[i].deleted; } } if (task.isCancelled()) return; if (rebuild) { if (rebuildCallback != null) rebuildCallback.onRebuild(); presentationList.clear(); postNumbersMap.clear(); attachments.clear(); indexCounter = 0; } final boolean openSpoilers = MainApplication.getInstance().settings.openSpoilers(); for (int i=presentationList.size(); i<posts.length; ++i) { if (task.isCancelled()) return; PresentationItemModel model = new PresentationItemModel( posts[i], source.pageModel.chanName, source.pageModel.boardName, source.pageModel.type == UrlPageModel.TYPE_THREADPAGE ? source.pageModel.threadNumber : null, dateFormat, spanClickListener, imageGetter, ThemeUtils.ThemeColors.getInstance(theme), openSpoilers, floatingModels, subscriptions); postNumbersMap.put(posts[i].number, i); if (source.pageModel.type == UrlPageModel.TYPE_THREADPAGE) { for (String ref : model.referencesTo) { Integer postPosition = postNumbersMap.get(ref); if (postPosition != null && postPosition < presentationList.size()) { presentationList.get(postPosition).addReferenceFrom(model.sourceModel.number); } } } presentationList.add(model); for (int j=0; j<model.attachmentHashes.length; ++j) { attachments.add(Triple.of(posts[i].attachments[j], model.attachmentHashes[j], posts[i].number)); } model.buildSpannedHeader(showIndex && !posts[i].deleted ? ++indexCounter : -1, source.boardModel.bumpLimit, reduceNames ? source.boardModel.defaultUserName : null, source.pageModel.type == UrlPageModel.TYPE_SEARCHPAGE ? posts[i].parentThread : null, subscriptions != null ? Arrays.binarySearch(subscriptions, posts[i].number) >= 0 : false); if (source.pageModel.type == UrlPageModel.TYPE_THREADPAGE) { model.hidden = isHiddenDelegate. isHidden(source.pageModel.chanName, source.pageModel.boardName, source.pageModel.threadNumber, posts[i].number); } else if (source.pageModel.type == UrlPageModel.TYPE_BOARDPAGE || source.pageModel.type == UrlPageModel.TYPE_CATALOGPAGE) { model.hidden = isHiddenDelegate.isHidden(source.pageModel.chanName, source.pageModel.boardName, posts[i].number, null); } if (!model.hidden && ( //автоскрытие source.pageModel.type == UrlPageModel.TYPE_THREADPAGE || source.pageModel.type == UrlPageModel.TYPE_BOARDPAGE || source.pageModel.type == UrlPageModel.TYPE_CATALOGPAGE)) { for (AutohideActivity.CompiledAutohideRule rule : autohideRules) { if ( (rule.inComment && model.spannedComment != null && rule.pattern.matcher(model.spannedComment).find()) || (rule.inSubject && posts[i].subject != null && rule.pattern.matcher(posts[i].subject).find()) || (rule.inName && (posts[i].name != null && rule.pattern.matcher(posts[i].name).find()) || (posts[i].trip != null && rule.pattern.matcher(posts[i].trip).find()))) { model.hidden = true; model.autohideReason = rule.regex; } } } } if (source.pageModel.type == UrlPageModel.TYPE_THREADPAGE) { for (PresentationItemModel model : presentationList) { if (task.isCancelled()) return; model.buildReferencesString(); } } if (source.threads != null) { for (int i=0; i<source.threads.length; ++i) { if (task.isCancelled()) return; presentationList.get(i).buildPostsCountString(source.threads[i].postsCount, source.threads[i].attachmentsCount); presentationList.get(i).buildStickyClosedString(source.threads[i].isSticky, source.threads[i].isClosed); } } notReady = false; } /** * Возвращает true, если построение модели не было завершено (было прервано или происходит в данный момент) */ public boolean isNotReady() { return notReady; } /** * Установить значение notReady как true (например, при фоновом автообновлении, чтобы при отображении построить модель) */ public void setNotReady() { notReady = true; } /** * Получить список вложений на данной странице, в виде {@link Triple} из модели вложения, хэша вложения и номера поста, к которому относится файл * @return список или null, если модель не построена или обновляется в данный момент */ public List<Triple<AttachmentModel, String, String>> getAttachments() { if (notReady || attachments == null) return null; synchronized (lock) { if (notReady) return null; return new ArrayList<Triple<AttachmentModel, String, String>>(attachments); } } /** * Получить копию списка готовых постов ({@link #presentationList}), в случае, если в данный момент не происходит обновление модели * (в этом случае метод вернёт null) */ public List<PresentationItemModel> getSafePresentationList() { if (notReady || presentationList == null) return null; synchronized (lock) { if (notReady) return null; return new ArrayList<PresentationItemModel>(presentationList); } } }