generated from exteraGramPlugins/pluginBase
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e4c2f2a6f | ||
|
|
8535bde5ae | ||
|
|
dba0c4f833 |
No files matched your search
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"name": "Message Logger",
|
"name": "Message Logger",
|
||||||
"description": "Shows deleted and edited messages and their edit history from your telegram-panel message logger.",
|
"description": "Shows deleted and edited messages and their edit history from your telegram-panel message logger.",
|
||||||
"author": "@lyuksovannyy",
|
"author": "@lyuksovannyy",
|
||||||
"version": "0.1.3",
|
"version": "0.1.6",
|
||||||
"icon": "exteraGramPluginIcons/4",
|
"icon": "exteraGramPluginIcons/4",
|
||||||
"app_version": ">=12.5.1",
|
"app_version": ">=12.5.1",
|
||||||
"sdk_version": ">=1.4.4.3",
|
"sdk_version": ">=1.4.4.3",
|
||||||
|
|||||||
+12
@@ -99,6 +99,18 @@ class Api:
|
|||||||
out.append((token[:10], exc))
|
out.append((token[:10], exc))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
def masters(self):
|
||||||
|
"""Telegram ids of the tokens' masters (from /me), cached 10 minutes."""
|
||||||
|
cached = getattr(self, "_masters", None)
|
||||||
|
if cached is not None and time.monotonic() - cached[0] < 600:
|
||||||
|
return cached[1]
|
||||||
|
found = []
|
||||||
|
for _prefix, me in self.me():
|
||||||
|
if isinstance(me, dict) and me.get("master_tg_id"):
|
||||||
|
found.append(int(me["master_tg_id"]))
|
||||||
|
self._masters = (time.monotonic(), found)
|
||||||
|
return found
|
||||||
|
|
||||||
def sync(self, account_id, chat_id, min_id, max_id, present_ids):
|
def sync(self, account_id, chat_id, min_id, max_id, present_ids):
|
||||||
body = {
|
body = {
|
||||||
"account_id": account_id,
|
"account_id": account_id,
|
||||||
|
|||||||
+284
-66
@@ -19,20 +19,26 @@ import time
|
|||||||
|
|
||||||
from base_plugin import MethodHook
|
from base_plugin import MethodHook
|
||||||
|
|
||||||
import accounts
|
|
||||||
import diag
|
import diag
|
||||||
|
import media_share
|
||||||
import peers
|
import peers
|
||||||
from api import ApiError
|
from api import ApiError
|
||||||
from jutil import field, hook_methods, in_background, java_list, on_ui
|
from jutil import field, hook_methods, in_background, java_class, java_list, on_ui
|
||||||
from views.history import _epoch
|
from views.history import _epoch
|
||||||
|
|
||||||
CONTROLLER = "org.telegram.messenger.MessagesController"
|
CONTROLLER = "org.telegram.messenger.MessagesController"
|
||||||
THUMBS = "org.telegram.ui.Components.GroupedPhotosListView"
|
THUMBS = "org.telegram.ui.Components.GroupedPhotosListView"
|
||||||
|
VIEWER = "org.telegram.ui.PhotoViewer"
|
||||||
|
GALLERY = "org.telegram.ui.Components.ProfileGalleryView"
|
||||||
|
OVERLAYS = "org.telegram.ui.ProfileActivity$OverlaysView"
|
||||||
|
_MARK_TAG = "message_logger_trash"
|
||||||
NOTIFICATIONS = "org.telegram.messenger.NotificationCenter"
|
NOTIFICATIONS = "org.telegram.messenger.NotificationCenter"
|
||||||
_DOWNLOAD_SECONDS = 20
|
|
||||||
# A logged photo whose id isn't Telegram's (logged by a bot) is the chat's
|
# A logged photo whose id isn't Telegram's (logged by a bot) is the chat's
|
||||||
# own when one of the chat's photos was set this close to its post.
|
# own when one of the chat's photos was set this close to its post.
|
||||||
_SAME_PHOTO_SECONDS = 10
|
_SAME_PHOTO_SECONDS = 10
|
||||||
|
# A chat's logged photos are asked for again after this long.
|
||||||
|
_FRESH_SECONDS = 600
|
||||||
|
_TELL_SECONDS = 3
|
||||||
|
|
||||||
|
|
||||||
def _photo_of(copy):
|
def _photo_of(copy):
|
||||||
@@ -50,65 +56,24 @@ def _photo_of(copy):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def logged_photo(account, avatar):
|
def logged_photo(account, avatar, masters=()):
|
||||||
"""(photo, reader slot, post) of a logged chat photo, else None (blocking)."""
|
"""(photo, reader slot, post) of a logged chat photo, read through a
|
||||||
|
member of its log group signed in here; else None (blocking)."""
|
||||||
from views.removed_chat import _fetch
|
from views.removed_chat import _fetch
|
||||||
|
|
||||||
logged = avatar.get("logged") or {}
|
logged = avatar.get("logged") or {}
|
||||||
if not avatar.get("has_photo") or not logged.get("chat_id"):
|
if not avatar.get("has_photo") or not logged.get("chat_id"):
|
||||||
return None
|
return None
|
||||||
channel_id = peers.channel_id_from_marked(logged["chat_id"])
|
reader = media_share.reader_for(
|
||||||
readers = accounts.with_access(
|
account, logged["chat_id"], [avatar.get("master_tg_id"), *masters]
|
||||||
-channel_id, prefer=(account, accounts.slot_of(avatar.get("master_tg_id")))
|
|
||||||
)
|
)
|
||||||
if not readers:
|
if reader is None:
|
||||||
return None
|
return None
|
||||||
|
channel_id = peers.channel_id_from_marked(logged["chat_id"])
|
||||||
message_id = int(logged["message_id"])
|
message_id = int(logged["message_id"])
|
||||||
copy = _fetch(readers[0], -channel_id, [message_id]).get(message_id)
|
copy = _fetch(reader, -channel_id, [message_id]).get(message_id)
|
||||||
photo = _photo_of(copy)
|
found = _photo_of(copy)
|
||||||
return (photo, readers[0], copy) if photo is not None else None
|
return (found, reader, copy) if found is not None else None
|
||||||
|
|
||||||
|
|
||||||
def _size(photo, side):
|
|
||||||
"""The smallest downloadable size at least `side` px wide (else the
|
|
||||||
largest): inline thumbnails have no file."""
|
|
||||||
sizes = [
|
|
||||||
size
|
|
||||||
for size in java_list(photo.sizes)
|
|
||||||
if getattr(size, "location", None) is not None and int(size.w or 0) > 0
|
|
||||||
]
|
|
||||||
if not sizes:
|
|
||||||
return None
|
|
||||||
bigger = [size for size in sizes if max(int(size.w), int(size.h)) >= side]
|
|
||||||
if bigger:
|
|
||||||
return min(bigger, key=lambda size: int(size.w))
|
|
||||||
return max(sizes, key=lambda size: int(size.w))
|
|
||||||
|
|
||||||
|
|
||||||
def download(reader, photo, copy, side):
|
|
||||||
"""Downloads the photo's size closest to `side` px through `reader` into
|
|
||||||
the shared cache; its path, or None (blocking)."""
|
|
||||||
from java import jint
|
|
||||||
from org.telegram.messenger import FileLoader, ImageLocation, MessageObject
|
|
||||||
|
|
||||||
size = _size(photo, side)
|
|
||||||
if size is None:
|
|
||||||
return None
|
|
||||||
loader = FileLoader.getInstance(reader)
|
|
||||||
path = loader.getPathToAttach(size, True)
|
|
||||||
if not path.exists():
|
|
||||||
parent = MessageObject(reader, copy, False, False)
|
|
||||||
loader.loadFile(
|
|
||||||
ImageLocation.getForPhoto(size, photo),
|
|
||||||
parent,
|
|
||||||
"jpg",
|
|
||||||
jint(FileLoader.PRIORITY_HIGH),
|
|
||||||
jint(1),
|
|
||||||
)
|
|
||||||
deadline = time.monotonic() + _DOWNLOAD_SECONDS
|
|
||||||
while not path.exists() and time.monotonic() < deadline:
|
|
||||||
time.sleep(0.25)
|
|
||||||
return str(path.getAbsolutePath()) if path.exists() else None
|
|
||||||
|
|
||||||
|
|
||||||
class Avatars:
|
class Avatars:
|
||||||
@@ -120,8 +85,15 @@ class Avatars:
|
|||||||
self._recorded = {} # (account, dialog) -> [(photo, original id, date)]
|
self._recorded = {} # (account, dialog) -> [(photo, original id, date)]
|
||||||
self._ids = set() # ids of those photos: drawn with the trash icon
|
self._ids = set() # ids of those photos: drawn with the trash icon
|
||||||
self._loading = set()
|
self._loading = set()
|
||||||
|
self._loaded_at = {} # (account, dialog) -> monotonic time of the answer
|
||||||
|
self._told = {} # (account, dialog) -> monotonic time the gallery was told
|
||||||
|
self._appending = False
|
||||||
self._updates = {} # (account, dialog) -> args of its last dialogPhotosUpdate
|
self._updates = {} # (account, dialog) -> args of its last dialogPhotosUpdate
|
||||||
self._icon = None
|
self._icon = None
|
||||||
|
self._dates = {} # logged photo id -> (set at, deleted at), ISO strings
|
||||||
|
self._watched = {} # gallery hash -> its page listener (kept alive)
|
||||||
|
self._position = None # ProfileGalleryView.getRealPosition
|
||||||
|
self._shown = None # PhotoViewer.isVisible()
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
avatars = self
|
avatars = self
|
||||||
@@ -133,6 +105,35 @@ class Avatars:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
diag.error("avatars.photos", exc)
|
diag.error("avatars.photos", exc)
|
||||||
|
|
||||||
|
class AfterSetData(MethodHook):
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
avatars.watch_gallery(param.thisObject)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("avatars.gallery", exc)
|
||||||
|
|
||||||
|
class AfterOverlays(MethodHook):
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
avatars.update_profile(field(param.thisObject, "this$0"))
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("avatars.profile", exc)
|
||||||
|
|
||||||
|
class AfterSwitch(MethodHook):
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
avatars.viewer_dates(param.thisObject, int(param.args[0]))
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("avatars.dates", exc)
|
||||||
|
|
||||||
|
class BeforeOpen(MethodHook):
|
||||||
|
def before_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
if avatars.open_without_photo(param.thisObject):
|
||||||
|
param.setResult(None)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("avatars.open", exc)
|
||||||
|
|
||||||
class AfterThumbs(MethodHook):
|
class AfterThumbs(MethodHook):
|
||||||
def after_hooked_method(self, param):
|
def after_hooked_method(self, param):
|
||||||
try:
|
try:
|
||||||
@@ -149,6 +150,15 @@ class Avatars:
|
|||||||
|
|
||||||
self.handles += hook_methods(self.plugin, CONTROLLER, "getDialogPhotos", AfterPhotos())
|
self.handles += hook_methods(self.plugin, CONTROLLER, "getDialogPhotos", AfterPhotos())
|
||||||
self.handles += hook_methods(self.plugin, THUMBS, "onDraw", AfterThumbs())
|
self.handles += hook_methods(self.plugin, THUMBS, "onDraw", AfterThumbs())
|
||||||
|
self.handles += hook_methods(self.plugin, GALLERY, "setData", AfterSetData())
|
||||||
|
self.handles += hook_methods(
|
||||||
|
self.plugin, VIEWER, "setIsAboutToSwitchToIndex", AfterSwitch()
|
||||||
|
)
|
||||||
|
self.handles += hook_methods(
|
||||||
|
self.plugin, "org.telegram.ui.ProfileActivity", "openAvatar", BeforeOpen()
|
||||||
|
)
|
||||||
|
for name in ("onDraw", "setAlphaValue"):
|
||||||
|
self.handles += hook_methods(self.plugin, OVERLAYS, name, AfterOverlays())
|
||||||
self.handles += hook_methods(
|
self.handles += hook_methods(
|
||||||
self.plugin, NOTIFICATIONS, "postNotificationName", BeforeNotify()
|
self.plugin, NOTIFICATIONS, "postNotificationName", BeforeNotify()
|
||||||
)
|
)
|
||||||
@@ -166,14 +176,16 @@ class Avatars:
|
|||||||
def photos_wanted(self, controller, dialog_id, holder):
|
def photos_wanted(self, controller, dialog_id, holder):
|
||||||
"""getDialogPhotos: the chat's logged photos are loaded once, then
|
"""getDialogPhotos: the chat's logged photos are loaded once, then
|
||||||
put into its photos (again after the app reloaded them)."""
|
put into its photos (again after the app reloaded them)."""
|
||||||
if not self.api.configured or holder is None:
|
if self._appending or not self.api.configured or holder is None:
|
||||||
return
|
return
|
||||||
account = int(field(controller, "currentAccount") or 0)
|
account = int(field(controller, "currentAccount") or 0)
|
||||||
key = (account, int(dialog_id))
|
key = (account, int(dialog_id))
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if key in self._recorded:
|
if key in self._recorded:
|
||||||
self._add_to(*key, holder)
|
# Added (and the gallery told) once the app is done with it.
|
||||||
return
|
on_ui(lambda: self._append(*key))
|
||||||
|
if time.monotonic() - self._loaded_at.get(key, 0) < _FRESH_SECONDS:
|
||||||
|
return
|
||||||
if key in self._loading:
|
if key in self._loading:
|
||||||
return
|
return
|
||||||
self._loading.add(key)
|
self._loading.add(key)
|
||||||
@@ -190,20 +202,23 @@ class Avatars:
|
|||||||
diag.log(f"avatars {chat_id}: {exc}")
|
diag.log(f"avatars {chat_id}: {exc}")
|
||||||
return
|
return
|
||||||
recorded = []
|
recorded = []
|
||||||
|
masters = self.api.masters()
|
||||||
for avatar in logged:
|
for avatar in logged:
|
||||||
found = logged_photo(account, avatar)
|
found = logged_photo(account, avatar, masters)
|
||||||
if found is None:
|
if found is None:
|
||||||
continue
|
continue
|
||||||
photo, reader, copy = found
|
photo, reader, copy = found
|
||||||
download(reader, photo, copy, 160)
|
if not media_share.photo_sizes(reader, photo, copy):
|
||||||
if download(reader, photo, copy, 800) is None:
|
diag.log(f"avatars {chat_id}: logged photo unavailable")
|
||||||
continue
|
continue # it can't be shown
|
||||||
original = avatar.get("photo_id")
|
original = avatar.get("photo_id")
|
||||||
recorded.append(
|
recorded.append(
|
||||||
(photo, int(original) if original else None, _epoch(avatar.get("date")))
|
(photo, int(original) if original else None, _epoch(avatar.get("date")))
|
||||||
)
|
)
|
||||||
|
self._dates[int(photo.id)] = (avatar.get("date"), avatar.get("deleted_at"))
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._recorded[(account, dialog_id)] = recorded
|
self._recorded[(account, dialog_id)] = recorded
|
||||||
|
self._loaded_at[(account, dialog_id)] = time.monotonic()
|
||||||
self._ids.update(int(p.id) for p, _o, _d in recorded)
|
self._ids.update(int(p.id) for p, _o, _d in recorded)
|
||||||
if recorded:
|
if recorded:
|
||||||
diag.log(f"avatars {chat_id}: {len(recorded)} logged photos")
|
diag.log(f"avatars {chat_id}: {len(recorded)} logged photos")
|
||||||
@@ -241,13 +256,45 @@ class Avatars:
|
|||||||
added = True
|
added = True
|
||||||
return added
|
return added
|
||||||
|
|
||||||
def _append(self, account, dialog_id):
|
def _viewing(self, dialog_id):
|
||||||
|
"""Whether the photo viewer shows the chat's photos: its list is
|
||||||
|
built when it opens and a change meanwhile desyncs it from its
|
||||||
|
thumbnails (no swiping to the new ones)."""
|
||||||
|
from org.telegram.ui import PhotoViewer
|
||||||
|
|
||||||
|
if not PhotoViewer.hasInstance():
|
||||||
|
return False
|
||||||
|
viewer = PhotoViewer.getInstance()
|
||||||
|
if self._shown is None:
|
||||||
|
# The method (not the field, which stays set once it closed).
|
||||||
|
self._shown = java_class(VIEWER).getMethod("isVisible")
|
||||||
|
return bool(self._shown.invoke(viewer)) and int(
|
||||||
|
field(viewer, "avatarsDialogId") or 0
|
||||||
|
) == int(dialog_id)
|
||||||
|
|
||||||
|
def _append(self, account, dialog_id, force=False):
|
||||||
|
"""Puts the logged photos into the chat's photos and tells the
|
||||||
|
gallery, at most every few seconds (it reads the photos again)."""
|
||||||
from org.telegram.messenger import MessagesController, NotificationCenter
|
from org.telegram.messenger import MessagesController, NotificationCenter
|
||||||
|
|
||||||
holder = MessagesController.getInstance(account).getDialogPhotos(dialog_id)
|
if not self.handles:
|
||||||
if holder is None or not self._add_to(account, dialog_id, holder):
|
return # unloaded
|
||||||
|
if not force and self._viewing(dialog_id):
|
||||||
|
on_ui(lambda: self._append(account, dialog_id), 1000) # once it closed
|
||||||
return
|
return
|
||||||
args = self._updates.get((account, dialog_id))
|
self._appending = True # getDialogPhotos calls back into photos_wanted
|
||||||
|
try:
|
||||||
|
holder = MessagesController.getInstance(account).getDialogPhotos(dialog_id)
|
||||||
|
finally:
|
||||||
|
self._appending = False
|
||||||
|
if holder is None:
|
||||||
|
return
|
||||||
|
key = (account, dialog_id)
|
||||||
|
added = self._add_to(account, dialog_id, holder)
|
||||||
|
if not added and time.monotonic() - self._told.get(key, 0) < _TELL_SECONDS:
|
||||||
|
return
|
||||||
|
self._told[key] = time.monotonic()
|
||||||
|
args = self._updates.get(key)
|
||||||
if args is None:
|
if args is None:
|
||||||
from java import jarray
|
from java import jarray
|
||||||
from java.lang import Object
|
from java.lang import Object
|
||||||
@@ -273,7 +320,11 @@ class Avatars:
|
|||||||
account = int(field(param.thisObject, "currentAccount") or 0)
|
account = int(field(param.thisObject, "currentAccount") or 0)
|
||||||
dialog_id = int(field(holder, "dialogId") or 0)
|
dialog_id = int(field(holder, "dialogId") or 0)
|
||||||
self._updates[(account, dialog_id)] = extra
|
self._updates[(account, dialog_id)] = extra
|
||||||
if (account, dialog_id) in self._recorded:
|
if (account, dialog_id) not in self._recorded:
|
||||||
|
return
|
||||||
|
if self._viewing(dialog_id):
|
||||||
|
on_ui(lambda: self._append(account, dialog_id), 1000)
|
||||||
|
else:
|
||||||
self._add_to(account, dialog_id, holder)
|
self._add_to(account, dialog_id, holder)
|
||||||
|
|
||||||
# the trash icon
|
# the trash icon
|
||||||
@@ -338,6 +389,173 @@ class Avatars:
|
|||||||
icon.setBounds(right - size, bottom - size, right, bottom)
|
icon.setBounds(right - size, bottom - size, right, bottom)
|
||||||
icon.draw(canvas)
|
icon.draw(canvas)
|
||||||
|
|
||||||
|
# the profile's trash icon (under the menu button)
|
||||||
|
|
||||||
|
def watch_gallery(self, gallery):
|
||||||
|
"""Redraws the icon as the profile's gallery changes page."""
|
||||||
|
key = int(gallery.hashCode())
|
||||||
|
if key in self._watched:
|
||||||
|
return
|
||||||
|
from java import dynamic_proxy, jclass
|
||||||
|
|
||||||
|
listener_class = jclass("androidx.viewpager.widget.ViewPager$OnPageChangeListener")
|
||||||
|
avatars = self
|
||||||
|
|
||||||
|
class Pages(dynamic_proxy(listener_class)):
|
||||||
|
def onPageScrolled(self, position, offset, pixels):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def onPageSelected(self, position):
|
||||||
|
on_ui(lambda: avatars.update_profile(field(gallery, "this$0")))
|
||||||
|
|
||||||
|
def onPageScrollStateChanged(self, state):
|
||||||
|
pass
|
||||||
|
|
||||||
|
listener = Pages()
|
||||||
|
gallery.addOnPageChangeListener(listener)
|
||||||
|
self._watched[key] = listener
|
||||||
|
|
||||||
|
def _current_photo(self, gallery):
|
||||||
|
if self._position is None:
|
||||||
|
method = java_class(GALLERY).getDeclaredMethod("getRealPosition")
|
||||||
|
method.setAccessible(True)
|
||||||
|
self._position = method
|
||||||
|
photos = field(gallery, "photos")
|
||||||
|
index = int(self._position.invoke(gallery))
|
||||||
|
if photos is None or not 0 <= index < photos.size():
|
||||||
|
return None
|
||||||
|
return photos.get(index)
|
||||||
|
|
||||||
|
def update_profile(self, profile):
|
||||||
|
"""Shows the icon while the expanded gallery shows a logged photo."""
|
||||||
|
if profile is None or not self._ids:
|
||||||
|
return
|
||||||
|
gallery = field(profile, "avatarsViewPager")
|
||||||
|
if gallery is None:
|
||||||
|
return
|
||||||
|
photo = self._current_photo(gallery)
|
||||||
|
shown = (
|
||||||
|
photo is not None
|
||||||
|
and int(photo.id) in self._ids
|
||||||
|
and bool(field(profile, "isPulledDown"))
|
||||||
|
)
|
||||||
|
mark = self._profile_mark(profile, create=shown)
|
||||||
|
if mark is None:
|
||||||
|
return
|
||||||
|
from android.view import View
|
||||||
|
|
||||||
|
mark.setVisibility(View.VISIBLE if shown else View.GONE)
|
||||||
|
mark.setAlpha(float(field(profile, "currentExpandAnimatorValue") or 1.0))
|
||||||
|
|
||||||
|
def _profile_mark(self, profile, create):
|
||||||
|
root = field(profile, "fragmentView")
|
||||||
|
if root is None or not hasattr(root, "addView"):
|
||||||
|
return None
|
||||||
|
for index in range(root.getChildCount()):
|
||||||
|
child = root.getChildAt(index)
|
||||||
|
if str(child.getTag()) == _MARK_TAG:
|
||||||
|
return child
|
||||||
|
if not create:
|
||||||
|
return None
|
||||||
|
from android.view import Gravity
|
||||||
|
from android.widget import FrameLayout, ImageView
|
||||||
|
from java import jint
|
||||||
|
from org.telegram.messenger import AndroidUtilities
|
||||||
|
from org.telegram.ui.ActionBar import ActionBar, Theme
|
||||||
|
|
||||||
|
from android.graphics import PorterDuff, PorterDuffColorFilter
|
||||||
|
from org.telegram.messenger import R
|
||||||
|
|
||||||
|
from hooks.render import _trash_drawable
|
||||||
|
|
||||||
|
dp = AndroidUtilities.dp
|
||||||
|
icon = _trash_drawable(R.drawable.msg_delete)
|
||||||
|
red = jint(Theme.getColor(Theme.key_text_RedBold))
|
||||||
|
icon.setColorFilter(PorterDuffColorFilter(red, PorterDuff.Mode.SRC_IN))
|
||||||
|
mark = ImageView(root.getContext())
|
||||||
|
mark.setTag(_MARK_TAG)
|
||||||
|
mark.setImageDrawable(icon)
|
||||||
|
mark.setPadding(dp(8), dp(8), dp(8), dp(8))
|
||||||
|
mark.setBackground(Theme.createCircleDrawable(dp(36), jint(0x66000000)))
|
||||||
|
params = FrameLayout.LayoutParams(dp(36), dp(36))
|
||||||
|
params.gravity = Gravity.TOP | Gravity.RIGHT
|
||||||
|
params.topMargin = AndroidUtilities.statusBarHeight + ActionBar.getCurrentActionBarHeight()
|
||||||
|
params.rightMargin = dp(10)
|
||||||
|
root.addView(mark, params)
|
||||||
|
return mark
|
||||||
|
|
||||||
|
# dates in the photo viewer
|
||||||
|
|
||||||
|
def viewer_dates(self, viewer, index):
|
||||||
|
"""A logged photo's subtitle: when it was deleted and set (instead of
|
||||||
|
its upload to the log group)."""
|
||||||
|
photos = field(viewer, "avatarsArr")
|
||||||
|
if photos is None or not 0 <= index < photos.size():
|
||||||
|
return
|
||||||
|
photo = photos.get(index)
|
||||||
|
dates = self._dates.get(int(photo.id)) if photo is not None else None
|
||||||
|
container = field(viewer, "actionBarContainer")
|
||||||
|
if dates is None or container is None:
|
||||||
|
return
|
||||||
|
from views.history import _when
|
||||||
|
|
||||||
|
set_at, deleted_at = dates
|
||||||
|
parts = [f"deleted {_when(deleted_at)}" if deleted_at else "deleted"]
|
||||||
|
if set_at:
|
||||||
|
parts.append(f"set {_when(set_at)}")
|
||||||
|
text = " · ".join(parts)
|
||||||
|
container.setSubtitle(text[:1].upper() + text[1:])
|
||||||
|
|
||||||
|
# a chat without a photo
|
||||||
|
|
||||||
|
def open_without_photo(self, profile):
|
||||||
|
"""openAvatar of a chat that has no photo but logged ones: the photo
|
||||||
|
viewer opens on the newest logged photo (with the rest)."""
|
||||||
|
from org.telegram.messenger import ImageLocation, MessagesController
|
||||||
|
from org.telegram.tgnet import TLRPC
|
||||||
|
from org.telegram.ui import PhotoViewer
|
||||||
|
|
||||||
|
account = int(profile.getCurrentAccount())
|
||||||
|
controller = MessagesController.getInstance(account)
|
||||||
|
user_id, chat_id = int(field(profile, "userId") or 0), int(field(profile, "chatId") or 0)
|
||||||
|
if user_id:
|
||||||
|
peer, dialog_id = controller.getUser(user_id), user_id
|
||||||
|
empty = TLRPC.TL_userProfilePhotoEmpty
|
||||||
|
else:
|
||||||
|
peer, dialog_id = controller.getChat(chat_id), -chat_id
|
||||||
|
empty = TLRPC.TL_chatPhotoEmpty
|
||||||
|
photo = getattr(peer, "photo", None) if peer is not None else None
|
||||||
|
if photo is not None and not isinstance(photo, empty):
|
||||||
|
return False # the app opens it
|
||||||
|
recorded = self._recorded.get((account, dialog_id))
|
||||||
|
if not recorded:
|
||||||
|
controller.getDialogPhotos(dialog_id) # loads the logged ones
|
||||||
|
return False
|
||||||
|
# The newest of the chat's photos (its own, then the logged ones).
|
||||||
|
holder = controller.getDialogPhotos(dialog_id)
|
||||||
|
if holder is not None:
|
||||||
|
self._add_to(account, dialog_id, holder)
|
||||||
|
listed = java_list(field(holder, "photos")) if holder is not None else []
|
||||||
|
first = next((p for p in listed if p is not None), recorded[0][0])
|
||||||
|
size = media_share.photo_size(first, 800)
|
||||||
|
if size is None:
|
||||||
|
return False
|
||||||
|
viewer = PhotoViewer.getInstance()
|
||||||
|
viewer.setParentActivity(profile)
|
||||||
|
viewer.openPhoto(
|
||||||
|
size.location, ImageLocation.getForPhoto(size, first), field(profile, "provider")
|
||||||
|
)
|
||||||
|
# The profile names no chat for a photo it doesn't show itself: the
|
||||||
|
# viewer is told, and gets the chat's photos (the logged ones).
|
||||||
|
from hook_utils import set_private_field
|
||||||
|
from java import jlong
|
||||||
|
|
||||||
|
set_private_field(viewer, "avatarsDialogId", jlong(dialog_id))
|
||||||
|
self._told.pop((account, dialog_id), None)
|
||||||
|
self._append(account, dialog_id, force=True)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def _trash(self):
|
def _trash(self):
|
||||||
if self._icon is None:
|
if self._icon is None:
|
||||||
from android.graphics import Paint, PorterDuff, PorterDuffColorFilter
|
from android.graphics import Paint, PorterDuff, PorterDuffColorFilter
|
||||||
|
|||||||
+68
-4
@@ -27,6 +27,7 @@ from base_plugin import MethodHook
|
|||||||
|
|
||||||
import diag
|
import diag
|
||||||
import header
|
import header
|
||||||
|
import media_share
|
||||||
import peers
|
import peers
|
||||||
import store
|
import store
|
||||||
import tg
|
import tg
|
||||||
@@ -229,6 +230,8 @@ class ChatSync:
|
|||||||
self._inflight = set() # (account, dialog) with a sync running
|
self._inflight = set() # (account, dialog) with a sync running
|
||||||
self._exists = {} # (account, dialog) -> ids Telegram still has
|
self._exists = {} # (account, dialog) -> ids Telegram still has
|
||||||
self._restoring = set() # (account, dialog, id) in flight
|
self._restoring = set() # (account, dialog, id) in flight
|
||||||
|
self._copies = {} # (account, dialog, id) -> (copy, strip) read, not placed
|
||||||
|
self._placing = False # a restored message is being placed
|
||||||
self._fragments = {} # (account, dialog) -> last ChatActivity seen
|
self._fragments = {} # (account, dialog) -> last ChatActivity seen
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
@@ -247,6 +250,18 @@ class ChatSync:
|
|||||||
self.plugin, CHAT, "updateMessagesVisiblePart", AfterVisible()
|
self.plugin, CHAT, "updateMessagesVisiblePart", AfterVisible()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class BeforeBadge(MethodHook):
|
||||||
|
def before_hooked_method(self, param):
|
||||||
|
if sync._placing:
|
||||||
|
param.setResult(None) # a restored message is no arrival
|
||||||
|
|
||||||
|
self.handles += hook_methods(
|
||||||
|
self.plugin,
|
||||||
|
"org.telegram.ui.Components.chat.layouts.ChatActivitySideControlsButtonsLayout",
|
||||||
|
"setButtonCount",
|
||||||
|
BeforeBadge(),
|
||||||
|
)
|
||||||
|
|
||||||
def uninstall(self):
|
def uninstall(self):
|
||||||
for name in EDIT_UPDATES:
|
for name in EDIT_UPDATES:
|
||||||
try:
|
try:
|
||||||
@@ -419,6 +434,14 @@ class ChatSync:
|
|||||||
key = (account, dialog_id, message_id)
|
key = (account, dialog_id, message_id)
|
||||||
if key in self._restoring:
|
if key in self._restoring:
|
||||||
continue
|
continue
|
||||||
|
fetched = self._copies.get(key)
|
||||||
|
if fetched is not None: # read before; not placed yet
|
||||||
|
on_ui(
|
||||||
|
lambda f=fetched, m=message_id, st=state: self._insert(
|
||||||
|
fragment, account, dialog_id, m, st, *f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
pointer, strip = self._source(me, chat_id, message_id, state)
|
pointer, strip = self._source(me, chat_id, message_id, state)
|
||||||
if pointer is None:
|
if pointer is None:
|
||||||
continue
|
continue
|
||||||
@@ -440,8 +463,24 @@ class ChatSync:
|
|||||||
return state.get("logged"), True
|
return state.get("logged"), True
|
||||||
|
|
||||||
def _fetch_copy(self, fragment, account, dialog_id, message_id, state, pointer, strip):
|
def _fetch_copy(self, fragment, account, dialog_id, message_id, state, pointer, strip):
|
||||||
|
"""Reads the copy through a member of its log group signed in here
|
||||||
|
(this account, else the master); another account's download of its
|
||||||
|
media lets this one show it (media_share)."""
|
||||||
channel_id = peers.channel_id_from_marked(pointer["chat_id"])
|
channel_id = peers.channel_id_from_marked(pointer["chat_id"])
|
||||||
copy_id = int(pointer["message_id"])
|
copy_id = int(pointer["message_id"])
|
||||||
|
reader = media_share.reader_for(account, pointer["chat_id"], self.api.masters())
|
||||||
|
if reader is None:
|
||||||
|
self._restoring.discard((account, dialog_id, message_id))
|
||||||
|
diag.log(f"copy {pointer}: no account here is in its log group")
|
||||||
|
return
|
||||||
|
|
||||||
|
def insert(copy):
|
||||||
|
if reader != account:
|
||||||
|
ready, total = media_share.copy_media(reader, copy)
|
||||||
|
if total:
|
||||||
|
diag.log(f"copy {pointer}: {ready}/{total} media through account {reader}")
|
||||||
|
self._copies[(account, dialog_id, message_id)] = (copy, strip)
|
||||||
|
on_ui(lambda: self._insert(fragment, account, dialog_id, message_id, state, copy, strip))
|
||||||
|
|
||||||
def done(found, error):
|
def done(found, error):
|
||||||
copy = (found or {}).get(copy_id)
|
copy = (found or {}).get(copy_id)
|
||||||
@@ -449,22 +488,29 @@ class ChatSync:
|
|||||||
self._restoring.discard((account, dialog_id, message_id))
|
self._restoring.discard((account, dialog_id, message_id))
|
||||||
diag.log(f"copy {pointer} unavailable: {error}")
|
diag.log(f"copy {pointer} unavailable: {error}")
|
||||||
return
|
return
|
||||||
on_ui(lambda: self._insert(fragment, account, dialog_id, message_id, state, copy, strip))
|
in_background(lambda: insert(copy))
|
||||||
|
|
||||||
tg.get_messages(account, -channel_id, [copy_id], done)
|
tg.get_messages(reader, -channel_id, [copy_id], done)
|
||||||
|
|
||||||
def _insert(self, fragment, account, dialog_id, message_id, state, copy, strip):
|
def _insert(self, fragment, account, dialog_id, message_id, state, copy, strip):
|
||||||
"""Hands the rebuilt message to the chat the way new messages arrive
|
"""Hands the rebuilt message to the chat the way new messages arrive
|
||||||
(ChatActivity.processNewMessages), so the app places it and keeps its
|
(ChatActivity.processNewMessages), so the app places it and keeps its
|
||||||
own dicts and adapter consistent."""
|
own dicts and adapter consistent."""
|
||||||
self._restoring.discard((account, dialog_id, message_id))
|
key = (account, dialog_id, message_id)
|
||||||
|
self._restoring.discard(key)
|
||||||
messages = field(fragment, "messages")
|
messages = field(fragment, "messages")
|
||||||
if messages is None:
|
if messages is None:
|
||||||
return
|
return
|
||||||
if _loaded(fragment, message_id) or any(
|
if _loaded(fragment, message_id) or any(
|
||||||
m is not None and int(m.getId()) == message_id for m in java_list(messages)
|
m is not None and int(m.getId()) == message_id for m in java_list(messages)
|
||||||
):
|
):
|
||||||
|
self._copies.pop(key, None)
|
||||||
return
|
return
|
||||||
|
ids = [int(m.getId()) for m in java_list(messages) if m is not None and int(m.getId()) > 0]
|
||||||
|
at_end = _end_reached(fragment, "forwardEndReached")
|
||||||
|
if not at_end and (not ids or not min(ids) < message_id < max(ids)):
|
||||||
|
return # outside what is loaded: placed when that part is
|
||||||
|
self._copies.pop(key, None)
|
||||||
from java import jboolean
|
from java import jboolean
|
||||||
from java.util import ArrayList
|
from java.util import ArrayList
|
||||||
|
|
||||||
@@ -472,7 +518,25 @@ class ChatSync:
|
|||||||
store.mark_deleted(account, dialog_id, [message_id])
|
store.mark_deleted(account, dialog_id, [message_id])
|
||||||
batch = ArrayList()
|
batch = ArrayList()
|
||||||
batch.add(obj)
|
batch.add(obj)
|
||||||
_process_new_messages().invoke(fragment, batch, jboolean(False))
|
# Away from the newest messages the app only counts arrivals (the
|
||||||
|
# scroll-down badge); with the end "loaded" it places the message
|
||||||
|
# by its id among the loaded ones.
|
||||||
|
# It is no arrival: the unread count (scroll-down badge) stays.
|
||||||
|
flags = field(fragment, "forwardEndReached")
|
||||||
|
unread = int(field(fragment, "newUnreadMessageCount") or 0)
|
||||||
|
if not at_end:
|
||||||
|
flags[0] = True
|
||||||
|
self._placing = True
|
||||||
|
try:
|
||||||
|
_process_new_messages().invoke(fragment, batch, jboolean(False))
|
||||||
|
finally:
|
||||||
|
self._placing = False
|
||||||
|
if not at_end:
|
||||||
|
flags[0] = False
|
||||||
|
from hook_utils import set_private_field
|
||||||
|
from java import jint
|
||||||
|
|
||||||
|
set_private_field(fragment, "newUnreadMessageCount", jint(unread))
|
||||||
diag.log(f"restored {dialog_id}/{message_id}")
|
diag.log(f"restored {dialog_id}/{message_id}")
|
||||||
|
|
||||||
# cells (UI thread)
|
# cells (UI thread)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from base_plugin import MethodHook
|
|||||||
|
|
||||||
import accounts
|
import accounts
|
||||||
import diag
|
import diag
|
||||||
|
import media_share
|
||||||
import peers
|
import peers
|
||||||
import store
|
import store
|
||||||
from api import ApiError
|
from api import ApiError
|
||||||
@@ -226,11 +227,11 @@ class RemovedChats:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _load_avatar(self, account, dialog_id, avatar):
|
def _load_avatar(self, account, dialog_id, avatar):
|
||||||
found = avatars.logged_photo(account, avatar)
|
found = avatars.logged_photo(account, avatar, self.api.masters())
|
||||||
if found is None:
|
if found is None:
|
||||||
return
|
return
|
||||||
photo, reader, copy = found
|
photo, reader, copy = found
|
||||||
path = avatars.download(reader, photo, copy, 160)
|
path = media_share.photo(reader, photo, copy, 160)
|
||||||
if path is None:
|
if path is None:
|
||||||
return
|
return
|
||||||
self._avatar_paths[(account, dialog_id)] = path
|
self._avatar_paths[(account, dialog_id)] = path
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Logger copies read through an account that can, their media shown by any.
|
||||||
|
|
||||||
|
The log groups' members (the master, accounts added to them) can read the
|
||||||
|
copies; others can't, not even download a copy's media with its file ids.
|
||||||
|
So a copy is read through a member signed in here (`reader`: the current
|
||||||
|
account when it is one, else the master), and its media downloaded through
|
||||||
|
that member into the app's file directories, which all its accounts share:
|
||||||
|
the current account then finds the files there.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import accounts
|
||||||
|
from jutil import java_list
|
||||||
|
|
||||||
|
_WAIT_SECONDS = 20
|
||||||
|
# Larger documents are left to a tap (and fail for a non-member).
|
||||||
|
_MAX_DOCUMENT_BYTES = 20 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def reader_for(account, log_chat_id, masters=()):
|
||||||
|
"""A signed-in member of a log group (marked id): `account` when it is
|
||||||
|
one, else a master, else any; None when none is signed in."""
|
||||||
|
import peers
|
||||||
|
|
||||||
|
channel_id = peers.channel_id_from_marked(log_chat_id)
|
||||||
|
prefer = [account] + [accounts.slot_of(m) for m in masters if m]
|
||||||
|
readers = accounts.with_access(-channel_id, prefer=prefer)
|
||||||
|
return readers[0] if readers else None
|
||||||
|
|
||||||
|
|
||||||
|
def _wait(path):
|
||||||
|
deadline = time.monotonic() + _WAIT_SECONDS
|
||||||
|
while not path.exists() and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.25)
|
||||||
|
return path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def _files(photo):
|
||||||
|
"""The photo's sizes that are files (inline thumbnails are not)."""
|
||||||
|
return [
|
||||||
|
size
|
||||||
|
for size in java_list(photo.sizes)
|
||||||
|
if getattr(size, "location", None) is not None
|
||||||
|
and int(size.w or 0) > 0
|
||||||
|
and not any(
|
||||||
|
kind in type(size).__name__ for kind in ("Stripped", "PathSize", "Cached")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def photo_size(photo, side):
|
||||||
|
"""The smallest downloadable size at least `side` px (else the largest):
|
||||||
|
inline thumbnails have no file."""
|
||||||
|
sizes = _files(photo)
|
||||||
|
if not sizes:
|
||||||
|
return None
|
||||||
|
bigger = [size for size in sizes if max(int(size.w), int(size.h)) >= side]
|
||||||
|
if bigger:
|
||||||
|
return min(bigger, key=lambda size: int(size.w))
|
||||||
|
return max(sizes, key=lambda size: int(size.w))
|
||||||
|
|
||||||
|
|
||||||
|
def photo(reader, photo, copy, side):
|
||||||
|
"""Downloads the photo's size closest to `side` px through `reader` into
|
||||||
|
the shared cache; its path, or None (blocking)."""
|
||||||
|
size = photo_size(photo, side)
|
||||||
|
return _photo_size(reader, photo, size, copy) if size is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def photo_sizes(reader, photo, copy):
|
||||||
|
"""Downloads every size of the photo (thumbnails, the viewer's large one)
|
||||||
|
through `reader`; whether the largest is there (blocking)."""
|
||||||
|
sizes = _files(photo)
|
||||||
|
sizes.sort(key=lambda size: int(size.w))
|
||||||
|
paths = [_photo_size(reader, photo, size, copy) for size in sizes]
|
||||||
|
return bool(paths) and paths[-1] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _photo_size(reader, photo, size, copy):
|
||||||
|
from java import jint
|
||||||
|
from org.telegram.messenger import FileLoader, ImageLocation, MessageObject
|
||||||
|
|
||||||
|
loader = FileLoader.getInstance(reader)
|
||||||
|
path = loader.getPathToAttach(size, True)
|
||||||
|
if not path.exists():
|
||||||
|
parent = MessageObject(reader, copy, False, False)
|
||||||
|
loader.loadFile(
|
||||||
|
ImageLocation.getForPhoto(size, photo),
|
||||||
|
parent,
|
||||||
|
"jpg",
|
||||||
|
jint(FileLoader.PRIORITY_HIGH),
|
||||||
|
jint(1),
|
||||||
|
)
|
||||||
|
return str(path.getAbsolutePath()) if _wait(path) else None
|
||||||
|
|
||||||
|
|
||||||
|
def document(reader, document, copy):
|
||||||
|
"""Downloads a document (up to 20 MB) through `reader` where the app
|
||||||
|
keeps its kind of file; whether it is there (blocking)."""
|
||||||
|
from java import jint
|
||||||
|
from org.telegram.messenger import FileLoader, MessageObject
|
||||||
|
|
||||||
|
if int(getattr(document, "size", 0) or 0) > _MAX_DOCUMENT_BYTES:
|
||||||
|
return False
|
||||||
|
loader = FileLoader.getInstance(reader)
|
||||||
|
path = loader.getPathToAttach(document)
|
||||||
|
if path.exists():
|
||||||
|
return True
|
||||||
|
parent = MessageObject(reader, copy, False, False)
|
||||||
|
loader.loadFile(document, parent, jint(FileLoader.PRIORITY_HIGH), jint(0))
|
||||||
|
return _wait(path)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_media(reader, copy):
|
||||||
|
"""Downloads a copy's media (photo, document, a rich message's) through
|
||||||
|
`reader` before another account shows it (blocking). Returns (ready,
|
||||||
|
total)."""
|
||||||
|
media = getattr(copy, "media", None)
|
||||||
|
photos, documents = [], []
|
||||||
|
if getattr(media, "photo", None) is not None:
|
||||||
|
photos.append(media.photo)
|
||||||
|
if getattr(media, "document", None) is not None:
|
||||||
|
documents.append(media.document)
|
||||||
|
rich = getattr(copy, "rich_message", None)
|
||||||
|
if rich is not None:
|
||||||
|
photos += java_list(rich.photos)
|
||||||
|
documents += java_list(rich.documents)
|
||||||
|
ready = 0
|
||||||
|
for item in photos:
|
||||||
|
if getattr(item, "sizes", None) is not None:
|
||||||
|
ready += bool(photo_sizes(reader, item, copy))
|
||||||
|
for item in documents:
|
||||||
|
ready += bool(document(reader, item, copy))
|
||||||
|
return ready, len(photos) + len(documents)
|
||||||
@@ -11,8 +11,8 @@ getHistory/getMessages for it (hooks/removed_chats.py).
|
|||||||
|
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
import accounts
|
|
||||||
import diag
|
import diag
|
||||||
|
import media_share
|
||||||
import peers
|
import peers
|
||||||
import store
|
import store
|
||||||
import tg
|
import tg
|
||||||
@@ -124,17 +124,18 @@ class History:
|
|||||||
if pointer is not None:
|
if pointer is not None:
|
||||||
by_group.setdefault(pointer[0], set()).add(pointer[1])
|
by_group.setdefault(pointer[0], set()).add(pointer[1])
|
||||||
found = {}
|
found = {}
|
||||||
|
masters = [master, *self.api.masters()]
|
||||||
for log_chat, ids in by_group.items():
|
for log_chat, ids in by_group.items():
|
||||||
channel_id = peers.channel_id_from_marked(log_chat)
|
channel_id = peers.channel_id_from_marked(log_chat)
|
||||||
readers = accounts.with_access(
|
reader = media_share.reader_for(self.account, log_chat, masters)
|
||||||
-channel_id, prefer=(self.account, accounts.slot_of(master))
|
if reader is None:
|
||||||
)
|
|
||||||
if not readers:
|
|
||||||
continue
|
continue
|
||||||
ordered = sorted(ids)
|
ordered = sorted(ids)
|
||||||
for start in range(0, len(ordered), _COPIES_PER_REQUEST):
|
for start in range(0, len(ordered), _COPIES_PER_REQUEST):
|
||||||
chunk = ordered[start : start + _COPIES_PER_REQUEST]
|
chunk = ordered[start : start + _COPIES_PER_REQUEST]
|
||||||
for message_id, copy in _fetch(readers[0], -channel_id, chunk).items():
|
for message_id, copy in _fetch(reader, -channel_id, chunk).items():
|
||||||
|
if reader != self.account:
|
||||||
|
media_share.copy_media(reader, copy)
|
||||||
found[(log_chat, message_id)] = copy
|
found[(log_chat, message_id)] = copy
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user