diff --git a/app/build.gradle b/app/build.gradle index da055b53a63be2ac6252c23cebc894ecedc6d2e0..866a47dd451113d89321a082c884cf20a69617a2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -144,10 +144,18 @@ android { buildFeatures { buildConfig true } + sourceSets { + main { + assets { + srcDirs 'src/main/assets', 'src/tests/assets', 'src/test/assets' + } + } + } } dependencies { implementation 'com.google.dagger:dagger:2.48' + implementation 'androidx.test:monitor:1.7.2' annotationProcessor 'com.google.dagger:dagger-compiler:2.48' //Core @@ -186,7 +194,6 @@ dependencies { implementation 'androidx.media:media:1.7.0' //Other - implementation 'org.jmdns:jmdns:3.5.1' implementation 'org.jsoup:jsoup:1.15.3' implementation 'com.google.android.flexbox:flexbox:3.0.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' diff --git a/app/src/main/assets/scripts/source.js b/app/src/main/assets/scripts/source.js index 0324e0f577b6ba6eb7c3b37264625ee06158710a..134dd4a7a43760f5c43911c2eb166c5cbdbd0028 100644 --- a/app/src/main/assets/scripts/source.js +++ b/app/src/main/assets/scripts/source.js @@ -406,6 +406,39 @@ class DashSource { this.requestModifier = obj.requestModifier; } } +class DashManifestRawSource { + constructor(obj) { + obj = obj ?? {}; + this.plugin_type = "DashRawSource"; + this.name = obj.name ?? ""; + this.bitrate = obj.bitrate ?? 0; + this.container = obj.container ?? ""; + this.codec = obj.codec ?? ""; + this.duration = obj.duration ?? 0; + this.url = obj.url; + this.language = obj.language ?? Language.UNKNOWN; + if(obj.requestModifier) + this.requestModifier = obj.requestModifier; + } +} + +class DashManifestRawAudioSource { + constructor(obj) { + obj = obj ?? {}; + this.plugin_type = "DashRawAudioSource"; + this.name = obj.name ?? ""; + this.bitrate = obj.bitrate ?? 0; + this.container = obj.container ?? ""; + this.codec = obj.codec ?? ""; + this.duration = obj.duration ?? 0; + this.url = obj.url; + this.language = obj.language ?? Language.UNKNOWN; + this.manifest = obj.manifest ?? null; + if(obj.requestModifier) + this.requestModifier = obj.requestModifier; + } +} + class RequestModifier { constructor(obj) { diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Content.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Content.kt index 71516d7f916a5399a501f279dc2028680c6c0ce1..72903ec806b15cade70e56a0adbb45e235ef0721 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Content.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Content.kt @@ -18,7 +18,10 @@ fun IAudioSource.isDownloadable(): Boolean = VideoHelper.isDownloadable(this); @UnstableApi fun JSSource.getHttpDataSourceFactory(): HttpDataSource.Factory { val requestModifier = getRequestModifier(); - return if (requestModifier != null) { + val requestExecutor = getRequestExecutor(); + return if (requestExecutor != null) { + JSHttpDataSource.Factory().setRequestExecutor(requestExecutor); + } else if (requestModifier != null) { JSHttpDataSource.Factory().setRequestModifier(requestModifier); } else { DefaultHttpDataSource.Factory(); diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt index 6c316ac5021a08fb6189e7b05ec8f8929d2b803a..0ebaa40f8b4ddd848782c6b5d200667eddb88157 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt @@ -9,6 +9,7 @@ import org.jsoup.nodes.Element import org.jsoup.nodes.Node import org.jsoup.nodes.TextNode import org.jsoup.parser.Tag +import java.lang.IllegalStateException import java.text.DecimalFormat import java.time.OffsetDateTime import java.time.temporal.ChronoUnit @@ -352,10 +353,23 @@ fun String.toSafeFileName(): String { return this.map { if (it in safeCharacters) it else '_' }.joinToString(separator = "") } +private val slds = hashSetOf(".com.ac", ".net.ac", ".gov.ac", ".org.ac", ".mil.ac", ".co.ae", ".net.ae", ".gov.ae", ".ac.ae", ".sch.ae", ".org.ae", ".mil.ae", ".pro.ae", ".name.ae", ".com.af", ".edu.af", ".gov.af", ".net.af", ".org.af", ".com.al", ".edu.al", ".gov.al", ".mil.al", ".net.al", ".org.al", ".ed.ao", ".gv.ao", ".og.ao", ".co.ao", ".pb.ao", ".it.ao", ".com.ar", ".edu.ar", ".gob.ar", ".gov.ar", ".gov.ar", ".int.ar", ".mil.ar", ".net.ar", ".org.ar", ".tur.ar", ".gv.at", ".ac.at", ".co.at", ".or.at", ".com.au", ".net.au", ".org.au", ".edu.au", ".gov.au", ".csiro.au", ".asn.au", ".id.au", ".org.ba", ".net.ba", ".edu.ba", ".gov.ba", ".mil.ba", ".unsa.ba", ".untz.ba", ".unmo.ba", ".unbi.ba", ".unze.ba", ".co.ba", ".com.ba", ".rs.ba", ".co.bb", ".com.bb", ".net.bb", ".org.bb", ".gov.bb", ".edu.bb", ".info.bb", ".store.bb", ".tv.bb", ".biz.bb", ".com.bh", ".info.bh", ".cc.bh", ".edu.bh", ".biz.bh", ".net.bh", ".org.bh", ".gov.bh", ".com.bn", ".edu.bn", ".gov.bn", ".net.bn", ".org.bn", ".com.bo", ".net.bo", ".org.bo", ".tv.bo", ".mil.bo", ".int.bo", ".gob.bo", ".gov.bo", ".edu.bo", ".adm.br", ".adv.br", ".agr.br", ".am.br", ".arq.br", ".art.br", ".ato.br", ".b.br", ".bio.br", ".blog.br", ".bmd.br", ".cim.br", ".cng.br", ".cnt.br", ".com.br", ".coop.br", ".ecn.br", ".edu.br", ".eng.br", ".esp.br", ".etc.br", ".eti.br", ".far.br", ".flog.br", ".fm.br", ".fnd.br", ".fot.br", ".fst.br", ".g12.br", ".ggf.br", ".gov.br", ".imb.br", ".ind.br", ".inf.br", ".jor.br", ".jus.br", ".lel.br", ".mat.br", ".med.br", ".mil.br", ".mus.br", ".net.br", ".nom.br", ".not.br", ".ntr.br", ".odo.br", ".org.br", ".ppg.br", ".pro.br", ".psc.br", ".psi.br", ".qsl.br", ".rec.br", ".slg.br", ".srv.br", ".tmp.br", ".trd.br", ".tur.br", ".tv.br", ".vet.br", ".vlog.br", ".wiki.br", ".zlg.br", ".com.bs", ".net.bs", ".org.bs", ".edu.bs", ".gov.bs", "com.bz", "edu.bz", "gov.bz", "net.bz", "org.bz", ".ab.ca", ".bc.ca", ".mb.ca", ".nb.ca", ".nf.ca", ".nl.ca", ".ns.ca", ".nt.ca", ".nu.ca", ".on.ca", ".pe.ca", ".qc.ca", ".sk.ca", ".yk.ca", ".co.ck", ".org.ck", ".edu.ck", ".gov.ck", ".net.ck", ".gen.ck", ".biz.ck", ".info.ck", ".ac.cn", ".com.cn", ".edu.cn", ".gov.cn", ".mil.cn", ".net.cn", ".org.cn", ".ah.cn", ".bj.cn", ".cq.cn", ".fj.cn", ".gd.cn", ".gs.cn", ".gz.cn", ".gx.cn", ".ha.cn", ".hb.cn", ".he.cn", ".hi.cn", ".hl.cn", ".hn.cn", ".jl.cn", ".js.cn", ".jx.cn", ".ln.cn", ".nm.cn", ".nx.cn", ".qh.cn", ".sc.cn", ".sd.cn", ".sh.cn", ".sn.cn", ".sx.cn", ".tj.cn", ".tw.cn", ".xj.cn", ".xz.cn", ".yn.cn", ".zj.cn", ".com.co", ".org.co", ".edu.co", ".gov.co", ".net.co", ".mil.co", ".nom.co", ".ac.cr", ".co.cr", ".ed.cr", ".fi.cr", ".go.cr", ".or.cr", ".sa.cr", ".cr", ".ac.cy", ".net.cy", ".gov.cy", ".org.cy", ".pro.cy", ".name.cy", ".ekloges.cy", ".tm.cy", ".ltd.cy", ".biz.cy", ".press.cy", ".parliament.cy", ".com.cy", ".edu.do", ".gob.do", ".gov.do", ".com.do", ".sld.do", ".org.do", ".net.do", ".web.do", ".mil.do", ".art.do", ".com.dz", ".org.dz", ".net.dz", ".gov.dz", ".edu.dz", ".asso.dz", ".pol.dz", ".art.dz", ".com.ec", ".info.ec", ".net.ec", ".fin.ec", ".med.ec", ".pro.ec", ".org.ec", ".edu.ec", ".gov.ec", ".mil.ec", ".com.eg", ".edu.eg", ".eun.eg", ".gov.eg", ".mil.eg", ".name.eg", ".net.eg", ".org.eg", ".sci.eg", ".com.er", ".edu.er", ".gov.er", ".mil.er", ".net.er", ".org.er", ".ind.er", ".rochest.er", ".w.er", ".com.es", ".nom.es", ".org.es", ".gob.es", ".edu.es", ".com.et", ".gov.et", ".org.et", ".edu.et", ".net.et", ".biz.et", ".name.et", ".info.et", ".ac.fj", ".biz.fj", ".com.fj", ".info.fj", ".mil.fj", ".name.fj", ".net.fj", ".org.fj", ".pro.fj", ".co.fk", ".org.fk", ".gov.fk", ".ac.fk", ".nom.fk", ".net.fk", ".fr", ".tm.fr", ".asso.fr", ".nom.fr", ".prd.fr", ".presse.fr", ".com.fr", ".gouv.fr", ".co.gg", ".net.gg", ".org.gg", ".com.gh", ".edu.gh", ".gov.gh", ".org.gh", ".mil.gh", ".com.gn", ".ac.gn", ".gov.gn", ".org.gn", ".net.gn", ".com.gr", ".edu.gr", ".net.gr", ".org.gr", ".gov.gr", ".mil.gr", ".com.gt", ".edu.gt", ".net.gt", ".gob.gt", ".org.gt", ".mil.gt", ".ind.gt", ".com.gu", ".net.gu", ".gov.gu", ".org.gu", ".edu.gu", ".com.hk", ".edu.hk", ".gov.hk", ".idv.hk", ".net.hk", ".org.hk", ".ac.id", ".co.id", ".net.id", ".or.id", ".web.id", ".sch.id", ".mil.id", ".go.id", ".war.net.id", ".ac.il", ".co.il", ".org.il", ".net.il", ".k12.il", ".gov.il", ".muni.il", ".idf.il", ".in", ".4fd.in", ".co.in", ".firm.in", ".net.in", ".org.in", ".gen.in", ".ind.in", ".ac.in", ".edu.in", ".res.in", ".ernet.in", ".gov.in", ".mil.in", ".nic.in", ".nic.in", ".iq", ".gov.iq", ".edu.iq", ".com.iq", ".mil.iq", ".org.iq", ".net.iq", ".ir", ".ac.ir", ".co.ir", ".gov.ir", ".id.ir", ".net.ir", ".org.ir", ".sch.ir", ".dnssec.ir", ".gov.it", ".edu.it", ".co.je", ".net.je", ".org.je", ".com.jo", ".net.jo", ".gov.jo", ".edu.jo", ".org.jo", ".mil.jo", ".name.jo", ".sch.jo", ".ac.jp", ".ad.jp", ".co.jp", ".ed.jp", ".go.jp", ".gr.jp", ".lg.jp", ".ne.jp", ".or.jp", ".co.ke", ".or.ke", ".ne.ke", ".go.ke", ".ac.ke", ".sc.ke", ".me.ke", ".mobi.ke", ".info.ke", ".per.kh", ".com.kh", ".edu.kh", ".gov.kh", ".mil.kh", ".net.kh", ".org.kh", ".com.ki", ".biz.ki", ".de.ki", ".net.ki", ".info.ki", ".org.ki", ".gov.ki", ".edu.ki", ".mob.ki", ".tel.ki", ".km", ".com.km", ".coop.km", ".asso.km", ".nom.km", ".presse.km", ".tm.km", ".medecin.km", ".notaires.km", ".pharmaciens.km", ".veterinaire.km", ".edu.km", ".gouv.km", ".mil.km", ".net.kn", ".org.kn", ".edu.kn", ".gov.kn", ".kr", ".co.kr", ".ne.kr", ".or.kr", ".re.kr", ".pe.kr", ".go.kr", ".mil.kr", ".ac.kr", ".hs.kr", ".ms.kr", ".es.kr", ".sc.kr", ".kg.kr", ".seoul.kr", ".busan.kr", ".daegu.kr", ".incheon.kr", ".gwangju.kr", ".daejeon.kr", ".ulsan.kr", ".gyeonggi.kr", ".gangwon.kr", ".chungbuk.kr", ".chungnam.kr", ".jeonbuk.kr", ".jeonnam.kr", ".gyeongbuk.kr", ".gyeongnam.kr", ".jeju.kr", ".edu.kw", ".com.kw", ".net.kw", ".org.kw", ".gov.kw", ".com.ky", ".org.ky", ".net.ky", ".edu.ky", ".gov.ky", ".com.kz", ".edu.kz", ".gov.kz", ".mil.kz", ".net.kz", ".org.kz", ".com.lb", ".edu.lb", ".gov.lb", ".net.lb", ".org.lb", ".gov.lk", ".sch.lk", ".net.lk", ".int.lk", ".com.lk", ".org.lk", ".edu.lk", ".ngo.lk", ".soc.lk", ".web.lk", ".ltd.lk", ".assn.lk", ".grp.lk", ".hotel.lk", ".com.lr", ".edu.lr", ".gov.lr", ".org.lr", ".net.lr", ".com.lv", ".edu.lv", ".gov.lv", ".org.lv", ".mil.lv", ".id.lv", ".net.lv", ".asn.lv", ".conf.lv", ".com.ly", ".net.ly", ".gov.ly", ".plc.ly", ".edu.ly", ".sch.ly", ".med.ly", ".org.ly", ".id.ly", ".ma", ".net.ma", ".ac.ma", ".org.ma", ".gov.ma", ".press.ma", ".co.ma", ".tm.mc", ".asso.mc", ".co.me", ".net.me", ".org.me", ".edu.me", ".ac.me", ".gov.me", ".its.me", ".priv.me", ".org.mg", ".nom.mg", ".gov.mg", ".prd.mg", ".tm.mg", ".edu.mg", ".mil.mg", ".com.mg", ".com.mk", ".org.mk", ".net.mk", ".edu.mk", ".gov.mk", ".inf.mk", ".name.mk", ".pro.mk", ".com.ml", ".net.ml", ".org.ml", ".edu.ml", ".gov.ml", ".presse.ml", ".gov.mn", ".edu.mn", ".org.mn", ".com.mo", ".edu.mo", ".gov.mo", ".net.mo", ".org.mo", ".com.mt", ".org.mt", ".net.mt", ".edu.mt", ".gov.mt", ".aero.mv", ".biz.mv", ".com.mv", ".coop.mv", ".edu.mv", ".gov.mv", ".info.mv", ".int.mv", ".mil.mv", ".museum.mv", ".name.mv", ".net.mv", ".org.mv", ".pro.mv", ".ac.mw", ".co.mw", ".com.mw", ".coop.mw", ".edu.mw", ".gov.mw", ".int.mw", ".museum.mw", ".net.mw", ".org.mw", ".com.mx", ".net.mx", ".org.mx", ".edu.mx", ".gob.mx", ".com.my", ".net.my", ".org.my", ".gov.my", ".edu.my", ".sch.my", ".mil.my", ".name.my", ".com.nf", ".net.nf", ".arts.nf", ".store.nf", ".web.nf", ".firm.nf", ".info.nf", ".other.nf", ".per.nf", ".rec.nf", ".com.ng", ".org.ng", ".gov.ng", ".edu.ng", ".net.ng", ".sch.ng", ".name.ng", ".mobi.ng", ".biz.ng", ".mil.ng", ".gob.ni", ".co.ni", ".com.ni", ".ac.ni", ".edu.ni", ".org.ni", ".nom.ni", ".net.ni", ".mil.ni", ".com.np", ".edu.np", ".gov.np", ".org.np", ".mil.np", ".net.np", ".edu.nr", ".gov.nr", ".biz.nr", ".info.nr", ".net.nr", ".org.nr", ".com.nr", ".com.om", ".co.om", ".edu.om", ".ac.om", ".sch.om", ".gov.om", ".net.om", ".org.om", ".mil.om", ".museum.om", ".biz.om", ".pro.om", ".med.om", ".edu.pe", ".gob.pe", ".nom.pe", ".mil.pe", ".sld.pe", ".org.pe", ".com.pe", ".net.pe", ".com.ph", ".net.ph", ".org.ph", ".mil.ph", ".ngo.ph", ".i.ph", ".gov.ph", ".edu.ph", ".com.pk", ".net.pk", ".edu.pk", ".org.pk", ".fam.pk", ".biz.pk", ".web.pk", ".gov.pk", ".gob.pk", ".gok.pk", ".gon.pk", ".gop.pk", ".gos.pk", ".pwr.pl", ".com.pl", ".biz.pl", ".net.pl", ".art.pl", ".edu.pl", ".org.pl", ".ngo.pl", ".gov.pl", ".info.pl", ".mil.pl", ".waw.pl", ".warszawa.pl", ".wroc.pl", ".wroclaw.pl", ".krakow.pl", ".katowice.pl", ".poznan.pl", ".lodz.pl", ".gda.pl", ".gdansk.pl", ".slupsk.pl", ".radom.pl", ".szczecin.pl", ".lublin.pl", ".bialystok.pl", ".olsztyn.pl", ".torun.pl", ".gorzow.pl", ".zgora.pl", ".biz.pr", ".com.pr", ".edu.pr", ".gov.pr", ".info.pr", ".isla.pr", ".name.pr", ".net.pr", ".org.pr", ".pro.pr", ".est.pr", ".prof.pr", ".ac.pr", ".com.ps", ".net.ps", ".org.ps", ".edu.ps", ".gov.ps", ".plo.ps", ".sec.ps", ".co.pw", ".ne.pw", ".or.pw", ".ed.pw", ".go.pw", ".belau.pw", ".arts.ro", ".com.ro", ".firm.ro", ".info.ro", ".nom.ro", ".nt.ro", ".org.ro", ".rec.ro", ".store.ro", ".tm.ro", ".www.ro", ".co.rs", ".org.rs", ".edu.rs", ".ac.rs", ".gov.rs", ".in.rs", ".com.sb", ".net.sb", ".edu.sb", ".org.sb", ".gov.sb", ".com.sc", ".net.sc", ".edu.sc", ".gov.sc", ".org.sc", ".co.sh", ".com.sh", ".org.sh", ".gov.sh", ".edu.sh", ".net.sh", ".nom.sh", ".com.sl", ".net.sl", ".org.sl", ".edu.sl", ".gov.sl", ".gov.st", ".saotome.st", ".principe.st", ".consulado.st", ".embaixada.st", ".org.st", ".edu.st", ".net.st", ".com.st", ".store.st", ".mil.st", ".co.st", ".edu.sv", ".gob.sv", ".com.sv", ".org.sv", ".red.sv", ".co.sz", ".ac.sz", ".org.sz", ".com.tr", ".gen.tr", ".org.tr", ".biz.tr", ".info.tr", ".av.tr", ".dr.tr", ".pol.tr", ".bel.tr", ".tsk.tr", ".bbs.tr", ".k12.tr", ".edu.tr", ".name.tr", ".net.tr", ".gov.tr", ".web.tr", ".tel.tr", ".tv.tr", ".co.tt", ".com.tt", ".org.tt", ".net.tt", ".biz.tt", ".info.tt", ".pro.tt", ".int.tt", ".coop.tt", ".jobs.tt", ".mobi.tt", ".travel.tt", ".museum.tt", ".aero.tt", ".cat.tt", ".tel.tt", ".name.tt", ".mil.tt", ".edu.tt", ".gov.tt", ".edu.tw", ".gov.tw", ".mil.tw", ".com.tw", ".net.tw", ".org.tw", ".idv.tw", ".game.tw", ".ebiz.tw", ".club.tw", ".com.mu", ".gov.mu", ".net.mu", ".org.mu", ".ac.mu", ".co.mu", ".or.mu", ".ac.mz", ".co.mz", ".edu.mz", ".org.mz", ".gov.mz", ".com.na", ".co.na", ".ac.nz", ".co.nz", ".cri.nz", ".geek.nz", ".gen.nz", ".govt.nz", ".health.nz", ".iwi.nz", ".maori.nz", ".mil.nz", ".net.nz", ".org.nz", ".parliament.nz", ".school.nz", ".abo.pa", ".ac.pa", ".com.pa", ".edu.pa", ".gob.pa", ".ing.pa", ".med.pa", ".net.pa", ".nom.pa", ".org.pa", ".sld.pa", ".com.pt", ".edu.pt", ".gov.pt", ".int.pt", ".net.pt", ".nome.pt", ".org.pt", ".publ.pt", ".com.py", ".edu.py", ".gov.py", ".mil.py", ".net.py", ".org.py", ".com.qa", ".edu.qa", ".gov.qa", ".mil.qa", ".net.qa", ".org.qa", ".asso.re", ".com.re", ".nom.re", ".ac.ru", ".adygeya.ru", ".altai.ru", ".amur.ru", ".arkhangelsk.ru", ".astrakhan.ru", ".bashkiria.ru", ".belgorod.ru", ".bir.ru", ".bryansk.ru", ".buryatia.ru", ".cbg.ru", ".chel.ru", ".chelyabinsk.ru", ".chita.ru", ".chita.ru", ".chukotka.ru", ".chuvashia.ru", ".com.ru", ".dagestan.ru", ".e-burg.ru", ".edu.ru", ".gov.ru", ".grozny.ru", ".int.ru", ".irkutsk.ru", ".ivanovo.ru", ".izhevsk.ru", ".jar.ru", ".joshkar-ola.ru", ".kalmykia.ru", ".kaluga.ru", ".kamchatka.ru", ".karelia.ru", ".kazan.ru", ".kchr.ru", ".kemerovo.ru", ".khabarovsk.ru", ".khakassia.ru", ".khv.ru", ".kirov.ru", ".koenig.ru", ".komi.ru", ".kostroma.ru", ".kranoyarsk.ru", ".kuban.ru", ".kurgan.ru", ".kursk.ru", ".lipetsk.ru", ".magadan.ru", ".mari.ru", ".mari-el.ru", ".marine.ru", ".mil.ru", ".mordovia.ru", ".mosreg.ru", ".msk.ru", ".murmansk.ru", ".nalchik.ru", ".net.ru", ".nnov.ru", ".nov.ru", ".novosibirsk.ru", ".nsk.ru", ".omsk.ru", ".orenburg.ru", ".org.ru", ".oryol.ru", ".penza.ru", ".perm.ru", ".pp.ru", ".pskov.ru", ".ptz.ru", ".rnd.ru", ".ryazan.ru", ".sakhalin.ru", ".samara.ru", ".saratov.ru", ".simbirsk.ru", ".smolensk.ru", ".spb.ru", ".stavropol.ru", ".stv.ru", ".surgut.ru", ".tambov.ru", ".tatarstan.ru", ".tom.ru", ".tomsk.ru", ".tsaritsyn.ru", ".tsk.ru", ".tula.ru", ".tuva.ru", ".tver.ru", ".tyumen.ru", ".udm.ru", ".udmurtia.ru", ".ulan-ude.ru", ".vladikavkaz.ru", ".vladimir.ru", ".vladivostok.ru", ".volgograd.ru", ".vologda.ru", ".voronezh.ru", ".vrn.ru", ".vyatka.ru", ".yakutia.ru", ".yamal.ru", ".yekaterinburg.ru", ".yuzhno-sakhalinsk.ru", ".ac.rw", ".co.rw", ".com.rw", ".edu.rw", ".gouv.rw", ".gov.rw", ".int.rw", ".mil.rw", ".net.rw", ".com.sa", ".edu.sa", ".gov.sa", ".med.sa", ".net.sa", ".org.sa", ".pub.sa", ".sch.sa", ".com.sd", ".edu.sd", ".gov.sd", ".info.sd", ".med.sd", ".net.sd", ".org.sd", ".tv.sd", ".a.se", ".ac.se", ".b.se", ".bd.se", ".c.se", ".d.se", ".e.se", ".f.se", ".g.se", ".h.se", ".i.se", ".k.se", ".l.se", ".m.se", ".n.se", ".o.se", ".org.se", ".p.se", ".parti.se", ".pp.se", ".press.se", ".r.se", ".s.se", ".t.se", ".tm.se", ".u.se", ".w.se", ".x.se", ".y.se", ".z.se", ".com.sg", ".edu.sg", ".gov.sg", ".idn.sg", ".net.sg", ".org.sg", ".per.sg", ".art.sn", ".com.sn", ".edu.sn", ".gouv.sn", ".org.sn", ".perso.sn", ".univ.sn", ".com.sy", ".edu.sy", ".gov.sy", ".mil.sy", ".net.sy", ".news.sy", ".org.sy", ".ac.th", ".co.th", ".go.th", ".in.th", ".mi.th", ".net.th", ".or.th", ".ac.tj", ".biz.tj", ".co.tj", ".com.tj", ".edu.tj", ".go.tj", ".gov.tj", ".info.tj", ".int.tj", ".mil.tj", ".name.tj", ".net.tj", ".nic.tj", ".org.tj", ".test.tj", ".web.tj", ".agrinet.tn", ".com.tn", ".defense.tn", ".edunet.tn", ".ens.tn", ".fin.tn", ".gov.tn", ".ind.tn", ".info.tn", ".intl.tn", ".mincom.tn", ".nat.tn", ".net.tn", ".org.tn", ".perso.tn", ".rnrt.tn", ".rns.tn", ".rnu.tn", ".tourism.tn", ".ac.tz", ".co.tz", ".go.tz", ".ne.tz", ".or.tz", ".biz.ua", ".cherkassy.ua", ".chernigov.ua", ".chernovtsy.ua", ".ck.ua", ".cn.ua", ".co.ua", ".com.ua", ".crimea.ua", ".cv.ua", ".dn.ua", ".dnepropetrovsk.ua", ".donetsk.ua", ".dp.ua", ".edu.ua", ".gov.ua", ".if.ua", ".in.ua", ".ivano-frankivsk.ua", ".kh.ua", ".kharkov.ua", ".kherson.ua", ".khmelnitskiy.ua", ".kiev.ua", ".kirovograd.ua", ".km.ua", ".kr.ua", ".ks.ua", ".kv.ua", ".lg.ua", ".lugansk.ua", ".lutsk.ua", ".lviv.ua", ".me.ua", ".mk.ua", ".net.ua", ".nikolaev.ua", ".od.ua", ".odessa.ua", ".org.ua", ".pl.ua", ".poltava.ua", ".pp.ua", ".rovno.ua", ".rv.ua", ".sebastopol.ua", ".sumy.ua", ".te.ua", ".ternopil.ua", ".uzhgorod.ua", ".vinnica.ua", ".vn.ua", ".zaporizhzhe.ua", ".zhitomir.ua", ".zp.ua", ".zt.ua", ".ac.ug", ".co.ug", ".go.ug", ".ne.ug", ".or.ug", ".org.ug", ".sc.ug", ".ac.uk", ".bl.uk", ".british-library.uk", ".co.uk", ".cym.uk", ".gov.uk", ".govt.uk", ".icnet.uk", ".jet.uk", ".lea.uk", ".ltd.uk", ".me.uk", ".mil.uk", ".mod.uk", ".mod.uk", ".national-library-scotland.uk", ".nel.uk", ".net.uk", ".nhs.uk", ".nhs.uk", ".nic.uk", ".nls.uk", ".org.uk", ".orgn.uk", ".parliament.uk", ".parliament.uk", ".plc.uk", ".police.uk", ".sch.uk", ".scot.uk", ".soc.uk", ".4fd.us", ".dni.us", ".fed.us", ".isa.us", ".kids.us", ".nsn.us", ".com.uy", ".edu.uy", ".gub.uy", ".mil.uy", ".net.uy", ".org.uy", ".co.ve", ".com.ve", ".edu.ve", ".gob.ve", ".info.ve", ".mil.ve", ".net.ve", ".org.ve", ".web.ve", ".co.vi", ".com.vi", ".k12.vi", ".net.vi", ".org.vi", ".ac.vn", ".biz.vn", ".com.vn", ".edu.vn", ".gov.vn", ".health.vn", ".info.vn", ".int.vn", ".name.vn", ".net.vn", ".org.vn", ".pro.vn", ".co.ye", ".com.ye", ".gov.ye", ".ltd.ye", ".me.ye", ".net.ye", ".org.ye", ".plc.ye", ".ac.yu", ".co.yu", ".edu.yu", ".gov.yu", ".org.yu", ".ac.za", ".agric.za", ".alt.za", ".bourse.za", ".city.za", ".co.za", ".cybernet.za", ".db.za", ".ecape.school.za", ".edu.za", ".fs.school.za", ".gov.za", ".gp.school.za", ".grondar.za", ".iaccess.za", ".imt.za", ".inca.za", ".kzn.school.za", ".landesign.za", ".law.za", ".lp.school.za", ".mil.za", ".mpm.school.za", ".ncape.school.za", ".net.za", ".ngo.za", ".nis.za", ".nom.za", ".nw.school.za", ".olivetti.za", ".org.za", ".pix.za", ".school.za", ".tm.za", ".wcape.school.za", ".web.za", ".ac.zm", ".co.zm", ".com.zm", ".edu.zm", ".gov.zm", ".net.zm", ".org.zm", ".sch.zm") fun String.matchesDomain(queryDomain: String): Boolean { - if(queryDomain.startsWith(".")) - //TODO: Should be safe, but double verify if can't be exploited - return this.endsWith(queryDomain) || this == queryDomain.trimStart('.') + + if(queryDomain.startsWith(".")) { + + val parts = queryDomain.lowercase().split("."); + if(parts.size < 3) + throw IllegalStateException("Illegal use of wildcards on First-Level-Domain"); + if(parts.size >= 3){ + val isSLD = slds.contains("." + parts[parts.size - 2] + "." + parts[parts.size - 1]); + if(isSLD && parts.size <= 3) + throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain"); + } + + //TODO: Should be safe, but double verify if can't be exploited + return this.endsWith(queryDomain) || this == queryDomain.trimStart('.'); + } else return this == queryDomain; } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index ff7a203ad304abcfa54c1c6fa40eda8b29978fd6..92e5cdb1e729620c4706a4e49260441842b9e492 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -350,7 +350,7 @@ class Settings : FragmentedStorageFileJson() { var playback = PlaybackSettings(); @Serializable class PlaybackSettings { - @FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, 0) + @FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, -1) @DropdownFieldOptionsId(R.array.audio_languages) var primaryLanguage: Int = 0; @@ -377,7 +377,7 @@ class Settings : FragmentedStorageFileJson() { //= context.resources.getStringArray(R.array.audio_languages)[primaryLanguage]; - @FormField(R.string.default_playback_speed, FieldForm.DROPDOWN, -1, 1) + @FormField(R.string.default_playback_speed, FieldForm.DROPDOWN, -1, 0) @DropdownFieldOptionsId(R.array.playback_speeds) var defaultPlaybackSpeed: Int = 3; fun getDefaultPlaybackSpeed(): Float = when(defaultPlaybackSpeed) { @@ -393,22 +393,26 @@ class Settings : FragmentedStorageFileJson() { else -> 1.0f; }; - @FormField(R.string.preferred_quality, FieldForm.DROPDOWN, R.string.preferred_quality_description, 2) + @FormField(R.string.preferred_quality, FieldForm.DROPDOWN, R.string.preferred_quality_description, 1) @DropdownFieldOptionsId(R.array.preferred_quality_array) var preferredQuality: Int = 0; - @FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, R.string.preferred_metered_quality_description, 3) + @FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, R.string.preferred_metered_quality_description, 2) @DropdownFieldOptionsId(R.array.preferred_quality_array) var preferredMeteredQuality: Int = 0; fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality); fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality); fun getCurrentPreferredQualityPixelCount(): Int = if(!StateApp.instance.isCurrentMetered()) getPreferredQualityPixelCount() else getPreferredMeteredQualityPixelCount(); - @FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, R.string.preferred_preview_quality_description, 4) + @FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, R.string.preferred_preview_quality_description, 3) @DropdownFieldOptionsId(R.array.preferred_quality_array) var preferredPreviewQuality: Int = 5; fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality); + + @FormField(R.string.simplify_sources, FieldForm.TOGGLE, R.string.simplify_sources_description, 4) + var simplifySources: Boolean = true; + @FormField(R.string.auto_rotate, FieldForm.DROPDOWN, -1, 5) @DropdownFieldOptionsId(R.array.system_enabled_disabled_array) var autoRotate: Int = 2; @@ -466,6 +470,12 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.full_screen_portrait, FieldForm.TOGGLE, R.string.allow_full_screen_portrait, 13) var fullscreenPortrait: Boolean = false; + + + @FormField(R.string.prefer_webm, FieldForm.TOGGLE, R.string.prefer_webm_description, 14) + var preferWebmVideo: Boolean = false; + @FormField(R.string.prefer_webm_audio, FieldForm.TOGGLE, R.string.prefer_webm_audio_description, 15) + var preferWebmAudio: Boolean = false; } @FormField(R.string.comments, "group", R.string.comments_description, 6) @@ -795,10 +805,10 @@ class Settings : FragmentedStorageFileJson() { fun export() { val activity = SettingsActivity.getActivity() ?: return; UISlideOverlays.showOverlay(activity.overlay, "Select export type", null, {}, - SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", null, { + SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", tag = null, call = { StateBackup.shareExternalBackup(); }), - SlideUpMenuItem(activity, R.drawable.ic_download, "File", "", null, { + SlideUpMenuItem(activity, R.drawable.ic_download, "File", "", tag = null, call = { StateBackup.saveExternalBackup(activity); }) ) diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index f26763a40cb38ff16f5ea1d6c825dfe4658bb9ad..06f88f679d9a044ea813afb35f06dcfca037404a 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -40,7 +40,6 @@ import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StateSubscriptionGroups -import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.views.AnyAdapterView import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny import com.futo.platformplayer.views.LoaderView @@ -92,9 +91,17 @@ class UISlideOverlays { withContext(Dispatchers.Main) { items.addAll(listOf( - SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", { - subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications; - }, false), + SlideUpMenuItem( + container.context, + R.drawable.ic_notifications, + "Notifications", + "", + tag = "notifications", + call = { + subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications; + }, + invokeParent = false + ), if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty()) SlideUpMenuGroup(container.context, "Subscription Groups", "You can select which groups this subscription is part of.", @@ -129,22 +136,62 @@ class UISlideOverlays { SlideUpMenuGroup(container.context, "Fetch Settings", "Depending on the platform you might not need to enable a type for it to be available.", -1, listOf()), - if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(container.context, R.drawable.ic_live_tv, "Livestreams", "Check for livestreams", "fetchLive", { - subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive; - }, false) else null, - if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(container.context, R.drawable.ic_play, "Streams", "Check for streams", "fetchStreams", { - subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams; - }, false) else null, + if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem( + container.context, + R.drawable.ic_live_tv, + "Livestreams", + "Check for livestreams", + tag = "fetchLive", + call = { + subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive; + }, + invokeParent = false + ) else null, + if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem( + container.context, + R.drawable.ic_play, + "Streams", + "Check for streams", + tag = "fetchStreams", + call = { + subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams; + }, + invokeParent = false + ) else null, if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS)) - SlideUpMenuItem(container.context, R.drawable.ic_play, "Videos", "Check for videos", "fetchVideos", { - subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos; - }, false) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty()) - SlideUpMenuItem(container.context, R.drawable.ic_play, "Content", "Check for content", "fetchVideos", { - subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos; - }, false) else null, - if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", { - subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts; - }, false) else null/*,, + SlideUpMenuItem( + container.context, + R.drawable.ic_play, + "Videos", + "Check for videos", + tag = "fetchVideos", + call = { + subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos; + }, + invokeParent = false + ) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty()) + SlideUpMenuItem( + container.context, + R.drawable.ic_play, + "Content", + "Check for content", + tag = "fetchVideos", + call = { + subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos; + }, + invokeParent = false + ) else null, + if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem( + container.context, + R.drawable.ic_chat, + "Posts", + "Check for posts", + tag = "fetchPosts", + call = { + subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts; + }, + invokeParent = false + ) else null/*,, SlideUpMenuGroup(container.context, "Actions", "Various things you can do with this subscription", @@ -243,11 +290,23 @@ class UISlideOverlays { masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) masterPlaylist.getAudioSources().forEach { it -> - audioButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, { - selectedAudioVariant = it - slideUpMenuOverlay.selectOption(audioButtons, it) - slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) - }, false)) + + val estSize = VideoHelper.estimateSourceSize(it); + val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; + audioButtons.add(SlideUpMenuItem( + container.context, + R.drawable.ic_music, + it.name, + listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), + (prefix + it.codec).trim(), + tag = it, + call = { + selectedAudioVariant = it + slideUpMenuOverlay.selectOption(audioButtons, it) + slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) + }, + invokeParent = false + )) } /*masterPlaylist.getSubtitleSources().forEach { it -> @@ -259,11 +318,22 @@ class UISlideOverlays { }*/ masterPlaylist.getVideoSources().forEach { - videoButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, { - selectedVideoVariant = it - slideUpMenuOverlay.selectOption(videoButtons, it) - slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) - }, false)) + val estSize = VideoHelper.estimateSourceSize(it); + val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; + videoButtons.add(SlideUpMenuItem( + container.context, + R.drawable.ic_movie, + it.name, + "${it.width}x${it.height}", + (prefix + it.codec).trim(), + tag = it, + call = { + selectedVideoVariant = it + slideUpMenuOverlay.selectOption(videoButtons, it) + slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) + }, + invokeParent = false + )) } val newItems = arrayListOf<View>() @@ -342,29 +412,56 @@ class UISlideOverlays { } items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources, - listOf(listOf(SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.none), container.context.getString(R.string.audio_only), "none", { - selectedVideo = null; - menu?.selectOption(videoSources, "none"); - if(selectedAudio != null || !requiresAudio) - menu?.setOk(container.context.getString(R.string.download)); - }, false)) + + listOf(listOf(SlideUpMenuItem( + container.context, + R.drawable.ic_movie, + container.context.getString(R.string.none), + container.context.getString(R.string.audio_only), + tag = "none", + call = { + selectedVideo = null; + menu?.selectOption(videoSources, "none"); + if(selectedAudio != null || !requiresAudio) + menu?.setOk(container.context.getString(R.string.download)); + }, + invokeParent = false + )) + videoSources .filter { it.isDownloadable() } .map { when (it) { is IVideoUrlSource -> { - SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, { - selectedVideo = it - menu?.selectOption(videoSources, it); - if(selectedAudio != null || !requiresAudio) - menu?.setOk(container.context.getString(R.string.download)); - }, false) + val estSize = VideoHelper.estimateSourceSize(it); + val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; + SlideUpMenuItem( + container.context, + R.drawable.ic_movie, + it.name, + "${it.width}x${it.height}", + (prefix + it.codec).trim(), + tag = it, + call = { + selectedVideo = it + menu?.selectOption(videoSources, it); + if(selectedAudio != null || !requiresAudio) + menu?.setOk(container.context.getString(R.string.download)); + }, + invokeParent = false + ) } is IHLSManifestSource -> { - SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS", it, { - showHlsPicker(video, it, it.url, container) - }, false) + SlideUpMenuItem( + container.context, + R.drawable.ic_movie, + it.name, + "HLS", + tag = it, + call = { + showHlsPicker(video, it, it.url, container) + }, + invokeParent = false + ) } else -> { @@ -389,17 +486,36 @@ class UISlideOverlays { .map { when (it) { is IAudioUrlSource -> { - SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, { - selectedAudio = it - menu?.selectOption(audioSources, it); - menu?.setOk(container.context.getString(R.string.download)); - }, false); + val estSize = VideoHelper.estimateSourceSize(it); + val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; + SlideUpMenuItem( + container.context, + R.drawable.ic_music, + it.name, + "${it.bitrate}", + (prefix + it.codec).trim(), + tag = it, + call = { + selectedAudio = it + menu?.selectOption(audioSources, it); + menu?.setOk(container.context.getString(R.string.download)); + }, + invokeParent = false + ); } is IHLSManifestAudioSource -> { - SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS Audio", it, { - showHlsPicker(video, it, it.url, container) - }, false) + SlideUpMenuItem( + container.context, + R.drawable.ic_movie, + it.name, + "HLS Audio", + tag = it, + call = { + showHlsPicker(video, it, it.url, container) + }, + invokeParent = false + ) } else -> { @@ -417,15 +533,23 @@ class UISlideOverlays { if(contentResolver != null && subtitleSources.isNotEmpty()) { items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleSources, subtitleSources.map { - SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, { - if (selectedSubtitle == it) { - selectedSubtitle = null; - menu?.selectOption(subtitleSources, null); - } else { - selectedSubtitle = it; - menu?.selectOption(subtitleSources, it); - } - }, false); + SlideUpMenuItem( + container.context, + R.drawable.ic_edit, + it.name, + "", + tag = it, + call = { + if (selectedSubtitle == it) { + selectedSubtitle = null; + menu?.selectOption(subtitleSources, null); + } else { + selectedSubtitle = it; + menu?.selectOption(subtitleSources, it); + } + }, + invokeParent = false + ); }) ); } @@ -537,23 +661,47 @@ class UISlideOverlays { ); items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.target_resolution), "Video", resolutions.map { - SlideUpMenuItem(container.context, R.drawable.ic_movie, it.first, it.second, it.third, { - targetPxSize = it.third; - menu?.selectOption("Video", it.third); - }, false) + SlideUpMenuItem( + container.context, + R.drawable.ic_movie, + it.first, + it.second, + tag = it.third, + call = { + targetPxSize = it.third; + menu?.selectOption("Video", it.third); + }, + invokeParent = false + ) })); items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.target_bitrate), "Bitrate", listOf( - SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.low_bitrate), "", 1, { - targetBitrate = 1; - menu?.selectOption("Bitrate", 1); - menu?.setOk(container.context.getString(R.string.download)); - }, false), - SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.high_bitrate), "", 9999999, { - targetBitrate = 9999999; - menu?.selectOption("Bitrate", 9999999); - menu?.setOk(container.context.getString(R.string.download)); - }, false) + SlideUpMenuItem( + container.context, + R.drawable.ic_movie, + container.context.getString(R.string.low_bitrate), + "", + tag = 1, + call = { + targetBitrate = 1; + menu?.selectOption("Bitrate", 1); + menu?.setOk(container.context.getString(R.string.download)); + }, + invokeParent = false + ), + SlideUpMenuItem( + container.context, + R.drawable.ic_movie, + container.context.getString(R.string.high_bitrate), + "", + tag = 9999999, + call = { + targetBitrate = 9999999; + menu?.selectOption("Bitrate", 9999999); + menu?.setOk(container.context.getString(R.string.download)); + }, + invokeParent = false + ) ))); @@ -676,8 +824,12 @@ class UISlideOverlays { if (lastUpdated != null) { items.add( SlideUpMenuGroup(container.context, container.context.getString(R.string.recently_used_playlist), "recentlyusedplaylist", - SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} " + container.context.getString(R.string.videos), "", - { + SlideUpMenuItem(container.context, + R.drawable.ic_playlist_add, + lastUpdated.name, + "${lastUpdated.videos.size} " + container.context.getString(R.string.videos), + tag = "", + call = { StatePlaylists.instance.addToPlaylist(lastUpdated.id, video); StateDownloads.instance.checkForOutdatedPlaylists(); })) @@ -689,44 +841,90 @@ class UISlideOverlays { val watchLater = StatePlaylists.instance.getWatchLater(); items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions", (listOf( - SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), "download", { - showDownloadVideoOverlay(video, container, true); - }, false), - SlideUpMenuItem(container.context, R.drawable.ic_share, container.context.getString(R.string.share), "Share the video", "share", { - val url = if(video.shareUrl.isNotEmpty()) video.shareUrl else video.url; - container.context.startActivity(Intent.createChooser(Intent().apply { - action = Intent.ACTION_SEND; - putExtra(Intent.EXTRA_TEXT, url); - type = "text/plain"; - }, null)); - }, false), - SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, container.context.getString(R.string.hide_creator_from_home), "", "hide_creator", { - StateMeta.instance.addHiddenCreator(video.author.url); - UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home"); - })) + SlideUpMenuItem( + container.context, + R.drawable.ic_download, + container.context.getString(R.string.download), + container.context.getString(R.string.download_the_video), + tag = "download", + call = { + showDownloadVideoOverlay(video, container, true); + }, + invokeParent = false + ), + SlideUpMenuItem( + container.context, + R.drawable.ic_share, + container.context.getString(R.string.share), + "Share the video", + tag = "share", + call = { + val url = if(video.shareUrl.isNotEmpty()) video.shareUrl else video.url; + container.context.startActivity(Intent.createChooser(Intent().apply { + action = Intent.ACTION_SEND; + putExtra(Intent.EXTRA_TEXT, url); + type = "text/plain"; + }, null)); + }, + invokeParent = false + ), + SlideUpMenuItem( + container.context, + R.drawable.ic_visibility_off, + container.context.getString(R.string.hide_creator_from_home), + "", + tag = "hide_creator", + call = { + StateMeta.instance.addHiddenCreator(video.author.url); + UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home"); + })) + actions) )); items.add( SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto", - SlideUpMenuItem(container.context, R.drawable.ic_queue_add, container.context.getString(R.string.add_to_queue), "${queue.size} " + container.context.getString(R.string.videos), "queue", - { StatePlayer.instance.addToQueue(video); }), - SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, "${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "", "${watchLater.size} " + container.context.getString(R.string.videos), "watch later", - { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }), - SlideUpMenuItem(container.context, R.drawable.ic_history, container.context.getString(R.string.add_to_history), "Mark as watched", "history", - { StateHistory.instance.markAsWatched(video); }), + SlideUpMenuItem(container.context, + R.drawable.ic_queue_add, + container.context.getString(R.string.add_to_queue), + "${queue.size} " + container.context.getString(R.string.videos), + tag = "queue", + call = { StatePlayer.instance.addToQueue(video); }), + SlideUpMenuItem(container.context, + R.drawable.ic_watchlist_add, + "${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "", + "${watchLater.size} " + container.context.getString(R.string.videos), + tag = "watch later", + call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }), + SlideUpMenuItem(container.context, + R.drawable.ic_history, + container.context.getString(R.string.add_to_history), + "Mark as watched", + tag = "history", + call = { StateHistory.instance.markAsWatched(video); }), )); val playlistItems = arrayListOf<SlideUpMenuItem>(); - playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, container.context.getString(R.string.new_playlist), container.context.getString(R.string.add_to_new_playlist), "add_to_new_playlist", { - showCreatePlaylistOverlay(container) { - val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video))); - StatePlaylists.instance.createOrUpdatePlaylist(playlist); - }; - }, false)) + playlistItems.add(SlideUpMenuItem( + container.context, + R.drawable.ic_playlist_add, + container.context.getString(R.string.new_playlist), + container.context.getString(R.string.add_to_new_playlist), + tag = "add_to_new_playlist", + call = { + showCreatePlaylistOverlay(container) { + val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video))); + StatePlaylists.instance.createOrUpdatePlaylist(playlist); + }; + }, + invokeParent = false + )) for (playlist in allPlaylists) { - playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, "${container.context.getString(R.string.add_to)} " + playlist.name + "", "${playlist.videos.size} " + container.context.getString(R.string.videos), "", - { + playlistItems.add(SlideUpMenuItem(container.context, + R.drawable.ic_playlist_add, + "${container.context.getString(R.string.add_to)} " + playlist.name + "", + "${playlist.videos.size} " + container.context.getString(R.string.videos), + tag = "", + call = { StatePlaylists.instance.addToPlaylist(playlist.id, video); StateDownloads.instance.checkForOutdatedPlaylists(); })); @@ -748,8 +946,12 @@ class UISlideOverlays { if (lastUpdated != null) { items.add( SlideUpMenuGroup(container.context, container.context.getString(R.string.recently_used_playlist), "recentlyusedplaylist", - SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} " + container.context.getString(R.string.videos), "", - { + SlideUpMenuItem(container.context, + R.drawable.ic_playlist_add, + lastUpdated.name, + "${lastUpdated.videos.size} " + container.context.getString(R.string.videos), + tag = "", + call = { StatePlaylists.instance.addToPlaylist(lastUpdated.id, video); StateDownloads.instance.checkForOutdatedPlaylists(); })) @@ -761,25 +963,52 @@ class UISlideOverlays { val watchLater = StatePlaylists.instance.getWatchLater(); items.add( SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other", - SlideUpMenuItem(container.context, R.drawable.ic_queue_add, container.context.getString(R.string.queue), "${queue.size} " + container.context.getString(R.string.videos), "queue", - { StatePlayer.instance.addToQueue(video); }), - SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, StatePlayer.TYPE_WATCHLATER, "${watchLater.size} " + container.context.getString(R.string.videos), "watch later", - { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }), - SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), container.context.getString(R.string.download), - { showDownloadVideoOverlay(video, container, true); }, false)) + SlideUpMenuItem(container.context, + R.drawable.ic_queue_add, + container.context.getString(R.string.queue), + "${queue.size} " + container.context.getString(R.string.videos), + tag = "queue", + call = { StatePlayer.instance.addToQueue(video); }), + SlideUpMenuItem(container.context, + R.drawable.ic_watchlist_add, + StatePlayer.TYPE_WATCHLATER, + "${watchLater.size} " + container.context.getString(R.string.videos), + tag = "watch later", + call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }), + SlideUpMenuItem( + container.context, + R.drawable.ic_download, + container.context.getString(R.string.download), + container.context.getString(R.string.download_the_video), + tag = container.context.getString(R.string.download), + call = { showDownloadVideoOverlay(video, container, true); }, + invokeParent = false + )) ); val playlistItems = arrayListOf<SlideUpMenuItem>(); - playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, container.context.getString(R.string.new_playlist), container.context.getString(R.string.add_to_new_playlist), "add_to_new_playlist", { - slideUpMenuOverlayUpdated(showCreatePlaylistOverlay(container) { - val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video))); - StatePlaylists.instance.createOrUpdatePlaylist(playlist); - }); - }, false)) + playlistItems.add(SlideUpMenuItem( + container.context, + R.drawable.ic_playlist_add, + container.context.getString(R.string.new_playlist), + container.context.getString(R.string.add_to_new_playlist), + tag = "add_to_new_playlist", + call = { + slideUpMenuOverlayUpdated(showCreatePlaylistOverlay(container) { + val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video))); + StatePlaylists.instance.createOrUpdatePlaylist(playlist); + }); + }, + invokeParent = false + )) for (playlist in allPlaylists) { - playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, playlist.name, "${playlist.videos.size} " + container.context.getString(R.string.videos), "", - { + playlistItems.add(SlideUpMenuItem(container.context, + R.drawable.ic_playlist_add, + playlist.name, + "${playlist.videos.size} " + container.context.getString(R.string.videos), + tag = "", + call = { StatePlaylists.instance.addToPlaylist(playlist.id, video); StateDownloads.instance.checkForOutdatedPlaylists(); })); @@ -804,20 +1033,36 @@ class UISlideOverlays { val views = arrayOf( hidden - .map { btn -> SlideUpMenuItem(container.context, btn.iconResource, btn.text.text.toString(), "", "", { - btn.handler?.invoke(btn); - }, invokeParents) as View }.toTypedArray(), - arrayOf(SlideUpMenuItem(container.context, R.drawable.ic_pin, container.context.getString(R.string.change_pins), container.context.getString(R.string.decide_which_buttons_should_be_pinned), "", { - showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) { - val selected = it - .map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } } - .filter { it != null } - .map { it!! } - .toList(); - - onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) }); - } - }, false)) + .map { btn -> SlideUpMenuItem( + container.context, + btn.iconResource, + btn.text.text.toString(), + "", + tag = "", + call = { + btn.handler?.invoke(btn); + }, + invokeParent = invokeParents + ) as View }.toTypedArray(), + arrayOf(SlideUpMenuItem( + container.context, + R.drawable.ic_pin, + container.context.getString(R.string.change_pins), + container.context.getString(R.string.decide_which_buttons_should_be_pinned), + tag = "", + call = { + showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) { + val selected = it + .map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } } + .filter { it != null } + .map { it!! } + .toList(); + + onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) }); + } + }, + invokeParent = false + )) ).flatten().toTypedArray(); return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.more_options), null, true, *views).apply { show() }; @@ -829,14 +1074,21 @@ class UISlideOverlays { var overlay: SlideUpMenuOverlay? = null; overlay = SlideUpMenuOverlay(container.context, container, title, container.context.getString(R.string.save), true, - options.map { SlideUpMenuItem(container.context, R.drawable.ic_move_up, it.first, "", it.second, { + options.map { SlideUpMenuItem( + container.context, + R.drawable.ic_move_up, + it.first, + "", + tag = it.second, + call = { if(overlay!!.selectOption(null, it.second, true, true)) { if(!selection.contains(it.second)) selection.add(it.second); - } - else + } else selection.remove(it.second); - }, false) + }, + invokeParent = false + ) }); overlay.onOK.subscribe { onOrdered.invoke(selection); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/chapters/IChapter.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/chapters/IChapter.kt index 5d7a7c7fbaafa305a4cbe1106ad6b74e3db73e9a..4ed5add4ea463a0e5616e573bbe59a344be9687d 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/chapters/IChapter.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/chapters/IChapter.kt @@ -23,7 +23,7 @@ enum class ChapterType(val value: Int) { companion object { fun fromInt(value: Int): ChapterType { - val result = ChapterType.values().firstOrNull { it.value == value }; + val result = ChapterType.entries.firstOrNull { it.value == value }; if(result == null) throw UnknownPlatformException(value.toString()); return result; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt index 27d51abec0bcaee13d0f526a1a3024d459b75b56..a310e0899bd5b017aa6f789bf6903b717d44183a 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt @@ -21,7 +21,7 @@ enum class ContentType(val value: Int) { companion object { fun fromInt(value: Int): ContentType { - val result = ContentType.values().firstOrNull { it.value == value }; + val result = ContentType.entries.firstOrNull { it.value == value }; if(result == null) throw UnknownPlatformException(value.toString()); return result; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventType.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventType.kt index ddec2e0ac2afbb3fccdae1a527ef36c21e8b417e..c2f9bc4ad34228631178978ad75a8790ca229215 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventType.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventType.kt @@ -10,7 +10,7 @@ enum class LiveEventType(val value : Int) { companion object{ fun fromInt(value : Int) : LiveEventType{ - return LiveEventType.values().first { it.value == value }; + return LiveEventType.entries.first { it.value == value }; } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt index 8a2c20d404cbd8c7aadc6c72e2dd0d741feb482e..c1de57d1c7453254c01834fe1aa5b935bf917a50 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt @@ -10,7 +10,7 @@ enum class TextType(val value: Int) { companion object { fun fromInt(value: Int): TextType { - val result = TextType.values().firstOrNull { it.value == value }; + val result = TextType.entries.firstOrNull { it.value == value }; if(result == null) throw IllegalArgumentException("Unknown Texttype: $value"); return result; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingType.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingType.kt index eba214301baf1e3cf71806dbea281921645ab7eb..956b9d31a9fcb4faf7be76e3c6c4e93e3f581bf1 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingType.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingType.kt @@ -8,7 +8,7 @@ enum class RatingType(val value : Int) { companion object{ fun fromInt(value : Int) : RatingType{ - return RatingType.values().first { it.value == value }; + return RatingType.entries.first { it.value == value }; } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClientConstants.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClientConstants.kt new file mode 100644 index 0000000000000000000000000000000000000000..e5c950472cff9278d116102c6e5f7d17d03e57d3 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClientConstants.kt @@ -0,0 +1,7 @@ +package com.futo.platformplayer.api.media.platforms.js + +class JSClientConstants { + companion object { + val PLUGIN_SPEC_VERSION = 2; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt index 23a7a7618f4739ccff17a607b64fa2ce518b2c52..3d57cb41e0946aea9b168284e11f48d6eb1d024e 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt @@ -4,6 +4,7 @@ import android.net.Uri import com.futo.platformplayer.SignatureProvider import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.matchesDomain import com.futo.platformplayer.states.StatePlugins import kotlinx.serialization.Contextual import java.net.URL @@ -79,7 +80,7 @@ class SourcePluginConfig( private val _allowUrlsLower: List<String> get() { if(_allowUrlsLowerVal == null) _allowUrlsLowerVal = allowUrls.map { it.lowercase() } - .filter { it.length > 0 && (it[0] != '*' || (_allowRegex.matches(it))) }; + .filter { it.length > 0 }; return _allowUrlsLowerVal!!; }; @@ -172,12 +173,10 @@ class SourcePluginConfig( return true; val uri = Uri.parse(url); val host = uri.host?.lowercase() ?: ""; - return _allowUrlsLower.any { it == host || (it.length > 0 && it[0] == '*' && host.endsWith(it.substring(1))) }; + return _allowUrlsLower.any { it == host || (it.length > 0 && it[0] == '.' && host.matchesDomain(it)) }; } companion object { - private val _allowRegex = Regex("\\*\\.[a-z0-9]+\\.[a-z]+"); - fun fromJson(json: String, sourceUrl: String? = null): SourcePluginConfig { val obj = Serializer.json.decodeFromString<SourcePluginConfig>(json); if(obj.sourceUrl == null) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt index 6127601c7af7d207f29353aeecd7bbb51feb690b..b3dd9dd52aaa257144c1a882443be92740270b0f 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt @@ -54,4 +54,8 @@ open class JSContent : IPlatformContent, IPluginSourced { _hasGetDetails = _content.has("getDetails"); } + + fun getUnderlyingObject(): V8ValueObject? { + return _content; + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt new file mode 100644 index 0000000000000000000000000000000000000000..a9ab2c1a7555852df3de4a3a4bcdffde8437a012 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt @@ -0,0 +1,134 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.V8Value +import com.caoccao.javet.values.primitive.V8ValueString +import com.caoccao.javet.values.primitive.V8ValueUndefined +import com.caoccao.javet.values.reference.V8ValueObject +import com.caoccao.javet.values.reference.V8ValueTypedArray +import com.futo.platformplayer.api.media.platforms.js.DevJSClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.engine.exceptions.PluginException +import com.futo.platformplayer.engine.exceptions.ScriptException +import com.futo.platformplayer.engine.exceptions.ScriptImplementationException +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateDeveloper +import kotlinx.serialization.Serializable +import java.util.Base64 + +class JSRequestExecutor { + private val _plugin: JSClient; + private val _config: IV8PluginConfig; + private var _executor: V8ValueObject; + val urlPrefix: String?; + + private val hasCleanup: Boolean; + + constructor(plugin: JSClient, executor: V8ValueObject) { + this._plugin = plugin; + this._executor = executor; + this._config = plugin.config; + val config = plugin.config; + + urlPrefix = executor.getOrDefault(config, "urlPrefix", "RequestExecutor", null); + + if(!executor.has("executeRequest")) + throw ScriptImplementationException(config, "RequestExecutor is missing executeRequest", null); + hasCleanup = executor.has("cleanup"); + } + + //TODO: Executor properties? + @Throws(ScriptException::class) + open fun executeRequest(url: String, headers: Map<String, String>): ByteArray { + if (_executor.isClosed) + throw IllegalStateException("Executor object is closed"); + + val result = if(_plugin is DevJSClient) + StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") { + V8Plugin.catchScriptErrors<Any>( + _config, + "[${_config.name}] JSRequestExecutor", + "builder.modifyRequest()" + ) { + _executor.invoke("executeRequest", url, headers); + } as V8Value; + } + else V8Plugin.catchScriptErrors<Any>( + _config, + "[${_config.name}] JSRequestExecutor", + "builder.modifyRequest()" + ) { + _executor.invoke("executeRequest", url, headers); + } as V8Value; + + try { + if(result is V8ValueString) { + val base64Result = Base64.getDecoder().decode(result.value); + return base64Result; + } + if(result is V8ValueTypedArray) { + val buffer = result.buffer; + val byteBuffer = buffer.byteBuffer; + val bytesResult = ByteArray(result.byteLength); + byteBuffer.get(bytesResult, 0, result.byteLength); + buffer.close(); + return bytesResult; + } + if(result is V8ValueObject && result.has("type")) { + val type = result.getOrThrow<Int>(_config, "type", "JSRequestModifier"); + when(type) { + //TODO: Buffer type? + } + } + if(result is V8ValueUndefined) { + if(_plugin is DevJSClient) + StateDeveloper.instance.logDevException(_plugin.devID, "JSRequestExecutor.executeRequest returned illegal undefined"); + throw ScriptImplementationException(_config, "JSRequestExecutor.executeRequest returned illegal undefined", null); + } + throw NotImplementedError("Executor result type not implemented? " + result.javaClass.name); + } + finally { + result.close(); + } + } + + + open fun cleanup() { + if (!hasCleanup || _executor.isClosed) + return; + + if(_plugin is DevJSClient) + StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") { + V8Plugin.catchScriptErrors<Any>( + _config, + "[${_config.name}] JSRequestExecutor", + "builder.modifyRequest()" + ) { + _executor.invokeVoid("cleanup", null); + }; + } + else V8Plugin.catchScriptErrors<Any>( + _config, + "[${_config.name}] JSRequestExecutor", + "builder.modifyRequest()" + ) { + _executor.invokeVoid("cleanup", null); + }; + } + + protected fun finalize() { + cleanup(); + } +} + +//TODO: are these available..? +@Serializable +class ExecutorParameters { + var rangeStart: Int = -1; + var rangeEnd: Int = -1; + + var segment: Int = -1; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt new file mode 100644 index 0000000000000000000000000000000000000000..e95e74367e459d7f3e390f7d14e734a6fe19887a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt @@ -0,0 +1,64 @@ +package com.futo.platformplayer.api.media.platforms.js.models.sources + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource +import com.futo.platformplayer.api.media.platforms.js.DevJSClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrNull +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.others.Language +import com.futo.platformplayer.states.StateDeveloper + +class JSDashManifestRawAudioSource : JSSource, IAudioSource { + override val container : String = "application/dash+xml"; + override val name : String; + override val codec: String; + override val bitrate: Int; + override val duration: Long; + override val priority: Boolean; + + override val language: String; + + val url: String; + var manifest: String?; + + val hasGenerate: Boolean; + + constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) { + val contextName = "DashRawSource"; + val config = plugin.config; + name = _obj.getOrThrow(config, "name", contextName); + url = _obj.getOrThrow(config, "url", contextName); + manifest = _obj.getOrThrow(config, "manifest", contextName); + codec = _obj.getOrDefault(config, "codec", contextName, "") ?: ""; + bitrate = _obj.getOrDefault(config, "bitrate", contextName, 0) ?: 0; + duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0; + priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false; + language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN; + hasGenerate = _obj.has("generate"); + } + + fun generate(): String? { + if(!hasGenerate) + return manifest; + if(_obj.isClosed) + throw IllegalStateException("Source object already closed"); + + val plugin = _plugin.getUnderlyingPlugin(); + if(_plugin is DevJSClient) + return StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRaw", false) { + _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") { + _obj.invokeString("generate"); + } + } + else + return _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") { + _obj.invokeString("generate"); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawSource.kt new file mode 100644 index 0000000000000000000000000000000000000000..006df8cdd47e24f10bad099b0ed21c13c1546f74 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawSource.kt @@ -0,0 +1,109 @@ +package com.futo.platformplayer.api.media.platforms.js.models.sources + +import com.caoccao.javet.values.V8Value +import com.caoccao.javet.values.primitive.V8ValueString +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource +import com.futo.platformplayer.api.media.platforms.js.DevJSClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrNull +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.states.StateDeveloper + +open class JSDashManifestRawSource: JSSource, IVideoSource { + override val container : String = "application/dash+xml"; + override val name : String; + override val width: Int; + override val height: Int; + override val codec: String; + override val bitrate: Int?; + override val duration: Long; + override val priority: Boolean; + + var url: String?; + var manifest: String?; + + val hasGenerate: Boolean; + val canMerge: Boolean; + + constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) { + val contextName = "DashRawSource"; + val config = plugin.config; + name = _obj.getOrThrow(config, "name", contextName); + url = _obj.getOrThrow(config, "url", contextName); + manifest = _obj.getOrDefault<String>(config, "manifest", contextName, null); + width = _obj.getOrDefault(config, "width", contextName, 0) ?: 0; + height = _obj.getOrDefault(config, "height", contextName, 0) ?: 0; + codec = _obj.getOrDefault(config, "codec", contextName, "") ?: ""; + bitrate = _obj.getOrDefault(config, "bitrate", contextName, 0) ?: 0; + duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0; + priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false; + canMerge = _obj.getOrDefault(config, "canMerge", contextName, false) ?: false; + hasGenerate = _obj.has("generate"); + } + + open fun generate(): String? { + if(!hasGenerate) + return manifest; + if(_obj.isClosed) + throw IllegalStateException("Source object already closed"); + if(_plugin is DevJSClient) { + return StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRawSource.generate()") { + _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", { + _obj.invokeString("generate"); + }); + } + } + else + return _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", { + _obj.invokeString("generate"); + }); + } +} + +class JSDashManifestMergingRawSource( + val video: JSDashManifestRawSource, + val audio: JSDashManifestRawAudioSource): JSDashManifestRawSource(video.getUnderlyingPlugin()!!, video.getUnderlyingObject()!!), IVideoSource { + + override val name: String + get() = video.name; + override val bitrate: Int + get() = (video.bitrate ?: 0) + audio.bitrate; + override val codec: String + get() = video.codec + override val container: String + get() = video.container + override val duration: Long + get() = video.duration; + override val height: Int + get() = video.height; + override val width: Int + get() = video.width; + override val priority: Boolean + get() = video.priority; + + override fun generate(): String? { + val videoDash = video.generate(); + val audioDash = audio.generate(); + if(videoDash != null && audioDash == null) return videoDash; + if(audioDash != null && videoDash == null) return audioDash; + if(videoDash == null) return null; + + //TODO: Temporary simple solution..make more reliable version + val audioAdaptationSet = adaptationSetRegex.find(audioDash!!); + if(audioAdaptationSet != null) { + return videoDash.replace("</AdaptationSet>", "</AdaptationSet>\n" + audioAdaptationSet.value) + } + else + return videoDash; + } + + companion object { + private val adaptationSetRegex = Regex("<AdaptationSet.*?>.*?<\\/AdaptationSet>", RegexOption.DOT_MATCHES_ALL); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt index 862a53a5352e84ab8375c241180ac9b3aa2a6bfa..a658f5cbd7ea4552665054b472dec38bd6cba267 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt @@ -10,10 +10,12 @@ import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.models.JSRequest +import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor import com.futo.platformplayer.api.media.platforms.js.models.JSRequestModifier import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.orNull import com.futo.platformplayer.views.video.datasources.JSHttpDataSource @@ -21,9 +23,17 @@ abstract class JSSource { protected val _plugin: JSClient; protected val _config: IV8PluginConfig; protected val _obj: V8ValueObject; + val hasRequestModifier: Boolean; private val _requestModifier: JSRequest?; + val hasRequestExecutor: Boolean; + private val _requestExecutor: JSRequest?; + + val requiresCustomDatasource: Boolean get() { + return hasRequestModifier || hasRequestExecutor; + } + val type : String; constructor(type: String, plugin: JSClient, obj: V8ValueObject) { @@ -36,6 +46,11 @@ abstract class JSSource { JSRequest(plugin, it, null, null, true); } hasRequestModifier = _requestModifier != null || obj.has("getRequestModifier"); + + _requestExecutor = obj.getOrDefault<V8ValueObject>(_config, "requestExecutor", "JSSource.requestExecutor", null)?.let { + JSRequest(plugin, it, null, null, true); + } + hasRequestExecutor = _requestModifier != null || obj.has("getRequestExecutor"); } fun getRequestModifier(): IRequestModifier? { @@ -44,20 +59,38 @@ abstract class JSSource { return@AdhocRequestModifier _requestModifier.modify(_plugin, url, headers); }; - if (!hasRequestModifier || _obj.isClosed) { + if (!hasRequestModifier || _obj.isClosed) return null; - } val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSVideoUrlSource", "obj.getRequestModifier()") { _obj.invoke("getRequestModifier", arrayOf<Any>()); }; - if (result !is V8ValueObject) { + if (result !is V8ValueObject) return null; - } return JSRequestModifier(_plugin, result) } + open fun getRequestExecutor(): JSRequestExecutor? { + if (!hasRequestExecutor || _obj.isClosed) + return null; + + val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSSource", "obj.getRequestExecutor()") { + _obj.invoke("getRequestExecutor", arrayOf<Any>()); + }; + + if (result !is V8ValueObject) + return null; + + return JSRequestExecutor(_plugin, result) + } + + fun getUnderlyingPlugin(): JSClient? { + return _plugin; + } + fun getUnderlyingObject(): V8ValueObject? { + return _obj; + } companion object { const val TYPE_AUDIOURL = "AudioUrlSource"; @@ -65,33 +98,45 @@ abstract class JSSource { const val TYPE_AUDIO_WITH_METADATA = "AudioUrlRangeSource"; const val TYPE_VIDEO_WITH_METADATA = "VideoUrlRangeSource"; const val TYPE_DASH = "DashSource"; + const val TYPE_DASH_RAW = "DashRawSource"; + const val TYPE_DASH_RAW_AUDIO = "DashRawAudioSource"; const val TYPE_HLS = "HLSSource"; const val TYPE_AUDIOURL_WIDEVINE = "AudioUrlWidevineSource" fun fromV8VideoNullable(plugin: JSClient, obj: V8Value?) : IVideoSource? = obj.orNull { fromV8Video(plugin, it as V8ValueObject) }; - fun fromV8Video(plugin: JSClient, obj: V8ValueObject) : IVideoSource { + fun fromV8Video(plugin: JSClient, obj: V8ValueObject) : IVideoSource? { val type = obj.getString("plugin_type"); return when(type) { TYPE_VIDEOURL -> JSVideoUrlSource(plugin, obj); TYPE_VIDEO_WITH_METADATA -> JSVideoUrlRangeSource(plugin, obj); TYPE_HLS -> fromV8HLS(plugin, obj); TYPE_DASH -> fromV8Dash(plugin, obj); - else -> throw NotImplementedError("Unknown type ${type}"); + TYPE_DASH_RAW -> fromV8DashRaw(plugin, obj); + else -> { + Logger.w("JSSource", "Unknown video type ${type}"); + null; + }; } } fun fromV8DashNullable(plugin: JSClient, obj: V8Value?) : JSDashManifestSource? = obj.orNull { fromV8Dash(plugin, it as V8ValueObject) }; fun fromV8Dash(plugin: JSClient, obj: V8ValueObject) : JSDashManifestSource = JSDashManifestSource(plugin, obj); + fun fromV8DashRaw(plugin: JSClient, obj: V8ValueObject) : JSDashManifestRawSource = JSDashManifestRawSource(plugin, obj); + fun fromV8DashRawAudio(plugin: JSClient, obj: V8ValueObject) : JSDashManifestRawAudioSource = JSDashManifestRawAudioSource(plugin, obj); fun fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestSource? = obj.orNull { fromV8HLS(plugin, it as V8ValueObject) }; fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestSource = JSHLSManifestSource(plugin, obj); - fun fromV8Audio(plugin: JSClient, obj: V8ValueObject) : IAudioSource { + fun fromV8Audio(plugin: JSClient, obj: V8ValueObject) : IAudioSource? { val type = obj.getString("plugin_type"); return when(type) { TYPE_HLS -> JSHLSManifestAudioSource.fromV8HLS(plugin, obj); TYPE_AUDIOURL -> JSAudioUrlSource(plugin, obj); + TYPE_DASH_RAW_AUDIO -> fromV8DashRawAudio(plugin, obj); TYPE_AUDIOURL_WIDEVINE -> JSAudioUrlWidevineSource(plugin, obj); TYPE_AUDIO_WITH_METADATA -> JSAudioUrlRangeSource(plugin, obj); - else -> throw NotImplementedError("Unknown type ${type}"); + else -> { + Logger.w("JSSource", "Unknown audio type ${type}"); + null; + }; } } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSUnMuxVideoSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSUnMuxVideoSourceDescriptor.kt index 035f5fb64d13699aaf25335088981a3bf36964f0..548d27617e55f48ade8995df2180726c6493bd1b 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSUnMuxVideoSourceDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSUnMuxVideoSourceDescriptor.kt @@ -23,9 +23,11 @@ class JSUnMuxVideoSourceDescriptor: VideoUnMuxedSourceDescriptor { this.isUnMuxed = obj.getOrThrow(config, "isUnMuxed", contextName); this.videoSources = obj.getOrThrow<V8ValueArray>(config, "videoSources", contextName).toArray() .map { JSSource.fromV8Video(plugin, it as V8ValueObject) } + .filterNotNull() .toTypedArray(); this.audioSources = obj.getOrThrow<V8ValueArray>(config, "audioSources", contextName).toArray() .map { JSSource.fromV8Audio(plugin, it as V8ValueObject) } + .filterNotNull() .toTypedArray(); } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoSourceDescriptor.kt index 4b983ef71783837f329054a6380eef884d3e3dc1..e68f0ae04a7f5cbc9ec9d011e9073b19e6ade1bc 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoSourceDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoSourceDescriptor.kt @@ -21,6 +21,7 @@ class JSVideoSourceDescriptor : VideoMuxedSourceDescriptor { this.isUnMuxed = obj.getOrThrow(config, "isUnMuxed", contextName); this.videoSources = obj.getOrThrow<V8ValueArray>(config, "videoSources", contextName).toArray() .map { JSSource.fromV8Video(plugin, it as V8ValueObject) } + .filterNotNull() .toTypedArray(); } diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index 5e99462e173456efeeb823f3cc53aebfa9fa3d4a..c0301d40c4134a705653b9e72ae1c90e7c37dd22 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -31,6 +31,8 @@ import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.exceptions.UnsupportedCastException import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.mdns.DnsService +import com.futo.platformplayer.mdns.ServiceDiscoverer import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.states.StateApp @@ -45,15 +47,10 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import java.net.InetAddress import java.util.UUID -import javax.jmdns.JmDNS -import javax.jmdns.ServiceEvent -import javax.jmdns.ServiceListener -import javax.jmdns.ServiceTypeListener class StateCasting { private val _scopeIO = CoroutineScope(Dispatchers.IO); private val _scopeMain = CoroutineScope(Dispatchers.Main); - private var _jmDNS: JmDNS? = null; private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get(); private val _castServer = ManagedHttpServer(9999); @@ -72,102 +69,46 @@ class StateCasting { var activeDevice: CastingDevice? = null; private val _client = ManagedHttpClient(); var _resumeCastingDevice: CastingDeviceInfo? = null; + val _serviceDiscoverer = ServiceDiscoverer(arrayOf( + "_googlecast._tcp.local", + "_airplay._tcp.local", + "_fastcast._tcp.local", + "_fcast._tcp.local" + )) { handleServiceUpdated(it) } val isCasting: Boolean get() = activeDevice != null; - private val _chromecastServiceListener = object : ServiceListener { - override fun serviceAdded(event: ServiceEvent) { - Logger.i(TAG, "ChromeCast service added: " + event.info); - addOrUpdateDevice(event); - } - - override fun serviceRemoved(event: ServiceEvent) { - Logger.i(TAG, "ChromeCast service removed: " + event.info); - synchronized(devices) { - val device = devices[event.info.name]; - if (device != null) { - onDeviceRemoved.emit(device); + private fun handleServiceUpdated(services: List<DnsService>) { + for (s in services) { + //TODO: Addresses IPv4 only? + val addresses = s.addresses.toTypedArray() + val port = s.port.toInt() + var name = s.texts.firstOrNull { it.startsWith("md=") }?.substring("md=".length) + if (s.name.endsWith("._googlecast._tcp.local")) { + if (name == null) { + name = s.name.substring(0, s.name.length - "._googlecast._tcp.local".length) } - } - } - override fun serviceResolved(event: ServiceEvent) { - Logger.v(TAG, "ChromeCast service resolved: " + event.info); - addOrUpdateDevice(event); - } - - fun addOrUpdateDevice(event: ServiceEvent) { - addOrUpdateChromeCastDevice(event.info.name, event.info.inetAddresses, event.info.port); - } - } - - private val _airPlayServiceListener = object : ServiceListener { - override fun serviceAdded(event: ServiceEvent) { - Logger.i(TAG, "AirPlay service added: " + event.info); - addOrUpdateDevice(event); - } - - override fun serviceRemoved(event: ServiceEvent) { - Logger.i(TAG, "AirPlay service removed: " + event.info); - synchronized(devices) { - val device = devices[event.info.name]; - if (device != null) { - onDeviceRemoved.emit(device); + addOrUpdateChromeCastDevice(name, addresses, port) + } else if (s.name.endsWith("._airplay._tcp.local")) { + if (name == null) { + name = s.name.substring(0, s.name.length - "._airplay._tcp.local".length) } - } - } - - override fun serviceResolved(event: ServiceEvent) { - Logger.i(TAG, "AirPlay service resolved: " + event.info); - addOrUpdateDevice(event); - } - fun addOrUpdateDevice(event: ServiceEvent) { - addOrUpdateAirPlayDevice(event.info.name, event.info.inetAddresses, event.info.port); - } - } - - private val _fastCastServiceListener = object : ServiceListener { - override fun serviceAdded(event: ServiceEvent) { - Logger.i(TAG, "FastCast service added: " + event.info); - addOrUpdateDevice(event); - } - - override fun serviceRemoved(event: ServiceEvent) { - Logger.i(TAG, "FastCast service removed: " + event.info); - synchronized(devices) { - val device = devices[event.info.name]; - if (device != null) { - onDeviceRemoved.emit(device); + addOrUpdateAirPlayDevice(name, addresses, port) + } else if (s.name.endsWith("._fastcast._tcp.local")) { + if (name == null) { + name = s.name.substring(0, s.name.length - "._fastcast._tcp.local".length) } - } - } - - override fun serviceResolved(event: ServiceEvent) { - Logger.i(TAG, "FastCast service resolved: " + event.info); - addOrUpdateDevice(event); - } - - fun addOrUpdateDevice(event: ServiceEvent) { - addOrUpdateFastCastDevice(event.info.name, event.info.inetAddresses, event.info.port); - } - } - - private val _serviceTypeListener = object : ServiceTypeListener { - override fun serviceTypeAdded(event: ServiceEvent?) { - if (event == null) { - return; - } - Logger.i(TAG, "Service type added (name: ${event.name}, type: ${event.type})"); - } + addOrUpdateFastCastDevice(name, addresses, port) + } else if (s.name.endsWith("._fcast._tcp.local")) { + if (name == null) { + name = s.name.substring(0, s.name.length - "._fcast._tcp.local".length) + } - override fun subTypeForServiceTypeAdded(event: ServiceEvent?) { - if (event == null) { - return; + addOrUpdateFastCastDevice(name, addresses, port) } - - Logger.i(TAG, "Sub type for service type added (name: ${event.name}, type: ${event.type})"); } } @@ -237,29 +178,30 @@ class StateCasting { rememberedDevices.clear(); rememberedDevices.addAll(_storage.deviceInfos.map { deviceFromCastingDeviceInfo(it) }); - _scopeIO.launch { - try { - val jmDNS = JmDNS.create(InetAddress.getLocalHost()); - jmDNS.addServiceListener("_googlecast._tcp.local.", _chromecastServiceListener); - jmDNS.addServiceListener("_airplay._tcp.local.", _airPlayServiceListener); - jmDNS.addServiceListener("_fastcast._tcp.local.", _fastCastServiceListener); - jmDNS.addServiceListener("_fcast._tcp.local.", _fastCastServiceListener); - - if (BuildConfig.DEBUG) { - jmDNS.addServiceTypeListener(_serviceTypeListener); - } - - _jmDNS = jmDNS; - } catch (e: Throwable) { - Logger.e(TAG, "Failed to start casting service.", e); - } - } _castServer.start(); enableDeveloper(true); Logger.i(TAG, "CastingService started."); } + @Synchronized + fun startDiscovering() { + try { + _serviceDiscoverer.start() + } catch (e: Throwable) { + Logger.i(TAG, "Failed to start ServiceDiscoverer", e) + } + } + + @Synchronized + fun stopDiscovering() { + try { + _serviceDiscoverer.stop() + } catch (e: Throwable) { + Logger.i(TAG, "Failed to stop ServiceDiscoverer", e) + } + } + @Synchronized fun stop() { if (!_started) @@ -269,25 +211,7 @@ class StateCasting { Logger.i(TAG, "CastingService stopping.") - val jmDNS = _jmDNS; - if (jmDNS != null) { - _scopeIO.launch { - try { - jmDNS.removeServiceListener("_googlecast._tcp.local.", _chromecastServiceListener); - jmDNS.removeServiceListener("_airplay._tcp", _airPlayServiceListener); - jmDNS.removeServiceListener("_fastcast._tcp.local.", _fastCastServiceListener); - - if (BuildConfig.DEBUG) { - jmDNS.removeServiceTypeListener(_serviceTypeListener); - } - - jmDNS.close(); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to stop mDNS.", e); - } - } - } - + stopDiscovering() _scopeIO.cancel(); _scopeMain.cancel(); @@ -1245,7 +1169,7 @@ class StateCasting { } } else { val newDevice = deviceFactory(); - devices[name] = newDevice; + this.devices[name] = newDevice; invokeEvents = { onDeviceAdded.emit(newDevice); diff --git a/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt b/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt index e386681c244d8103c9cf29321ca338b5b363cca5..acb57b789e975ec59e5426ebff8b8b48d74e04f7 100644 --- a/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt +++ b/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt @@ -25,10 +25,8 @@ import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StatePlatform import com.google.gson.ExclusionStrategy import com.google.gson.FieldAttributes -import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.JsonArray -import com.google.gson.JsonElement import com.google.gson.JsonParser import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -573,7 +571,7 @@ class DeveloperEndpoints(private val context: Context) { val resp = _client.get(body.url!!, body.headers); context.respondCode(200, - Json.encodeToString(PackageHttp.BridgeHttpResponse(resp.url, resp.code, resp.body?.string())), + Json.encodeToString(PackageHttp.BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string())), context.query.getOrDefault("CT", "text/plain")); } catch(ex: Exception) { diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt index e9e826fec627d7d9ccedfc8772c06b3bdf26529c..bd5da2eabb7b881aa991a4c66485934aa2aefacd 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -104,6 +104,8 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { super.show(); Logger.i(TAG, "Dialog shown."); + StateCasting.instance.startDiscovering() + (_imageLoader.drawable as Animatable?)?.start(); _devices.clear(); @@ -169,6 +171,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { (_imageLoader.drawable as Animatable?)?.stop(); + StateCasting.instance.stopDiscovering() StateCasting.instance.onDeviceAdded.remove(this); StateCasting.instance.onDeviceChanged.remove(this); StateCasting.instance.onDeviceRemoved.remove(this); diff --git a/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt b/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt index 432bb11413006bb25e8ebeb00a6299baa378ab5b..9c12eb932f471bb5a2f94d902f8541047348b850 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt @@ -6,6 +6,8 @@ import com.caoccao.javet.exceptions.JavetException import com.caoccao.javet.exceptions.JavetExecutionException import com.caoccao.javet.interop.V8Host import com.caoccao.javet.interop.V8Runtime +import com.caoccao.javet.interop.options.V8Flags +import com.caoccao.javet.interop.options.V8RuntimeOptions import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.primitive.V8ValueBoolean import com.caoccao.javet.values.primitive.V8ValueInteger @@ -133,9 +135,10 @@ class V8Plugin { synchronized(_runtimeLock) { if (_runtime != null) return; - + //V8RuntimeOptions.V8_FLAGS.setUseStrict(true); val host = V8Host.getV8Instance(); val options = host.jsRuntimeType.getRuntimeOptions(); + _runtime = host.createV8Runtime(options); if (!host.isIsolateCreated) throw IllegalStateException("Isolate not created"); diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt index c26885d9019faa390dc255ae415efda1287730ae..027fd4b8aecacfae20f62a99ac165f8ec5a6aab9 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt @@ -2,12 +2,14 @@ package com.futo.platformplayer.engine.packages import com.caoccao.javet.annotations.V8Function import com.caoccao.javet.annotations.V8Property +import com.caoccao.javet.values.V8Value import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.JSClientConstants import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient import com.futo.platformplayer.engine.IV8PluginConfig @@ -49,6 +51,16 @@ class PackageBridge : V8Package { fun buildFlavor(): String { return BuildConfig.FLAVOR; } + @V8Property + fun buildSpecVersion(): Int { + return JSClientConstants.PLUGIN_SPEC_VERSION; + } + + @V8Function + fun dispose(value: V8Value) { + Logger.e(TAG, "Manual dispose: " + value.javaClass.name); + value.close(); + } @V8Function fun toast(str: String) { diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt index 7822beb5309d1a00131fd28339ead9d074a04f45..4eab03f01f300845ed8ec53ca9c2ce77895adc83 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt @@ -7,7 +7,11 @@ import com.caoccao.javet.enums.V8ConversionMode import com.caoccao.javet.enums.V8ProxyMode import com.caoccao.javet.interop.V8Runtime import com.caoccao.javet.values.V8Value +import com.caoccao.javet.values.primitive.V8ValueString +import com.caoccao.javet.values.reference.V8ValueArrayBuffer import com.caoccao.javet.values.reference.V8ValueObject +import com.caoccao.javet.values.reference.V8ValueSharedArrayBuffer +import com.caoccao.javet.values.reference.V8ValueTypedArray import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient @@ -16,6 +20,9 @@ import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.internal.IV8Convertable import com.futo.platformplayer.engine.internal.V8BindObject import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import java.net.SocketTimeoutException import kotlin.streams.asSequence @@ -64,33 +71,42 @@ class PackageHttp: V8Package { } @V8Function - fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse { + fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse { return if(useAuth) - _packageClientAuth.request(method, url, headers) + _packageClientAuth.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING) else - _packageClient.request(method, url, headers); + _packageClient.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING); } @V8Function - fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse { + fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse { return if(useAuth) - _packageClientAuth.requestWithBody(method, url, body, headers) + _packageClientAuth.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING) else - _packageClient.requestWithBody(method, url, body, headers); + _packageClient.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING); } @V8Function - fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse { + fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse { return if(useAuth) - _packageClientAuth.GET(url, headers) + _packageClientAuth.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING) else - _packageClient.GET(url, headers); + _packageClient.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); } @V8Function - fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse { - return if(useAuth) - _packageClientAuth.POST(url, body, headers) + fun POST(url: String, body: Any, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse { + + val client = if(useAuth) _packageClientAuth else _packageClient; + + if(body is V8ValueString) + return client.POST(url, body.value, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); + else if(body is V8ValueTypedArray) + return client.POST(url, body.toBytes(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); + else if(body is ByteArray) + return client.POST(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); + else if(body is ArrayList<*>) //Avoid this case, used purely for testing + return client.POST(url, body.map { (it as Double).toInt().toByte() }.toByteArray(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); else - _packageClient.POST(url, body, headers); + throw NotImplementedError("Body type " + body?.javaClass?.name?.toString() + " not implemented for POST"); } @V8Function @@ -111,8 +127,19 @@ class PackageHttp: V8Package { } } + interface IBridgeHttpResponse { + val url: String; + val code: Int; + val headers: Map<String, List<String>>?; + } + @kotlinx.serialization.Serializable - class BridgeHttpResponse(val url: String, val code: Int, val body: String?, val headers: Map<String, List<String>>? = null) : IV8Convertable { + class BridgeHttpStringResponse( + override val url: String, + override val code: Int, val + body: String?, + override val headers: Map<String, List<String>>? = null) : IV8Convertable, IBridgeHttpResponse { + val isOk = code >= 200 && code < 300; override fun toV8(runtime: V8Runtime): V8Value? { @@ -125,6 +152,37 @@ class PackageHttp: V8Package { return obj; } } + @kotlinx.serialization.Serializable + class BridgeHttpBytesResponse: IV8Convertable, IBridgeHttpResponse { + override val url: String; + override val code: Int; + val body: ByteArray?; + override val headers: Map<String, List<String>>?; + + val isOk: Boolean; + + constructor(url: String, code: Int, body: ByteArray? = null, headers: Map<String, List<String>>? = null) { + this.url = url; + this.code = code; + this.body = body; + this.headers = headers; + this.isOk = code >= 200 && code < 300; + } + + override fun toV8(runtime: V8Runtime): V8Value? { + val obj = runtime.createV8ValueObject(); + obj.set("url", url); + obj.set("code", code); + if(body != null) { + val buffer = runtime.createV8ValueArrayBuffer(body.size); + buffer.fromBytes(body); + obj.set("body", body); + } + obj.set("headers", headers); + obj.set("isOk", isOk); + return obj; + } + } //TODO: This object is currently re-wrapped each modification, this is due to an issue passing the same object back and forth, should be fixed in future. @V8Convert(mode = V8ConversionMode.AllowOnly, proxyMode = V8ProxyMode.Class) @@ -147,6 +205,12 @@ class PackageHttp: V8Package { fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BatchBuilder = clientPOST(_package.getDefaultClient(useAuth), url, body, headers); + @V8Function + fun DUMMY(): BatchBuilder { + _reqs.add(Pair(_package.getDefaultClient(false), RequestDescriptor("DUMMY", "", mutableMapOf()))); + return BatchBuilder(_package, _reqs); + } + //Client-specific @V8Function @@ -169,12 +233,14 @@ class PackageHttp: V8Package { //Finalizer @V8Function - fun execute(): List<BridgeHttpResponse> { + fun execute(): List<IBridgeHttpResponse?> { return _reqs.parallelStream().map { + if(it.second.method == "DUMMY") + return@map null; if(it.second.body != null) - return@map it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers); + return@map it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType); else - return@map it.first.request(it.second.method, it.second.url, it.second.headers); + return@map it.first.request(it.second.method, it.second.url, it.second.headers, it.second.respType); } .asSequence() .toList(); @@ -232,63 +298,108 @@ class PackageHttp: V8Package { } @V8Function - fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap()) : BridgeHttpResponse { + fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { return@logExceptions catchHttp { val client = _client; //logRequest(method, url, headers, null); val resp = client.requestMethod(method, url, headers); - val responseBody = resp.body?.string(); //logResponse(method, url, resp.code, resp.headers, responseBody); - return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers, - _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + return@catchHttp when(returnType) { + ReturnType.STRING -> BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string(), sanitizeResponseHeaders(resp.headers, + _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + ReturnType.BYTES -> BridgeHttpBytesResponse(resp.url, resp.code, resp.body?.bytes(), sanitizeResponseHeaders(resp.headers, + _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + else -> throw NotImplementedError("Return type " + returnType.toString() + " not implemented"); + } } }; } @V8Function - fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap()) : BridgeHttpResponse { + fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { catchHttp { val client = _client; //logRequest(method, url, headers, body); val resp = client.requestMethod(method, url, body, headers); - val responseBody = resp.body?.string(); //logResponse(method, url, resp.code, resp.headers, responseBody); - return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers, - _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + + return@catchHttp when(returnType) { + ReturnType.STRING -> BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string(), sanitizeResponseHeaders(resp.headers, + _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + ReturnType.BYTES -> BridgeHttpBytesResponse(resp.url, resp.code, resp.body?.bytes(), sanitizeResponseHeaders(resp.headers, + _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + else -> throw NotImplementedError("Return type " + returnType.toString() + " not implemented"); + } } }; } @V8Function - fun GET(url: String, headers: MutableMap<String, String> = HashMap()) : BridgeHttpResponse { + fun GET(url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { catchHttp { val client = _client; //logRequest("GET", url, headers, null); val resp = client.get(url, headers); - val responseBody = resp.body?.string(); + //val responseBody = resp.body?.string(); //logResponse("GET", url, resp.code, resp.headers, responseBody); - return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers, - _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + + + return@catchHttp when(returnType) { + ReturnType.STRING -> BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string(), sanitizeResponseHeaders(resp.headers, + _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + ReturnType.BYTES -> BridgeHttpBytesResponse(resp.url, resp.code, resp.body?.bytes(), sanitizeResponseHeaders(resp.headers, + _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + else -> throw NotImplementedError("Return type " + returnType.toString() + " not implemented"); + } + } + }; + } + @V8Function + fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { + applyDefaultHeaders(headers); + return logExceptions { + catchHttp { + val client = _client; + //logRequest("POST", url, headers, body); + val resp = client.post(url, body, headers); + //val responseBody = resp.body?.string(); + //logResponse("POST", url, resp.code, resp.headers, responseBody); + + + return@catchHttp when(returnType) { + ReturnType.STRING -> BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string(), sanitizeResponseHeaders(resp.headers, + _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + ReturnType.BYTES -> BridgeHttpBytesResponse(resp.url, resp.code, resp.body?.bytes(), sanitizeResponseHeaders(resp.headers, + _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + else -> throw NotImplementedError("Return type " + returnType.toString() + " not implemented"); + } } }; } @V8Function - fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap()) : BridgeHttpResponse { + fun POST(url: String, body: ByteArray, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { catchHttp { val client = _client; //logRequest("POST", url, headers, body); val resp = client.post(url, body, headers); - val responseBody = resp.body?.string(); + //val responseBody = resp.body?.string(); //logResponse("POST", url, resp.code, resp.headers, responseBody); - return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers, - _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + + + return@catchHttp when(returnType) { + ReturnType.STRING -> BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string(), sanitizeResponseHeaders(resp.headers, + _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + ReturnType.BYTES -> BridgeHttpBytesResponse(resp.url, resp.code, resp.body?.bytes(), sanitizeResponseHeaders(resp.headers, + _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess)); + else -> throw NotImplementedError("Return type " + returnType.toString() + " not implemented"); + } } }; } @@ -388,13 +499,13 @@ class PackageHttp: V8Package { } } - private fun catchHttp(handle: ()->BridgeHttpResponse): BridgeHttpResponse { + private fun catchHttp(handle: ()->IBridgeHttpResponse): IBridgeHttpResponse { try{ return handle(); } //Forward timeouts catch(ex: SocketTimeoutException) { - return BridgeHttpResponse("", 408, null); + return BridgeHttpStringResponse("", 408, null); } } } @@ -514,20 +625,25 @@ class PackageHttp: V8Package { val url: String, val headers: MutableMap<String, String>, val body: String? = null, - val contentType: String? = null + val contentType: String? = null, + val respType: ReturnType = ReturnType.STRING ) - private fun catchHttp(handle: ()->BridgeHttpResponse): BridgeHttpResponse { + private fun catchHttp(handle: ()->BridgeHttpStringResponse): BridgeHttpStringResponse { try{ return handle(); } //Forward timeouts catch(ex: SocketTimeoutException) { - return BridgeHttpResponse("", 408, null); + return BridgeHttpStringResponse("", 408, null); } } + enum class ReturnType(val value: Int) { + STRING(0), + BYTES(1); + } companion object { private const val TAG = "PackageHttp"; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt index fcabcb5b01a196be2d8eaf54ecb3358707da75e0..1a4537f6145f785f854ae92fd01abd063a224e10 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt @@ -118,8 +118,13 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent private fun showVideoOptionsOverlay(content: IPlatformVideo) { _overlayContainer.let { - _videoOptionsOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem(context, R.drawable.ic_visibility_off, context.getString(R.string.hide), context.getString(R.string.hide_from_home), "hide", - { StateMeta.instance.addHiddenVideo(content.url); + _videoOptionsOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem( + context, + R.drawable.ic_visibility_off, + context.getString(R.string.hide), + context.getString(R.string.hide_from_home), + tag = "hide", + call = { StateMeta.instance.addHiddenVideo(content.url); if (fragment is HomeFragment) { val removeIndex = recyclerData.results.indexOf(content); if (removeIndex >= 0) { @@ -128,8 +133,12 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent } } }), - SlideUpMenuItem(context, R.drawable.ic_playlist, context.getString(R.string.play_feed_as_queue), context.getString(R.string.play_entire_feed), "playFeed", - { + SlideUpMenuItem(context, + R.drawable.ic_playlist, + context.getString(R.string.play_feed_as_queue), + context.getString(R.string.play_entire_feed), + tag = "playFeed", + call = { val newQueue = listOf(content) + recyclerData.results .filterIsInstance<IPlatformVideo>() .filter { it != content }; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt index af128741b1a96a402c573615bbb99845e87c8879..1c706c286b595b7052082704ed0c2c595ef2a69a 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt @@ -109,19 +109,31 @@ class PlaylistFragment : MainFragment() { val reconstruction = StatePlaylists.instance.playlistStore.getReconstructionString(playlist); UISlideOverlays.showOverlay(overlayContainer, context.getString(R.string.playlist) + " [${playlist.name}]", null, {}, - SlideUpMenuItem(context, R.drawable.ic_list, context.getString(R.string.share_as_text), context.getString(R.string.share_as_a_list_of_video_urls), 1, { - _fragment.startActivity(ShareCompat.IntentBuilder(context) - .setType("text/plain") - .setText(reconstruction) - .intent); - }), - SlideUpMenuItem(context, R.drawable.ic_move_up, context.getString(R.string.share_as_import), context.getString(R.string.share_as_a_import_file_for_grayjay), 2, { - val shareUri = StatePlaylists.instance.createPlaylistShareJsonUri(context, playlist); - _fragment.startActivity(ShareCompat.IntentBuilder(context) - .setType("application/json") - .setStream(shareUri) - .intent); - }) + SlideUpMenuItem( + context, + R.drawable.ic_list, + context.getString(R.string.share_as_text), + context.getString(R.string.share_as_a_list_of_video_urls), + tag = 1, + call = { + _fragment.startActivity(ShareCompat.IntentBuilder(context) + .setType("text/plain") + .setText(reconstruction) + .intent); + }), + SlideUpMenuItem( + context, + R.drawable.ic_move_up, + context.getString(R.string.share_as_import), + context.getString(R.string.share_as_a_import_file_for_grayjay), + tag = 2, + call = { + val shareUri = StatePlaylists.instance.createPlaylistShareJsonUri(context, playlist); + _fragment.startActivity(ShareCompat.IntentBuilder(context) + .setType("application/json") + .setStream(shareUri) + .intent); + }) ); }; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt index e1f311fbb656e17f40f34882f50734b9bf35c677..cbefca3b38915ca0102b8595875e94f5a5986e8c 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt @@ -157,7 +157,7 @@ class VideoDetailFragment : MainFragment { _viewDetail?.preventPictureInPicture = true; } - fun minimizeVideoDetail(){ + fun minimizeVideoDetail() { _viewDetail?.setFullscreen(false); if(_view != null) _view!!.transitionToStart(); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 0befae690f37e71b8335397f918e058a3d61d615..c7dd930c6b3c6cb5310a902c97ae9a41b2fc018e 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -23,7 +23,6 @@ import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.WindowManager -import android.webkit.WebView import android.widget.FrameLayout import android.widget.ImageButton import android.widget.ImageView @@ -59,8 +58,6 @@ import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes import com.futo.platformplayer.api.media.models.ratings.RatingLikes import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor -import com.futo.platformplayer.api.media.models.streams.sources.DashManifestSource -import com.futo.platformplayer.api.media.models.streams.sources.HLSManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource @@ -75,6 +72,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.StateCasting @@ -115,6 +113,7 @@ import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.stores.db.types.DBHistory import com.futo.platformplayer.toHumanBitrate +import com.futo.platformplayer.toHumanBytesSize import com.futo.platformplayer.toHumanNowDiffString import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.toHumanTime @@ -695,6 +694,7 @@ class VideoDetailView : ConstraintLayout { _lastAudioSource = null; _lastSubtitleSource = null; video = null; + _player.clear(); cleanupPlaybackTracker(); Logger.i(TAG, "Keep screen on unset onClose") fragment.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); @@ -1674,7 +1674,7 @@ class VideoDetailView : ConstraintLayout { _didTriggerDatasourceErrroCount++; UIDialogs.toast("Block detected, attempting bypass"); - + //return; fragment.lifecycleScope.launch(Dispatchers.IO) { val newDetails = StatePlatform.instance.getContentDetails(currentVideo.url, true).await(); val previousVideoSource = _lastVideoSource; @@ -1808,7 +1808,7 @@ class VideoDetailView : ConstraintLayout { } } - val doDedup = false; + val doDedup = Settings.instance.playback.simplifySources; val bestVideoSources = if(doDedup) (videoSources?.map { it.height * it.width } ?.distinct() @@ -1851,40 +1851,56 @@ class VideoDetailView : ConstraintLayout { SlideUpMenuGroup(this.context, context.getString(R.string.offline_video), "video", *localVideoSources .map { - SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, - { handleSelectVideoTrack(it) }); + SlideUpMenuItem(this.context, + R.drawable.ic_movie, + it.name, + "${it.width}x${it.height}", + tag = it, + call = { handleSelectVideoTrack(it) }); }.toList().toTypedArray()) else null, if(localAudioSource?.isNotEmpty() == true) SlideUpMenuGroup(this.context, context.getString(R.string.offline_audio), "audio", *localAudioSource .map { - SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it, - { handleSelectAudioTrack(it) }); + SlideUpMenuItem(this.context, + R.drawable.ic_music, + it.name, + it.bitrate.toHumanBitrate(), + tag = it, + call = { handleSelectAudioTrack(it) }); }.toList().toTypedArray()) else null, if(localSubtitleSources?.isNotEmpty() == true) SlideUpMenuGroup(this.context, context.getString(R.string.offline_subtitles), "subtitles", *localSubtitleSources .map { - SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", it, - { handleSelectSubtitleTrack(it) }) + SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, + call = { handleSelectSubtitleTrack(it) }) }.toList().toTypedArray()) else null, if(liveStreamVideoFormats?.isEmpty() == false) SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video", *liveStreamVideoFormats .map { - SlideUpMenuItem(this.context, R.drawable.ic_movie, it.label ?: it.containerMimeType ?: it.bitrate.toString(), "${it.width}x${it.height}", it, - { _player.selectVideoTrack(it.height) }); + SlideUpMenuItem(this.context, + R.drawable.ic_movie, + it.label ?: it.containerMimeType ?: it.bitrate.toString(), + "${it.width}x${it.height}", + tag = it, + call = { _player.selectVideoTrack(it.height) }); }.toList().toTypedArray()) else null, if(liveStreamAudioFormats?.isEmpty() == false) SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio", *liveStreamAudioFormats .map { - SlideUpMenuItem(this.context, R.drawable.ic_music, "${it.label ?: it.containerMimeType} ${it.bitrate}", "", it, - { _player.selectAudioTrack(it.bitrate) }); + SlideUpMenuItem(this.context, + R.drawable.ic_music, + "${it.label ?: it.containerMimeType} ${it.bitrate}", + "", + tag = it, + call = { _player.selectAudioTrack(it.bitrate) }); }.toList().toTypedArray()) else null, @@ -1892,24 +1908,38 @@ class VideoDetailView : ConstraintLayout { SlideUpMenuGroup(this.context, context.getString(R.string.video), "video", *bestVideoSources .map { - SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", it, - { handleSelectVideoTrack(it) }); + val estSize = VideoHelper.estimateSourceSize(it); + val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; + SlideUpMenuItem(this.context, + R.drawable.ic_movie, + it!!.name, + if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", + (prefix + it.codec.trim()).trim(), + tag = it, + call = { handleSelectVideoTrack(it) }); }.toList().toTypedArray()) else null, if(bestAudioSources.isNotEmpty()) SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio", *bestAudioSources .map { - SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it, - { handleSelectAudioTrack(it) }); + val estSize = VideoHelper.estimateSourceSize(it); + val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; + SlideUpMenuItem(this.context, + R.drawable.ic_music, + it.name, + it.bitrate.toHumanBitrate(), + (prefix + it.codec.trim()).trim(), + tag = it, + call = { handleSelectAudioTrack(it) }); }.toList().toTypedArray()) else null, if(video?.subtitles?.isNotEmpty() == true) SlideUpMenuGroup(this.context, context.getString(R.string.subtitles), "subtitles", *video.subtitles .map { - SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", it, - { handleSelectSubtitleTrack(it) }) + SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, + call = { handleSelectSubtitleTrack(it) }) }.toList().toTypedArray()) else null); } diff --git a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt index c1b89bd3732e9f5624cca574f2cac4b50f9e466c..cd86e01d746116984d7931c0537bcd0479dcf97c 100644 --- a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt +++ b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt @@ -20,6 +20,7 @@ import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.others.Language @@ -186,5 +187,25 @@ class VideoHelper { return@Resolver dataSpec; })).createMediaSource(manifest, MediaItem.Builder().setUri(Uri.parse(audioSource.getAudioUrl())).build()) } + + + fun estimateSourceSize(source: IVideoSource?): Int { + if(source == null) return 0; + if(source is IVideoUrlSource) { + if(source.bitrate ?: 0 <= 0 || source.duration.toInt() == 0) + return 0; + return (source.duration / 8).toInt() * source.bitrate!!; + } + else return 0; + } + fun estimateSourceSize(source: IAudioSource?): Int { + if(source == null) return 0; + if(source is IAudioUrlSource) { + if(source.bitrate <= 0 || source.duration?.toInt() ?: 0 == 0) + return 0; + return (source.duration!! / 8).toInt() * source.bitrate; + } + else return 0; + } } } diff --git a/app/src/main/java/com/futo/platformplayer/mdns/BroadcastService.kt b/app/src/main/java/com/futo/platformplayer/mdns/BroadcastService.kt new file mode 100644 index 0000000000000000000000000000000000000000..ac3c61e0376c1369ce69adcfbcf9e35c6e02f30a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/BroadcastService.kt @@ -0,0 +1,11 @@ +package com.futo.platformplayer.mdns + +data class BroadcastService( + val deviceName: String, + val serviceName: String, + val port: UShort, + val ttl: UInt, + val weight: UShort, + val priority: UShort, + val texts: List<String>? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsPacket.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsPacket.kt new file mode 100644 index 0000000000000000000000000000000000000000..2c27edf8a1f169c7f5cb780dc67ffef183e7a764 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/DnsPacket.kt @@ -0,0 +1,93 @@ +package com.futo.platformplayer.mdns + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +enum class QueryResponse(val value: Byte) { + Query(0), + Response(1) +} + +enum class DnsOpcode(val value: Byte) { + StandardQuery(0), + InverseQuery(1), + ServerStatusRequest(2) +} + +enum class DnsResponseCode(val value: Byte) { + NoError(0), + FormatError(1), + ServerFailure(2), + NameError(3), + NotImplemented(4), + Refused(5) +} + +data class DnsPacketHeader( + val identifier: UShort, + val queryResponse: Int, + val opcode: Int, + val authoritativeAnswer: Boolean, + val truncated: Boolean, + val recursionDesired: Boolean, + val recursionAvailable: Boolean, + val answerAuthenticated: Boolean, + val nonAuthenticatedData: Boolean, + val responseCode: DnsResponseCode +) + +data class DnsPacket( + val header: DnsPacketHeader, + val questions: List<DnsQuestion>, + val answers: List<DnsResourceRecord>, + val authorities: List<DnsResourceRecord>, + val additionals: List<DnsResourceRecord> +) { + companion object { + fun parse(data: ByteArray): DnsPacket { + val span = data.asUByteArray() + val flags = (span[2].toInt() shl 8 or span[3].toInt()).toUShort() + val questionCount = (span[4].toInt() shl 8 or span[5].toInt()).toUShort() + val answerCount = (span[6].toInt() shl 8 or span[7].toInt()).toUShort() + val authorityCount = (span[8].toInt() shl 8 or span[9].toInt()).toUShort() + val additionalCount = (span[10].toInt() shl 8 or span[11].toInt()).toUShort() + + var position = 12 + + val questions = List(questionCount.toInt()) { + DnsQuestion.parse(data, position).also { position = it.second } + }.map { it.first } + + val answers = List(answerCount.toInt()) { + DnsResourceRecord.parse(data, position).also { position = it.second } + }.map { it.first } + + val authorities = List(authorityCount.toInt()) { + DnsResourceRecord.parse(data, position).also { position = it.second } + }.map { it.first } + + val additionals = List(additionalCount.toInt()) { + DnsResourceRecord.parse(data, position).also { position = it.second } + }.map { it.first } + + return DnsPacket( + header = DnsPacketHeader( + identifier = (span[0].toInt() shl 8 or span[1].toInt()).toUShort(), + queryResponse = ((flags.toUInt() shr 15) and 0b1u).toInt(), + opcode = ((flags.toUInt() shr 11) and 0b1111u).toInt(), + authoritativeAnswer = (flags.toInt() shr 10) and 0b1 != 0, + truncated = (flags.toInt() shr 9) and 0b1 != 0, + recursionDesired = (flags.toInt() shr 8) and 0b1 != 0, + recursionAvailable = (flags.toInt() shr 7) and 0b1 != 0, + answerAuthenticated = (flags.toInt() shr 5) and 0b1 != 0, + nonAuthenticatedData = (flags.toInt() shr 4) and 0b1 != 0, + responseCode = DnsResponseCode.entries[flags.toInt() and 0b1111] + ), + questions = questions, + answers = answers, + authorities = authorities, + additionals = additionals + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsQuestion.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsQuestion.kt new file mode 100644 index 0000000000000000000000000000000000000000..01a7bd77a90daedf7de9e0abeb720ab18322c1f9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/DnsQuestion.kt @@ -0,0 +1,110 @@ +package com.futo.platformplayer.mdns + +import com.futo.platformplayer.mdns.Extensions.readDomainName +import java.nio.ByteBuffer +import java.nio.ByteOrder + + +enum class QuestionType(val value: UShort) { + A(1u), + NS(2u), + MD(3u), + MF(4u), + CNAME(5u), + SOA(6u), + MB(7u), + MG(8u), + MR(9u), + NULL(10u), + WKS(11u), + PTR(12u), + HINFO(13u), + MINFO(14u), + MX(15u), + TXT(16u), + RP(17u), + AFSDB(18u), + SIG(24u), + KEY(25u), + AAAA(28u), + LOC(29u), + SRV(33u), + NAPTR(35u), + KX(36u), + CERT(37u), + DNAME(39u), + APL(42u), + DS(43u), + SSHFP(44u), + IPSECKEY(45u), + RRSIG(46u), + NSEC(47u), + DNSKEY(48u), + DHCID(49u), + NSEC3(50u), + NSEC3PARAM(51u), + TSLA(52u), + SMIMEA(53u), + HIP(55u), + CDS(59u), + CDNSKEY(60u), + OPENPGPKEY(61u), + CSYNC(62u), + ZONEMD(63u), + SVCB(64u), + HTTPS(65u), + EUI48(108u), + EUI64(109u), + TKEY(249u), + TSIG(250u), + URI(256u), + CAA(257u), + TA(32768u), + DLV(32769u), + AXFR(252u), + IXFR(251u), + OPT(41u), + MAILB(253u), + MALA(254u), + All(252u) +} + +enum class QuestionClass(val value: UShort) { + IN(1u), + CS(2u), + CH(3u), + HS(4u), + All(255u) +} + +data class DnsQuestion( + override val name: String, + override val type: Int, + override val clazz: Int, + val queryUnicast: Boolean +) : DnsResourceRecordBase(name, type, clazz) { + companion object { + fun parse(data: ByteArray, startPosition: Int): Pair<DnsQuestion, Int> { + val span = data.asUByteArray() + var position = startPosition + val qname = span.readDomainName(position).also { position = it.second } + val qtype = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort() + position += 2 + val qclass = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort() + position += 2 + + return DnsQuestion( + name = qname.first, + type = qtype.toInt(), + queryUnicast = ((qclass.toInt() shr 15) and 0b1) != 0, + clazz = qclass.toInt() and 0b111111111111111 + ) to position + } + } +} + +open class DnsResourceRecordBase( + open val name: String, + open val type: Int, + open val clazz: Int +) diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsReader.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsReader.kt new file mode 100644 index 0000000000000000000000000000000000000000..83c329ffc650718bcca9663c0a16bb74f21e3730 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/DnsReader.kt @@ -0,0 +1,514 @@ +package com.futo.platformplayer.mdns + +import com.futo.platformplayer.mdns.Extensions.readDomainName +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.charset.StandardCharsets +import kotlin.math.pow +import java.net.InetAddress + +data class PTRRecord(val domainName: String) + +data class ARecord(val address: InetAddress) + +data class AAAARecord(val address: InetAddress) + +data class MXRecord(val preference: UShort, val exchange: String) + +data class CNAMERecord(val cname: String) + +data class TXTRecord(val texts: List<String>) + +data class SOARecord( + val primaryNameServer: String, + val responsibleAuthorityMailbox: String, + val serialNumber: Int, + val refreshInterval: Int, + val retryInterval: Int, + val expiryLimit: Int, + val minimumTTL: Int +) + +data class SRVRecord(val priority: UShort, val weight: UShort, val port: UShort, val target: String) + +data class NSRecord(val nameServer: String) + +data class CAARecord(val flags: Byte, val tag: String, val value: String) + +data class HINFORecord(val cpu: String, val os: String) + +data class RPRecord(val mailbox: String, val txtDomainName: String) + + +data class AFSDBRecord(val subtype: UShort, val hostname: String) +data class LOCRecord( + val version: Byte, + val size: Double, + val horizontalPrecision: Double, + val verticalPrecision: Double, + val latitude: Double, + val longitude: Double, + val altitude: Double +) { + companion object { + fun decodeSizeOrPrecision(coded: Byte): Double { + val baseValue = (coded.toInt() shr 4) and 0x0F + val exponent = coded.toInt() and 0x0F + return baseValue * 10.0.pow(exponent.toDouble()) + } + + fun decodeLatitudeOrLongitude(coded: Int): Double { + val arcSeconds = coded / 1E3 + return arcSeconds / 3600.0 + } + + fun decodeAltitude(coded: Int): Double { + return (coded / 100.0) - 100000.0 + } + } +} + +data class NAPTRRecord( + val order: UShort, + val preference: UShort, + val flags: String, + val services: String, + val regexp: String, + val replacement: String +) + +data class RRSIGRecord( + val typeCovered: UShort, + val algorithm: Byte, + val labels: Byte, + val originalTTL: UInt, + val signatureExpiration: UInt, + val signatureInception: UInt, + val keyTag: UShort, + val signersName: String, + val signature: ByteArray +) + +data class KXRecord(val preference: UShort, val exchanger: String) + +data class CERTRecord(val type: UShort, val keyTag: UShort, val algorithm: Byte, val certificate: ByteArray) + + + +data class DNAMERecord(val target: String) + +data class DSRecord(val keyTag: UShort, val algorithm: Byte, val digestType: Byte, val digest: ByteArray) + +data class SSHFPRecord(val algorithm: Byte, val fingerprintType: Byte, val fingerprint: ByteArray) + +data class TLSARecord(val usage: Byte, val selector: Byte, val matchingType: Byte, val certificateAssociationData: ByteArray) + +data class SMIMEARecord(val usage: Byte, val selector: Byte, val matchingType: Byte, val certificateAssociationData: ByteArray) + +data class URIRecord(val priority: UShort, val weight: UShort, val target: String) + +data class NSECRecord(val ownerName: String, val typeBitMaps: List<Pair<Byte, ByteArray>>) +data class NSEC3Record( + val hashAlgorithm: Byte, + val flags: Byte, + val iterations: UShort, + val salt: ByteArray, + val nextHashedOwnerName: ByteArray, + val typeBitMaps: List<UShort> +) + +data class NSEC3PARAMRecord(val hashAlgorithm: Byte, val flags: Byte, val iterations: UShort, val salt: ByteArray) +data class SPFRecord(val texts: List<String>) +data class TKEYRecord( + val algorithm: String, + val inception: UInt, + val expiration: UInt, + val mode: UShort, + val error: UShort, + val keyData: ByteArray, + val otherData: ByteArray +) + +data class TSIGRecord( + val algorithmName: String, + val timeSigned: UInt, + val fudge: UShort, + val mac: ByteArray, + val originalID: UShort, + val error: UShort, + val otherData: ByteArray +) + +data class OPTRecordOption(val code: UShort, val data: ByteArray) +data class OPTRecord(val options: List<OPTRecordOption>) + +class DnsReader(private val data: ByteArray, private var position: Int = 0, private val length: Int = data.size) { + + private val endPosition: Int = position + length + + fun readDomainName(): String { + return data.asUByteArray().readDomainName(position).also { position = it.second }.first + } + + fun readDouble(): Double { + checkRemainingBytes(Double.SIZE_BYTES) + val result = ByteBuffer.wrap(data, position, Double.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).double + position += Double.SIZE_BYTES + return result + } + + fun readInt16(): Short { + checkRemainingBytes(Short.SIZE_BYTES) + val result = ByteBuffer.wrap(data, position, Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).short + position += Short.SIZE_BYTES + return result + } + + fun readInt32(): Int { + checkRemainingBytes(Int.SIZE_BYTES) + val result = ByteBuffer.wrap(data, position, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int + position += Int.SIZE_BYTES + return result + } + + fun readInt64(): Long { + checkRemainingBytes(Long.SIZE_BYTES) + val result = ByteBuffer.wrap(data, position, Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).long + position += Long.SIZE_BYTES + return result + } + + fun readSingle(): Float { + checkRemainingBytes(Float.SIZE_BYTES) + val result = ByteBuffer.wrap(data, position, Float.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).float + position += Float.SIZE_BYTES + return result + } + + fun readByte(): Byte { + checkRemainingBytes(Byte.SIZE_BYTES) + return data[position++] + } + + fun readBytes(length: Int): ByteArray { + checkRemainingBytes(length) + return ByteArray(length).also { data.copyInto(it, startIndex = position, endIndex = position + length) } + .also { position += length } + } + + fun readUInt16(): UShort { + checkRemainingBytes(Short.SIZE_BYTES) + val result = ByteBuffer.wrap(data, position, Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).short.toUShort() + position += Short.SIZE_BYTES + return result + } + + fun readUInt32(): UInt { + checkRemainingBytes(Int.SIZE_BYTES) + val result = ByteBuffer.wrap(data, position, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int.toUInt() + position += Int.SIZE_BYTES + return result + } + + fun readUInt64(): ULong { + checkRemainingBytes(Long.SIZE_BYTES) + val result = ByteBuffer.wrap(data, position, Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).long.toULong() + position += Long.SIZE_BYTES + return result + } + + fun readString(): String { + val length = data[position++].toInt() + checkRemainingBytes(length) + return String(data, position, length, StandardCharsets.UTF_8).also { position += length } + } + + private fun checkRemainingBytes(requiredBytes: Int) { + if (position + requiredBytes > endPosition) throw IndexOutOfBoundsException() + } + + fun readRPRecord(): RPRecord { + return RPRecord(readDomainName(), readDomainName()) + } + + fun readKXRecord(): KXRecord { + val preference = readUInt16() + val exchanger = readDomainName() + return KXRecord(preference, exchanger) + } + + fun readCERTRecord(): CERTRecord { + val type = readUInt16() + val keyTag = readUInt16() + val algorithm = readByte() + val certificateLength = readUInt16().toInt() - 5 + val certificate = readBytes(certificateLength) + return CERTRecord(type, keyTag, algorithm, certificate) + } + + fun readPTRRecord(): PTRRecord { + return PTRRecord(readDomainName()) + } + + fun readARecord(): ARecord { + val address = readBytes(4) + return ARecord(InetAddress.getByAddress(address)) + } + + fun readAAAARecord(): AAAARecord { + val address = readBytes(16) + return AAAARecord(InetAddress.getByAddress(address)) + } + + fun readMXRecord(): MXRecord { + val preference = readUInt16() + val exchange = readDomainName() + return MXRecord(preference, exchange) + } + + fun readCNAMERecord(): CNAMERecord { + return CNAMERecord(readDomainName()) + } + + fun readTXTRecord(): TXTRecord { + val texts = mutableListOf<String>() + while (position < endPosition) { + val textLength = data[position++].toInt() + checkRemainingBytes(textLength) + val text = String(data, position, textLength, StandardCharsets.UTF_8) + texts.add(text) + position += textLength + } + return TXTRecord(texts) + } + + fun readSOARecord(): SOARecord { + val primaryNameServer = readDomainName() + val responsibleAuthorityMailbox = readDomainName() + val serialNumber = readInt32() + val refreshInterval = readInt32() + val retryInterval = readInt32() + val expiryLimit = readInt32() + val minimumTTL = readInt32() + return SOARecord(primaryNameServer, responsibleAuthorityMailbox, serialNumber, refreshInterval, retryInterval, expiryLimit, minimumTTL) + } + + fun readSRVRecord(): SRVRecord { + val priority = readUInt16() + val weight = readUInt16() + val port = readUInt16() + val target = readDomainName() + return SRVRecord(priority, weight, port, target) + } + + fun readNSRecord(): NSRecord { + return NSRecord(readDomainName()) + } + + fun readCAARecord(): CAARecord { + val length = readUInt16().toInt() + val flags = readByte() + val tagLength = readByte().toInt() + val tag = String(data, position, tagLength, StandardCharsets.US_ASCII).also { position += tagLength } + val valueLength = length - 1 - 1 - tagLength + val value = String(data, position, valueLength, StandardCharsets.US_ASCII).also { position += valueLength } + return CAARecord(flags, tag, value) + } + + fun readHINFORecord(): HINFORecord { + val cpuLength = readByte().toInt() + val cpu = String(data, position, cpuLength, StandardCharsets.US_ASCII).also { position += cpuLength } + val osLength = readByte().toInt() + val os = String(data, position, osLength, StandardCharsets.US_ASCII).also { position += osLength } + return HINFORecord(cpu, os) + } + + fun readAFSDBRecord(): AFSDBRecord { + return AFSDBRecord(readUInt16(), readDomainName()) + } + + fun readLOCRecord(): LOCRecord { + val version = readByte() + val size = LOCRecord.decodeSizeOrPrecision(readByte()) + val horizontalPrecision = LOCRecord.decodeSizeOrPrecision(readByte()) + val verticalPrecision = LOCRecord.decodeSizeOrPrecision(readByte()) + val latitudeCoded = readInt32() + val longitudeCoded = readInt32() + val altitudeCoded = readInt32() + val latitude = LOCRecord.decodeLatitudeOrLongitude(latitudeCoded) + val longitude = LOCRecord.decodeLatitudeOrLongitude(longitudeCoded) + val altitude = LOCRecord.decodeAltitude(altitudeCoded) + return LOCRecord(version, size, horizontalPrecision, verticalPrecision, latitude, longitude, altitude) + } + + fun readNAPTRRecord(): NAPTRRecord { + val order = readUInt16() + val preference = readUInt16() + val flags = readString() + val services = readString() + val regexp = readString() + val replacement = readDomainName() + return NAPTRRecord(order, preference, flags, services, regexp, replacement) + } + + fun readDNAMERecord(): DNAMERecord { + return DNAMERecord(readDomainName()) + } + + fun readDSRecord(): DSRecord { + val keyTag = readUInt16() + val algorithm = readByte() + val digestType = readByte() + val digestLength = readUInt16().toInt() - 4 + val digest = readBytes(digestLength) + return DSRecord(keyTag, algorithm, digestType, digest) + } + + fun readSSHFPRecord(): SSHFPRecord { + val algorithm = readByte() + val fingerprintType = readByte() + val fingerprintLength = readUInt16().toInt() - 2 + val fingerprint = readBytes(fingerprintLength) + return SSHFPRecord(algorithm, fingerprintType, fingerprint) + } + + fun readTLSARecord(): TLSARecord { + val usage = readByte() + val selector = readByte() + val matchingType = readByte() + val dataLength = readUInt16().toInt() - 3 + val certificateAssociationData = readBytes(dataLength) + return TLSARecord(usage, selector, matchingType, certificateAssociationData) + } + + fun readSMIMEARecord(): SMIMEARecord { + val usage = readByte() + val selector = readByte() + val matchingType = readByte() + val dataLength = readUInt16().toInt() - 3 + val certificateAssociationData = readBytes(dataLength) + return SMIMEARecord(usage, selector, matchingType, certificateAssociationData) + } + + fun readURIRecord(): URIRecord { + val priority = readUInt16() + val weight = readUInt16() + val length = readUInt16().toInt() + val target = String(data, position, length, StandardCharsets.US_ASCII).also { position += length } + return URIRecord(priority, weight, target) + } + + fun readRRSIGRecord(): RRSIGRecord { + val typeCovered = readUInt16() + val algorithm = readByte() + val labels = readByte() + val originalTTL = readUInt32() + val signatureExpiration = readUInt32() + val signatureInception = readUInt32() + val keyTag = readUInt16() + val signersName = readDomainName() + val signatureLength = readUInt16().toInt() + val signature = readBytes(signatureLength) + return RRSIGRecord( + typeCovered, + algorithm, + labels, + originalTTL, + signatureExpiration, + signatureInception, + keyTag, + signersName, + signature + ) + } + + fun readNSECRecord(): NSECRecord { + val ownerName = readDomainName() + val typeBitMaps = mutableListOf<Pair<Byte, ByteArray>>() + while (position < endPosition) { + val windowBlock = readByte() + val bitmapLength = readByte().toInt() + val bitmap = readBytes(bitmapLength) + typeBitMaps.add(windowBlock to bitmap) + } + return NSECRecord(ownerName, typeBitMaps) + } + + fun readNSEC3Record(): NSEC3Record { + val hashAlgorithm = readByte() + val flags = readByte() + val iterations = readUInt16() + val saltLength = readByte().toInt() + val salt = readBytes(saltLength) + val hashLength = readByte().toInt() + val nextHashedOwnerName = readBytes(hashLength) + val bitMapLength = readUInt16().toInt() + val typeBitMaps = mutableListOf<UShort>() + val endPos = position + bitMapLength + while (position < endPos) { + typeBitMaps.add(readUInt16()) + } + return NSEC3Record(hashAlgorithm, flags, iterations, salt, nextHashedOwnerName, typeBitMaps) + } + + fun readNSEC3PARAMRecord(): NSEC3PARAMRecord { + val hashAlgorithm = readByte() + val flags = readByte() + val iterations = readUInt16() + val saltLength = readByte().toInt() + val salt = readBytes(saltLength) + return NSEC3PARAMRecord(hashAlgorithm, flags, iterations, salt) + } + + + fun readSPFRecord(): SPFRecord { + val length = readUInt16().toInt() + val texts = mutableListOf<String>() + val endPos = position + length + while (position < endPos) { + val textLength = readByte().toInt() + val text = String(data, position, textLength, StandardCharsets.US_ASCII).also { position += textLength } + texts.add(text) + } + return SPFRecord(texts) + } + + fun readTKEYRecord(): TKEYRecord { + val algorithm = readDomainName() + val inception = readUInt32() + val expiration = readUInt32() + val mode = readUInt16() + val error = readUInt16() + val keySize = readUInt16().toInt() + val keyData = readBytes(keySize) + val otherSize = readUInt16().toInt() + val otherData = readBytes(otherSize) + return TKEYRecord(algorithm, inception, expiration, mode, error, keyData, otherData) + } + + fun readTSIGRecord(): TSIGRecord { + val algorithmName = readDomainName() + val timeSigned = readUInt32() + val fudge = readUInt16() + val macSize = readUInt16().toInt() + val mac = readBytes(macSize) + val originalID = readUInt16() + val error = readUInt16() + val otherSize = readUInt16().toInt() + val otherData = readBytes(otherSize) + return TSIGRecord(algorithmName, timeSigned, fudge, mac, originalID, error, otherData) + } + + + + fun readOPTRecord(): OPTRecord { + val options = mutableListOf<OPTRecordOption>() + while (position < endPosition) { + val optionCode = readUInt16() + val optionLength = readUInt16().toInt() + val optionData = readBytes(optionLength) + options.add(OPTRecordOption(optionCode, optionData)) + } + return OPTRecord(options) + } +} diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsResourceRecord.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsResourceRecord.kt new file mode 100644 index 0000000000000000000000000000000000000000..87ec0e5f3c7c40d8dd79b65ddadf18ee539555ab --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/DnsResourceRecord.kt @@ -0,0 +1,117 @@ +package com.futo.platformplayer.mdns + +import com.futo.platformplayer.mdns.Extensions.readDomainName + +enum class ResourceRecordType(val value: UShort) { + None(0u), + A(1u), + NS(2u), + MD(3u), + MF(4u), + CNAME(5u), + SOA(6u), + MB(7u), + MG(8u), + MR(9u), + NULL(10u), + WKS(11u), + PTR(12u), + HINFO(13u), + MINFO(14u), + MX(15u), + TXT(16u), + RP(17u), + AFSDB(18u), + SIG(24u), + KEY(25u), + AAAA(28u), + LOC(29u), + SRV(33u), + NAPTR(35u), + KX(36u), + CERT(37u), + DNAME(39u), + APL(42u), + DS(43u), + SSHFP(44u), + IPSECKEY(45u), + RRSIG(46u), + NSEC(47u), + DNSKEY(48u), + DHCID(49u), + NSEC3(50u), + NSEC3PARAM(51u), + TSLA(52u), + SMIMEA(53u), + HIP(55u), + CDS(59u), + CDNSKEY(60u), + OPENPGPKEY(61u), + CSYNC(62u), + ZONEMD(63u), + SVCB(64u), + HTTPS(65u), + EUI48(108u), + EUI64(109u), + TKEY(249u), + TSIG(250u), + URI(256u), + CAA(257u), + TA(32768u), + DLV(32769u), + AXFR(252u), + IXFR(251u), + OPT(41u) +} + +enum class ResourceRecordClass(val value: UShort) { + IN(1u), + CS(2u), + CH(3u), + HS(4u) +} + +data class DnsResourceRecord( + override val name: String, + override val type: Int, + override val clazz: Int, + val timeToLive: UInt, + val cacheFlush: Boolean, + val dataPosition: Int = -1, + val dataLength: Int = -1, + private val data: ByteArray? = null +) : DnsResourceRecordBase(name, type, clazz) { + + companion object { + fun parse(data: ByteArray, startPosition: Int): Pair<DnsResourceRecord, Int> { + val span = data.asUByteArray() + var position = startPosition + val name = span.readDomainName(position).also { position = it.second } + val type = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort() + position += 2 + val clazz = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort() + position += 2 + val ttl = (span[position].toInt() shl 24 or (span[position + 1].toInt() shl 16) or + (span[position + 2].toInt() shl 8) or span[position + 3].toInt()).toUInt() + position += 4 + val rdlength = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort() + val rdposition = position + 2 + position += 2 + rdlength.toInt() + + return DnsResourceRecord( + name = name.first, + type = type.toInt(), + clazz = clazz.toInt() and 0b1111111_11111111, + timeToLive = ttl, + cacheFlush = ((clazz.toInt() shr 15) and 0b1) != 0, + dataPosition = rdposition, + dataLength = rdlength.toInt(), + data = data + ) to position + } + } + + fun getDataReader(): DnsReader { + return DnsReader(data!!, dataPosition, dataLength) + } +} diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsWriter.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsWriter.kt new file mode 100644 index 0000000000000000000000000000000000000000..5b2c1f5c0402a8f55d00e4e946f80bd1719abdb2 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/DnsWriter.kt @@ -0,0 +1,208 @@ +package com.futo.platformplayer.mdns + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.charset.StandardCharsets + +class DnsWriter { + private val data = mutableListOf<Byte>() + private val namePositions = mutableMapOf<String, Int>() + + fun toByteArray(): ByteArray = data.toByteArray() + + fun writePacket( + header: DnsPacketHeader, + questionCount: Int? = null, questionWriter: ((DnsWriter, Int) -> Unit)? = null, + answerCount: Int? = null, answerWriter: ((DnsWriter, Int) -> Unit)? = null, + authorityCount: Int? = null, authorityWriter: ((DnsWriter, Int) -> Unit)? = null, + additionalsCount: Int? = null, additionalWriter: ((DnsWriter, Int) -> Unit)? = null + ) { + if (questionCount != null && questionWriter == null || questionCount == null && questionWriter != null) + throw Exception("When question count is given, question writer should also be given.") + if (answerCount != null && answerWriter == null || answerCount == null && answerWriter != null) + throw Exception("When answer count is given, answer writer should also be given.") + if (authorityCount != null && authorityWriter == null || authorityCount == null && authorityWriter != null) + throw Exception("When authority count is given, authority writer should also be given.") + if (additionalsCount != null && additionalWriter == null || additionalsCount == null && additionalWriter != null) + throw Exception("When additionals count is given, additional writer should also be given.") + + writeHeader(header, questionCount ?: 0, answerCount ?: 0, authorityCount ?: 0, additionalsCount ?: 0) + + repeat(questionCount ?: 0) { questionWriter?.invoke(this, it) } + repeat(answerCount ?: 0) { answerWriter?.invoke(this, it) } + repeat(authorityCount ?: 0) { authorityWriter?.invoke(this, it) } + repeat(additionalsCount ?: 0) { additionalWriter?.invoke(this, it) } + } + + fun writeHeader(header: DnsPacketHeader, questionCount: Int, answerCount: Int, authorityCount: Int, additionalsCount: Int) { + write(header.identifier) + + var flags: UShort = 0u + flags = flags or ((header.queryResponse.toUInt() and 0xFFFFu) shl 15).toUShort() + flags = flags or ((header.opcode.toUInt() and 0xFFFFu) shl 11).toUShort() + flags = flags or ((if (header.authoritativeAnswer) 1u else 0u) shl 10).toUShort() + flags = flags or ((if (header.truncated) 1u else 0u) shl 9).toUShort() + flags = flags or ((if (header.recursionDesired) 1u else 0u) shl 8).toUShort() + flags = flags or ((if (header.recursionAvailable) 1u else 0u) shl 7).toUShort() + flags = flags or ((if (header.answerAuthenticated) 1u else 0u) shl 5).toUShort() + flags = flags or ((if (header.nonAuthenticatedData) 1u else 0u) shl 4).toUShort() + flags = flags or header.responseCode.value.toUShort() + write(flags) + + write(questionCount.toUShort()) + write(answerCount.toUShort()) + write(authorityCount.toUShort()) + write(additionalsCount.toUShort()) + } + + fun writeDomainName(name: String) { + synchronized(namePositions) { + val labels = name.split('.') + for (label in labels) { + val nameAtOffset = name.substring(name.indexOf(label)) + if (namePositions.containsKey(nameAtOffset)) { + val position = namePositions[nameAtOffset]!! + val pointer = (0b11000000_00000000 or position).toUShort() + write(pointer) + return + } + if (label.isNotEmpty()) { + val labelBytes = label.toByteArray(StandardCharsets.UTF_8) + val nameStartPos = data.size + write(labelBytes.size.toByte()) + write(labelBytes) + namePositions[nameAtOffset] = nameStartPos + } + } + write(0.toByte()) // End of domain name + } + } + + fun write(value: DnsResourceRecord, dataWriter: (DnsWriter) -> Unit) { + writeDomainName(value.name) + write(value.type.toUShort()) + val cls = ((if (value.cacheFlush) 1u else 0u) shl 15).toUShort() or value.clazz.toUShort() + write(cls) + write(value.timeToLive) + + val lengthOffset = data.size + write(0.toUShort()) + dataWriter(this) + val rdLength = data.size - lengthOffset - 2 + val rdLengthBytes = ByteBuffer.allocate(2).order(ByteOrder.BIG_ENDIAN).putShort(rdLength.toShort()).array() + data[lengthOffset] = rdLengthBytes[0] + data[lengthOffset + 1] = rdLengthBytes[1] + } + + fun write(value: DnsQuestion) { + writeDomainName(value.name) + write(value.type.toUShort()) + write(((if (value.queryUnicast) 1u else 0u shl 15).toUShort() or value.clazz.toUShort())) + } + + fun write(value: Double) { + val bytes = ByteBuffer.allocate(Double.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putDouble(value).array() + write(bytes) + } + + fun write(value: Short) { + val bytes = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putShort(value).array() + write(bytes) + } + + fun write(value: Int) { + val bytes = ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value).array() + write(bytes) + } + + fun write(value: Long) { + val bytes = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value).array() + write(bytes) + } + + fun write(value: Float) { + val bytes = ByteBuffer.allocate(Float.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putFloat(value).array() + write(bytes) + } + + fun write(value: Byte) { + data.add(value) + } + + fun write(value: ByteArray) { + data.addAll(value.asIterable()) + } + + fun write(value: ByteArray, offset: Int, length: Int) { + data.addAll(value.slice(offset until offset + length)) + } + + fun write(value: UShort) { + val bytes = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putShort(value.toShort()).array() + write(bytes) + } + + fun write(value: UInt) { + val bytes = ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value.toInt()).array() + write(bytes) + } + + fun write(value: ULong) { + val bytes = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value.toLong()).array() + write(bytes) + } + + fun write(value: String) { + val bytes = value.toByteArray(StandardCharsets.UTF_8) + write(bytes.size.toByte()) + write(bytes) + } + + fun write(value: PTRRecord) { + writeDomainName(value.domainName) + } + + fun write(value: ARecord) { + val bytes = value.address.address + if (bytes.size != 4) throw Exception("Unexpected amount of address bytes.") + write(bytes) + } + + fun write(value: AAAARecord) { + val bytes = value.address.address + if (bytes.size != 16) throw Exception("Unexpected amount of address bytes.") + write(bytes) + } + + fun write(value: TXTRecord) { + value.texts.forEach { + val bytes = it.toByteArray(StandardCharsets.UTF_8) + write(bytes.size.toByte()) + write(bytes) + } + } + + fun write(value: SRVRecord) { + write(value.priority) + write(value.weight) + write(value.port) + writeDomainName(value.target) + } + + fun write(value: NSECRecord) { + writeDomainName(value.ownerName) + value.typeBitMaps.forEach { (windowBlock, bitmap) -> + write(windowBlock) + write(bitmap.size.toByte()) + write(bitmap) + } + } + + fun write(value: OPTRecord) { + value.options.forEach { option -> + write(option.code) + write(option.data.size.toUShort()) + write(option.data) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/mdns/Extensions.kt b/app/src/main/java/com/futo/platformplayer/mdns/Extensions.kt new file mode 100644 index 0000000000000000000000000000000000000000..48bb4c6a4b3787818d95b60cdae6b54230d59d68 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/Extensions.kt @@ -0,0 +1,63 @@ +package com.futo.platformplayer.mdns + +import android.util.Log + +object Extensions { + fun ByteArray.toByteDump(): String { + val result = StringBuilder() + for (i in indices) { + result.append(String.format("%02X ", this[i])) + + if ((i + 1) % 16 == 0 || i == size - 1) { + val padding = 3 * (16 - (i % 16 + 1)) + if (i == size - 1 && (i + 1) % 16 != 0) result.append(" ".repeat(padding)) + + result.append("; ") + val start = i - (i % 16) + val end = minOf(i, size - 1) + for (j in start..end) { + val ch = if (this[j] in 32..127) this[j].toChar() else '.' + result.append(ch) + } + if (i != size - 1) result.appendLine() + } + } + return result.toString() + } + + fun UByteArray.readDomainName(startPosition: Int): Pair<String, Int> { + var position = startPosition + return readDomainName(position, 0) + } + + private fun UByteArray.readDomainName(position: Int, depth: Int = 0): Pair<String, Int> { + if (depth > 16) throw Exception("Exceeded maximum recursion depth in DNS packet. Possible circular reference.") + + val domainParts = mutableListOf<String>() + var newPosition = position + + while (true) { + if (newPosition < 0) + println() + + val length = this[newPosition].toUByte() + if ((length and 0b11000000u).toUInt() == 0b11000000u) { + val offset = (((length and 0b00111111u).toUInt()) shl 8) or this[newPosition + 1].toUInt() + val (part, _) = this.readDomainName(offset.toInt(), depth + 1) + domainParts.add(part) + newPosition += 2 + break + } else if (length.toUInt() == 0u) { + newPosition++ + break + } else { + newPosition++ + val part = String(this.asByteArray(), newPosition, length.toInt(), Charsets.UTF_8) + domainParts.add(part) + newPosition += length.toInt() + } + } + + return domainParts.joinToString(".") to newPosition + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt b/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt new file mode 100644 index 0000000000000000000000000000000000000000..494c4934fe6f6bc173c6a34a17116e901f8280a9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt @@ -0,0 +1,482 @@ +package com.futo.platformplayer.mdns + +import com.futo.platformplayer.logging.Logger +import kotlinx.coroutines.* +import java.net.* +import java.util.* +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +class MDNSListener { + companion object { + private val TAG = "MDNSListener" + const val MulticastPort = 5353 + val MulticastAddressIPv4: InetAddress = InetAddress.getByName("224.0.0.251") + val MulticastAddressIPv6: InetAddress = InetAddress.getByName("FF02::FB") + val MdnsEndpointIPv6: InetSocketAddress = InetSocketAddress(MulticastAddressIPv6, MulticastPort) + val MdnsEndpointIPv4: InetSocketAddress = InetSocketAddress(MulticastAddressIPv4, MulticastPort) + } + + private val _lockObject = ReentrantLock() + private var _receiver4: DatagramSocket? = null + private var _receiver6: DatagramSocket? = null + private val _senders = mutableListOf<DatagramSocket>() + private val _nicMonitor = NICMonitor() + private val _serviceRecordAggregator = ServiceRecordAggregator() + private var _started = false + private var _threadReceiver4: Thread? = null + private var _threadReceiver6: Thread? = null + private var _scope: CoroutineScope? = null + + var onPacket: ((DnsPacket) -> Unit)? = null + var onServicesUpdated: ((List<DnsService>) -> Unit)? = null + + private val _recordLockObject = ReentrantLock() + private val _recordsA = mutableListOf<Pair<DnsResourceRecord, ARecord>>() + private val _recordsAAAA = mutableListOf<Pair<DnsResourceRecord, AAAARecord>>() + private val _recordsPTR = mutableListOf<Pair<DnsResourceRecord, PTRRecord>>() + private val _recordsTXT = mutableListOf<Pair<DnsResourceRecord, TXTRecord>>() + private val _recordsSRV = mutableListOf<Pair<DnsResourceRecord, SRVRecord>>() + private val _services = mutableListOf<BroadcastService>() + + init { + _nicMonitor.added = { onNicsAdded(it) } + _nicMonitor.removed = { onNicsRemoved(it) } + _serviceRecordAggregator.onServicesUpdated = { onServicesUpdated?.invoke(it) } + } + + fun start() { + if (_started) throw Exception("Already running.") + _started = true + + _scope = CoroutineScope(Dispatchers.IO); + + Logger.i(TAG, "Starting") + _lockObject.withLock { + val receiver4 = DatagramSocket(null).apply { + reuseAddress = true + bind(InetSocketAddress(InetAddress.getByName("0.0.0.0"), MulticastPort)) + } + _receiver4 = receiver4 + + val receiver6 = DatagramSocket(null).apply { + reuseAddress = true + bind(InetSocketAddress(InetAddress.getByName("::"), MulticastPort)) + } + _receiver6 = receiver6 + + _nicMonitor.start() + _serviceRecordAggregator.start() + onNicsAdded(_nicMonitor.current) + + _threadReceiver4 = Thread { + receiveLoop(receiver4) + }.apply { start() } + + _threadReceiver6 = Thread { + receiveLoop(receiver6) + }.apply { start() } + } + } + + fun queryServices(names: Array<String>) { + if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.") + + val writer = DnsWriter() + writer.writePacket( + DnsPacketHeader( + identifier = 0u, + queryResponse = QueryResponse.Query.value.toInt(), + opcode = DnsOpcode.StandardQuery.value.toInt(), + truncated = false, + nonAuthenticatedData = false, + recursionDesired = false, + answerAuthenticated = false, + authoritativeAnswer = false, + recursionAvailable = false, + responseCode = DnsResponseCode.NoError + ), + questionCount = names.size, + questionWriter = { w, i -> + w.write( + DnsQuestion( + name = names[i], + type = QuestionType.PTR.value.toInt(), + clazz = QuestionClass.IN.value.toInt(), + queryUnicast = false + ) + ) + } + ) + + send(writer.toByteArray()) + } + + private fun send(data: ByteArray) { + _lockObject.withLock { + for (sender in _senders) { + try { + val endPoint = if (sender.localAddress is Inet4Address) MdnsEndpointIPv4 else MdnsEndpointIPv6 + sender.send(DatagramPacket(data, data.size, endPoint)) + } catch (e: Exception) { + Logger.i(TAG, "Failed to send on ${sender.localSocketAddress}: ${e.message}.") + } + } + } + } + + fun queryAllQuestions(names: Array<String>) { + if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.") + + val questions = names.flatMap { _serviceRecordAggregator.getAllQuestions(it) } + questions.groupBy { it.name }.forEach { (_, questionsForHost) -> + val writer = DnsWriter() + writer.writePacket( + DnsPacketHeader( + identifier = 0u, + queryResponse = QueryResponse.Query.value.toInt(), + opcode = DnsOpcode.StandardQuery.value.toInt(), + truncated = false, + nonAuthenticatedData = false, + recursionDesired = false, + answerAuthenticated = false, + authoritativeAnswer = false, + recursionAvailable = false, + responseCode = DnsResponseCode.NoError + ), + questionCount = questionsForHost.size, + questionWriter = { w, i -> w.write(questionsForHost[i]) } + ) + send(writer.toByteArray()) + } + } + + private fun onNicsAdded(nics: List<NetworkInterface>) { + _lockObject.withLock { + if (!_started) return + + val addresses = nics.flatMap { nic -> + nic.interfaceAddresses.map { it.address } + .filter { it is Inet4Address || (it is Inet6Address && it.isLinkLocalAddress) } + } + + addresses.forEach { address -> + Logger.i(TAG, "New address discovered $address") + + try { + when (address) { + is Inet4Address -> { + val sender = MulticastSocket(null).apply { + reuseAddress = true + bind(InetSocketAddress(address, MulticastPort)) + joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), NetworkInterface.getByInetAddress(address)) + } + _senders.add(sender) + } + + is Inet6Address -> { + val sender = MulticastSocket(null).apply { + reuseAddress = true + bind(InetSocketAddress(address, MulticastPort)) + joinGroup(InetSocketAddress(MulticastAddressIPv6, MulticastPort), NetworkInterface.getByInetAddress(address)) + } + _senders.add(sender) + } + + else -> throw UnsupportedOperationException("Address type ${address.javaClass.name} is not supported.") + } + } catch (e: Exception) { + Logger.i(TAG, "Exception occurred when processing added address $address: ${e.message}.") + // Close the socket if there was an error + (_senders.lastOrNull() as? MulticastSocket)?.close() + } + } + } + + if (nics.isNotEmpty()) { + try { + updateBroadcastRecords() + broadcastRecords() + } catch (e: Exception) { + Logger.i(TAG, "Exception occurred when broadcasting records: ${e.message}.") + } + } + } + + private fun onNicsRemoved(nics: List<NetworkInterface>) { + _lockObject.withLock { + if (!_started) return + //TODO: Cleanup? + } + + if (nics.isNotEmpty()) { + try { + updateBroadcastRecords() + broadcastRecords() + } catch (e: Exception) { + Logger.e(TAG, "Exception occurred when broadcasting records", e) + } + } + } + + private fun receiveLoop(client: DatagramSocket) { + Logger.i(TAG, "Started receive loop") + + val buffer = ByteArray(1024) + val packet = DatagramPacket(buffer, buffer.size) + while (_started) { + try { + client.receive(packet) + handleResult(packet) + } catch (e: Exception) { + Logger.e(TAG, "An exception occurred while handling UDP result:", e) + } + } + + Logger.i(TAG, "Stopped receive loop") + } + + fun broadcastService( + deviceName: String, + serviceName: String, + port: UShort, + ttl: UInt = 120u, + weight: UShort = 0u, + priority: UShort = 0u, + texts: List<String>? = null + ) { + _recordLockObject.withLock { + _services.add( + BroadcastService( + deviceName = deviceName, + port = port, + priority = priority, + serviceName = serviceName, + texts = texts, + ttl = ttl, + weight = weight + ) + ) + } + + updateBroadcastRecords() + broadcastRecords() + } + + private fun updateBroadcastRecords() { + _recordLockObject.withLock { + _recordsSRV.clear() + _recordsPTR.clear() + _recordsA.clear() + _recordsAAAA.clear() + _recordsTXT.clear() + + _services.forEach { service -> + val id = UUID.randomUUID().toString() + val deviceDomainName = "${service.deviceName}.${service.serviceName}" + val addressName = "$id.local" + + _recordsSRV.add( + DnsResourceRecord( + clazz = ResourceRecordClass.IN.value.toInt(), + type = ResourceRecordType.SRV.value.toInt(), + timeToLive = service.ttl, + name = deviceDomainName, + cacheFlush = false + ) to SRVRecord( + target = addressName, + port = service.port, + priority = service.priority, + weight = service.weight + ) + ) + + _recordsPTR.add( + DnsResourceRecord( + clazz = ResourceRecordClass.IN.value.toInt(), + type = ResourceRecordType.PTR.value.toInt(), + timeToLive = service.ttl, + name = service.serviceName, + cacheFlush = false + ) to PTRRecord( + domainName = deviceDomainName + ) + ) + + val addresses = _nicMonitor.current.flatMap { nic -> + nic.interfaceAddresses.map { it.address } + } + + addresses.forEach { address -> + when (address) { + is Inet4Address -> _recordsA.add( + DnsResourceRecord( + clazz = ResourceRecordClass.IN.value.toInt(), + type = ResourceRecordType.A.value.toInt(), + timeToLive = service.ttl, + name = addressName, + cacheFlush = false + ) to ARecord( + address = address + ) + ) + + is Inet6Address -> _recordsAAAA.add( + DnsResourceRecord( + clazz = ResourceRecordClass.IN.value.toInt(), + type = ResourceRecordType.AAAA.value.toInt(), + timeToLive = service.ttl, + name = addressName, + cacheFlush = false + ) to AAAARecord( + address = address + ) + ) + + else -> Logger.i(TAG, "Invalid address type: $address.") + } + } + + if (service.texts != null) { + _recordsTXT.add( + DnsResourceRecord( + clazz = ResourceRecordClass.IN.value.toInt(), + type = ResourceRecordType.TXT.value.toInt(), + timeToLive = service.ttl, + name = deviceDomainName, + cacheFlush = false + ) to TXTRecord( + texts = service.texts + ) + ) + } + } + } + } + + private fun broadcastRecords(questions: List<DnsQuestion>? = null) { + val writer = DnsWriter() + _recordLockObject.withLock { + val recordsA: List<Pair<DnsResourceRecord, ARecord>> + val recordsAAAA: List<Pair<DnsResourceRecord, AAAARecord>> + val recordsPTR: List<Pair<DnsResourceRecord, PTRRecord>> + val recordsTXT: List<Pair<DnsResourceRecord, TXTRecord>> + val recordsSRV: List<Pair<DnsResourceRecord, SRVRecord>> + + if (questions != null) { + recordsA = _recordsA.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } } + recordsAAAA = _recordsAAAA.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } } + recordsPTR = _recordsPTR.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } } + recordsSRV = _recordsSRV.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } } + recordsTXT = _recordsTXT.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } } + } else { + recordsA = _recordsA + recordsAAAA = _recordsAAAA + recordsPTR = _recordsPTR + recordsSRV = _recordsSRV + recordsTXT = _recordsTXT + } + + val answerCount = recordsA.size + recordsAAAA.size + recordsPTR.size + recordsSRV.size + recordsTXT.size + if (answerCount < 1) return + + val txtOffset = recordsA.size + recordsAAAA.size + recordsPTR.size + recordsSRV.size + val srvOffset = recordsA.size + recordsAAAA.size + recordsPTR.size + val ptrOffset = recordsA.size + recordsAAAA.size + val aaaaOffset = recordsA.size + + writer.writePacket( + DnsPacketHeader( + identifier = 0u, + queryResponse = QueryResponse.Response.value.toInt(), + opcode = DnsOpcode.StandardQuery.value.toInt(), + truncated = false, + nonAuthenticatedData = false, + recursionDesired = false, + answerAuthenticated = false, + authoritativeAnswer = true, + recursionAvailable = false, + responseCode = DnsResponseCode.NoError + ), + answerCount = answerCount, + answerWriter = { w, i -> + when { + i >= txtOffset -> { + val record = recordsTXT[i - txtOffset] + w.write(record.first) { it.write(record.second) } + } + + i >= srvOffset -> { + val record = recordsSRV[i - srvOffset] + w.write(record.first) { it.write(record.second) } + } + + i >= ptrOffset -> { + val record = recordsPTR[i - ptrOffset] + w.write(record.first) { it.write(record.second) } + } + + i >= aaaaOffset -> { + val record = recordsAAAA[i - aaaaOffset] + w.write(record.first) { it.write(record.second) } + } + + else -> { + val record = recordsA[i] + w.write(record.first) { it.write(record.second) } + } + } + } + ) + } + + send(writer.toByteArray()) + } + + private fun handleResult(result: DatagramPacket) { + try { + val packet = DnsPacket.parse(result.data) + if (packet.questions.isNotEmpty()) { + _scope?.launch(Dispatchers.IO) { + try { + broadcastRecords(packet.questions) + } catch (e: Throwable) { + Logger.i(TAG, "Broadcasting records failed", e) + } + } + + } + _serviceRecordAggregator.add(packet) + onPacket?.invoke(packet) + } catch (e: Exception) { + Logger.v(TAG, "Failed to handle packet: ${Base64.getEncoder().encodeToString(result.data.slice(IntRange(0, result.length - 1)).toByteArray())}", e) + } + } + + fun stop() { + _lockObject.withLock { + _started = false + + _scope?.cancel() + _scope = null + + _nicMonitor.stop() + _serviceRecordAggregator.stop() + + _receiver4?.close() + _receiver4 = null + + _receiver6?.close() + _receiver6 = null + + _senders.forEach { it.close() } + _senders.clear() + } + + _threadReceiver4?.join() + _threadReceiver4 = null + + _threadReceiver6?.join() + _threadReceiver6 = null + } +} diff --git a/app/src/main/java/com/futo/platformplayer/mdns/NICMonitor.kt b/app/src/main/java/com/futo/platformplayer/mdns/NICMonitor.kt new file mode 100644 index 0000000000000000000000000000000000000000..884e15143094015137f3d40fc759d7c44fb87ad7 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/NICMonitor.kt @@ -0,0 +1,66 @@ +package com.futo.platformplayer.mdns + +import kotlinx.coroutines.* +import java.net.NetworkInterface + +class NICMonitor { + private val lockObject = Any() + private val nics = mutableListOf<NetworkInterface>() + private var cts: Job? = null + + val current: List<NetworkInterface> + get() = synchronized(nics) { nics.toList() } + + var added: ((List<NetworkInterface>) -> Unit)? = null + var removed: ((List<NetworkInterface>) -> Unit)? = null + + fun start() { + synchronized(lockObject) { + if (cts != null) throw Exception("Already started.") + + cts = CoroutineScope(Dispatchers.Default).launch { + loopAsync() + } + } + + nics.clear() + nics.addAll(getCurrentInterfaces().toList()) + } + + fun stop() { + synchronized(lockObject) { + cts?.cancel() + cts = null + } + + synchronized(nics) { + nics.clear() + } + } + + private suspend fun loopAsync() { + while (cts?.isActive == true) { + try { + val currentNics = getCurrentInterfaces().toList() + removed?.invoke(nics.filter { k -> currentNics.none { n -> k.name == n.name } }) + added?.invoke(currentNics.filter { nic -> nics.none { k -> k.name == nic.name } }) + + synchronized(nics) { + nics.clear() + nics.addAll(currentNics) + } + } catch (ex: Exception) { + // Ignored + } + delay(5000) + } + } + + private fun getCurrentInterfaces(): List<NetworkInterface> { + val nics = NetworkInterface.getNetworkInterfaces().toList() + .filter { it.isUp && !it.isLoopback } + + return if (nics.isNotEmpty()) nics else NetworkInterface.getNetworkInterfaces().toList() + .filter { it.isUp } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/mdns/ServiceDiscoverer.kt b/app/src/main/java/com/futo/platformplayer/mdns/ServiceDiscoverer.kt new file mode 100644 index 0000000000000000000000000000000000000000..79d29736ec83132c94ffb6ec1aa16f5b4a0fe058 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/ServiceDiscoverer.kt @@ -0,0 +1,68 @@ +package com.futo.platformplayer.mdns + +import com.futo.platformplayer.logging.Logger +import java.lang.Thread.sleep + +class ServiceDiscoverer(names: Array<String>, private val _onServicesUpdated: (List<DnsService>) -> Unit) { + private val _names: Array<String> + private var _listener: MDNSListener? = null + private var _started = false + private var _thread: Thread? = null + + init { + if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.") + _names = names + } + + fun broadcastService( + deviceName: String, + serviceName: String, + port: UShort, + ttl: UInt = 120u, + weight: UShort = 0u, + priority: UShort = 0u, + texts: List<String>? = null + ) { + _listener?.let { + it.broadcastService(deviceName, serviceName, port, ttl, weight, priority, texts) + } + } + + fun stop() { + _started = false + _listener?.stop() + _listener = null + _thread?.join() + _thread = null + } + + fun start() { + if (_started) throw Exception("Already running.") + _started = true + + val listener = MDNSListener() + _listener = listener + listener.onServicesUpdated = { _onServicesUpdated?.invoke(it) } + listener.start() + + _thread = Thread { + try { + sleep(2000) + + while (_started) { + listener.queryServices(_names) + sleep(2000) + listener.queryAllQuestions(_names) + sleep(2000) + } + } catch (e: Throwable) { + Logger.i(TAG, "Exception in loop thread", e) + stop() + } + }.apply { start() } + } + + companion object { + private val TAG = "ServiceDiscoverer" + } +} diff --git a/app/src/main/java/com/futo/platformplayer/mdns/ServiceRecordAggregator.kt b/app/src/main/java/com/futo/platformplayer/mdns/ServiceRecordAggregator.kt new file mode 100644 index 0000000000000000000000000000000000000000..5ff21a2c2908126b8f391b806e31cb9520e46329 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/ServiceRecordAggregator.kt @@ -0,0 +1,219 @@ +package com.futo.platformplayer.mdns + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.net.InetAddress +import java.util.Date + +data class DnsService( + var name: String, + var target: String, + var port: UShort, + val addresses: MutableList<InetAddress> = mutableListOf(), + val pointers: MutableList<String> = mutableListOf(), + val texts: MutableList<String> = mutableListOf() +) + +data class CachedDnsAddressRecord( + val expirationTime: Date, + val address: InetAddress +) + +data class CachedDnsTxtRecord( + val expirationTime: Date, + val texts: List<String> +) + +data class CachedDnsPtrRecord( + val expirationTime: Date, + val target: String +) + +data class CachedDnsSrvRecord( + val expirationTime: Date, + val service: SRVRecord +) + +class ServiceRecordAggregator { + private val _lockObject = Any() + private val _cachedAddressRecords = mutableMapOf<String, MutableList<CachedDnsAddressRecord>>() + private val _cachedTxtRecords = mutableMapOf<String, CachedDnsTxtRecord>() + private val _cachedPtrRecords = mutableMapOf<String, MutableList<CachedDnsPtrRecord>>() + private val _cachedSrvRecords = mutableMapOf<String, CachedDnsSrvRecord>() + private val _currentServices = mutableListOf<DnsService>() + private var _cts: Job? = null + + var onServicesUpdated: ((List<DnsService>) -> Unit)? = null + + fun start() { + synchronized(_lockObject) { + if (_cts != null) throw Exception("Already started.") + + _cts = CoroutineScope(Dispatchers.Default).launch { + while (isActive) { + val now = Date() + synchronized(_currentServices) { + _cachedAddressRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } } + _cachedTxtRecords.entries.removeIf { now.after(it.value.expirationTime) } + _cachedSrvRecords.entries.removeIf { now.after(it.value.expirationTime) } + _cachedPtrRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } } + + val newServices = getCurrentServices() + _currentServices.clear() + _currentServices.addAll(newServices) + } + + onServicesUpdated?.invoke(_currentServices) + delay(5000) + } + } + } + } + + fun stop() { + synchronized(_lockObject) { + _cts?.cancel() + _cts = null + } + } + + fun add(packet: DnsPacket) { + val dnsResourceRecords = packet.answers + packet.additionals + packet.authorities + val txtRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.TXT.value.toInt() }.map { it to it.getDataReader().readTXTRecord() } + val aRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.A.value.toInt() }.map { it to it.getDataReader().readARecord() } + val aaaaRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.AAAA.value.toInt() }.map { it to it.getDataReader().readAAAARecord() } + val srvRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.SRV.value.toInt() }.map { it to it.getDataReader().readSRVRecord() } + val ptrRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.PTR.value.toInt() }.map { it to it.getDataReader().readPTRRecord() } + + /*val builder = StringBuilder() + builder.appendLine("Received records:") + srvRecords.forEach { builder.appendLine(" ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: (Port: ${it.second.port}, Target: ${it.second.target}, Priority: ${it.second.priority}, Weight: ${it.second.weight})") } + ptrRecords.forEach { builder.appendLine(" ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.domainName}") } + txtRecords.forEach { builder.appendLine(" ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.texts.joinToString(", ")}") } + aRecords.forEach { builder.appendLine(" ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") } + aaaaRecords.forEach { builder.appendLine(" ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") } + synchronized(lockObject) { + // Save to file if necessary + }*/ + + val currentServices: MutableList<DnsService> + synchronized(this._currentServices) { + ptrRecords.forEach { record -> + val cachedPtrRecord = _cachedPtrRecords.getOrPut(record.first.name) { mutableListOf() } + val newPtrRecord = CachedDnsPtrRecord(Date(System.currentTimeMillis() + record.first.timeToLive.toLong() * 1000L), record.second.domainName) + cachedPtrRecord.replaceOrAdd(newPtrRecord) { it.target == record.second.domainName } + + aRecords.forEach { aRecord -> + val cachedARecord = _cachedAddressRecords.getOrPut(aRecord.first.name) { mutableListOf() } + val newARecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aRecord.first.timeToLive.toLong() * 1000L), aRecord.second.address) + cachedARecord.replaceOrAdd(newARecord) { it.address == newARecord.address } + } + + aaaaRecords.forEach { aaaaRecord -> + val cachedAaaaRecord = _cachedAddressRecords.getOrPut(aaaaRecord.first.name) { mutableListOf() } + val newAaaaRecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aaaaRecord.first.timeToLive.toLong() * 1000L), aaaaRecord.second.address) + cachedAaaaRecord.replaceOrAdd(newAaaaRecord) { it.address == newAaaaRecord.address } + } + } + + txtRecords.forEach { txtRecord -> + _cachedTxtRecords[txtRecord.first.name] = CachedDnsTxtRecord(Date(System.currentTimeMillis() + txtRecord.first.timeToLive.toLong() * 1000L), txtRecord.second.texts) + } + + srvRecords.forEach { srvRecord -> + _cachedSrvRecords[srvRecord.first.name] = CachedDnsSrvRecord(Date(System.currentTimeMillis() + srvRecord.first.timeToLive.toLong() * 1000L), srvRecord.second) + } + + currentServices = getCurrentServices() + this._currentServices.clear() + this._currentServices.addAll(currentServices) + } + + onServicesUpdated?.invoke(currentServices) + } + + fun getAllQuestions(serviceName: String): List<DnsQuestion> { + val questions = mutableListOf<DnsQuestion>() + synchronized(_currentServices) { + val servicePtrRecords = _cachedPtrRecords[serviceName] ?: return emptyList() + + val ptrWithoutSrvRecord = servicePtrRecords.filterNot { _cachedSrvRecords.containsKey(it.target) }.map { it.target } + questions.addAll(ptrWithoutSrvRecord.flatMap { s -> + listOf( + DnsQuestion( + name = s, + type = QuestionType.SRV.value.toInt(), + clazz = QuestionClass.IN.value.toInt(), + queryUnicast = false + ) + ) + }) + + val incompleteCurrentServices = _currentServices.filter { it.addresses.isEmpty() && it.name.endsWith(serviceName) } + questions.addAll(incompleteCurrentServices.flatMap { s -> + listOf( + DnsQuestion( + name = s.name, + type = QuestionType.TXT.value.toInt(), + clazz = QuestionClass.IN.value.toInt(), + queryUnicast = false + ), + DnsQuestion( + name = s.target, + type = QuestionType.A.value.toInt(), + clazz = QuestionClass.IN.value.toInt(), + queryUnicast = false + ), + DnsQuestion( + name = s.target, + type = QuestionType.AAAA.value.toInt(), + clazz = QuestionClass.IN.value.toInt(), + queryUnicast = false + ) + ) + }) + } + return questions + } + + private fun getCurrentServices(): MutableList<DnsService> { + val currentServices = _cachedSrvRecords.map { (key, value) -> + DnsService( + name = key, + target = value.service.target, + port = value.service.port + ) + }.toMutableList() + + currentServices.forEach { service -> + _cachedAddressRecords[service.target]?.let { + service.addresses.addAll(it.map { record -> record.address }) + } + } + + currentServices.forEach { service -> + service.pointers.addAll(_cachedPtrRecords.filter { it.value.any { ptr -> ptr.target == service.name } }.map { it.key }) + } + + currentServices.forEach { service -> + _cachedTxtRecords[service.name]?.let { + service.texts.addAll(it.texts) + } + } + + return currentServices + } + + private inline fun <T> MutableList<T>.replaceOrAdd(newElement: T, predicate: (T) -> Boolean) { + val index = indexOfFirst(predicate) + if (index >= 0) { + this[index] = newElement + } else { + add(newElement) + } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/FeedStyle.kt b/app/src/main/java/com/futo/platformplayer/views/FeedStyle.kt index c261b7552afbccd351fc04e9841cd8a08525e751..9750f3827930864dfb4462c47c9e233010a84cb0 100644 --- a/app/src/main/java/com/futo/platformplayer/views/FeedStyle.kt +++ b/app/src/main/java/com/futo/platformplayer/views/FeedStyle.kt @@ -16,7 +16,7 @@ enum class FeedStyle(val value: Int) { fun fromInt(value: Int): FeedStyle { - val result = FeedStyle.values().firstOrNull { it.value == value }; + val result = FeedStyle.entries.firstOrNull { it.value == value }; if(result == null) throw UnknownPlatformException(value.toString()); return result; diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuItem.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuItem.kt index 373132c403440bebc0cf9a5c5f6a691b13cb198e..8e5da9903e67a204e01638491e12e36424246557 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuItem.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuItem.kt @@ -6,14 +6,17 @@ import android.view.LayoutInflater import android.widget.ImageView import android.widget.RelativeLayout import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible import com.futo.platformplayer.R -class SlideUpMenuItem : RelativeLayout { +class SlideUpMenuItem : ConstraintLayout { - private lateinit var _root: RelativeLayout; + private lateinit var _root: ConstraintLayout; private lateinit var _image: ImageView; private lateinit var _text: TextView; private lateinit var _subtext: TextView; + private lateinit var _description: TextView; var selectedOption: Boolean = false; @@ -25,11 +28,27 @@ class SlideUpMenuItem : RelativeLayout { init(); } - constructor(context: Context, imageRes: Int = 0, mainText: String, subText: String = "", tag: Any?, call: (()->Unit)? = null, invokeParent: Boolean = true): super(context){ + constructor( + context: Context, + imageRes: Int = 0, + mainText: String, + subText: String = "", + description: String? = "", + tag: Any?, + call: (() -> Unit)? = null, + invokeParent: Boolean = true + ): super(context){ init(); _image.setImageResource(imageRes); _text.text = mainText; _subtext.text = subText; + + if(description.isNullOrEmpty()) + _description.isVisible = false; + else { + _description.text = description; + _description.isVisible = true; + } this.itemTag = tag; if (call != null) { @@ -48,6 +67,7 @@ class SlideUpMenuItem : RelativeLayout { _image = findViewById(R.id.slide_up_menu_item_image); _text = findViewById(R.id.slide_up_menu_item_text); _subtext = findViewById(R.id.slide_up_menu_item_subtext); + _description = findViewById(R.id.slide_up_menu_item_description); setOptionSelected(false); } diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt index 21aca2a76c55eb07a31980ff624b100db2aa4bf0..82ad79440fc2b025eee255027bdaa517b34bc4e2 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt @@ -3,9 +3,14 @@ package com.futo.platformplayer.views.video import android.content.Context import android.net.Uri import android.util.AttributeSet +import android.util.Xml import android.widget.RelativeLayout import androidx.annotation.OptIn +import androidx.fragment.app.findFragment +import androidx.lifecycle.coroutineScope +import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.media3.common.C +import androidx.media3.common.C.Encoding import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player @@ -17,6 +22,8 @@ import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.HttpDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.dash.DashMediaSource +import androidx.media3.exoplayer.dash.manifest.DashManifest +import androidx.media3.exoplayer.dash.manifest.DashManifestParser import androidx.media3.exoplayer.drm.DefaultDrmSessionManagerProvider import androidx.media3.exoplayer.hls.HlsMediaSource import androidx.media3.exoplayer.source.MediaSource @@ -42,6 +49,9 @@ import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestMergingRawSource +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource @@ -52,12 +62,14 @@ import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.video.PlayerManager +import com.futo.platformplayer.views.video.datasources.JSHttpDataSource import com.google.gson.Gson import getHttpDataSourceFactory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.io.ByteArrayInputStream import java.io.File import kotlin.math.abs @@ -319,18 +331,37 @@ abstract class FutoVideoPlayerBase : RelativeLayout { swapSources(videoSource, audioSource,false, play, keepSubtitles); } fun swapSources(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true, keepSubtitles: Boolean = false): Boolean { - swapSourceInternal(videoSource); - swapSourceInternal(audioSource); + var videoSourceUsed = videoSource; + var audioSourceUsed = audioSource; + if(videoSource is JSDashManifestRawSource && audioSource is JSDashManifestRawAudioSource){ + videoSourceUsed = JSDashManifestMergingRawSource(videoSource, audioSource); + audioSourceUsed = null; + } + + val didSetVideo = swapSourceInternal(videoSourceUsed, play, resume); + val didSetAudio = swapSourceInternal(audioSourceUsed, play, resume); if(!keepSubtitles) _lastSubtitleMediaSource = null; - return loadSelectedSources(play, resume); + if(didSetVideo && didSetAudio) + return loadSelectedSources(play, resume); + else + return true; } fun swapSource(videoSource: IVideoSource?, resume: Boolean = true, play: Boolean = true): Boolean { - swapSourceInternal(videoSource); - return loadSelectedSources(play, resume); + var videoSourceUsed = videoSource; + if(videoSource is JSDashManifestRawSource && lastVideoSource is JSDashManifestMergingRawSource) + videoSourceUsed = JSDashManifestMergingRawSource(videoSource, (lastVideoSource as JSDashManifestMergingRawSource).audio); + val didSet = swapSourceInternal(videoSourceUsed, play, resume); + if(didSet) + return loadSelectedSources(play, resume); + else + return true; } fun swapSource(audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true): Boolean { - swapSourceInternal(audioSource); + if(audioSource is JSDashManifestRawAudioSource && lastVideoSource is JSDashManifestMergingRawSource) + swapSourceInternal(JSDashManifestMergingRawSource((lastVideoSource as JSDashManifestMergingRawSource).video, audioSource), play, resume); + else + swapSourceInternal(audioSource, play, resume); return loadSelectedSources(play, resume); } @@ -381,30 +412,34 @@ abstract class FutoVideoPlayerBase : RelativeLayout { } - private fun swapSourceInternal(videoSource: IVideoSource?) { + private fun swapSourceInternal(videoSource: IVideoSource?, play: Boolean, resume: Boolean): Boolean { _lastGeneratedDash = null; - when(videoSource) { - is LocalVideoSource -> swapVideoSourceLocal(videoSource); - is JSVideoUrlRangeSource -> swapVideoSourceUrlRange(videoSource); - is IDashManifestSource -> swapVideoSourceDash(videoSource); - is IHLSManifestSource -> swapVideoSourceHLS(videoSource); - is IVideoUrlSource -> swapVideoSourceUrl(videoSource); - null -> _lastVideoMediaSource = null; + val didSet = when(videoSource) { + is LocalVideoSource -> { swapVideoSourceLocal(videoSource); true; } + is JSVideoUrlRangeSource -> { swapVideoSourceUrlRange(videoSource); true; } + is IDashManifestSource -> { swapVideoSourceDash(videoSource); true;} + is JSDashManifestRawSource -> swapVideoSourceDashRaw(videoSource, play, resume); + is IHLSManifestSource -> { swapVideoSourceHLS(videoSource); true; } + is IVideoUrlSource -> { swapVideoSourceUrl(videoSource); true; } + null -> { _lastVideoMediaSource = null; true;} else -> throw IllegalArgumentException("Unsupported video source [${videoSource.javaClass.simpleName}]"); } lastVideoSource = videoSource; - } - private fun swapSourceInternal(audioSource: IAudioSource?) { - when(audioSource) { - is LocalAudioSource -> swapAudioSourceLocal(audioSource); - is JSAudioUrlRangeSource -> swapAudioSourceUrlRange(audioSource); - is JSHLSManifestAudioSource -> swapAudioSourceHLS(audioSource); - is IAudioUrlWidevineSource -> swapAudioSourceUrlWidevine(audioSource) - is IAudioUrlSource -> swapAudioSourceUrl(audioSource); - null -> _lastAudioMediaSource = null; + return didSet; + } + private fun swapSourceInternal(audioSource: IAudioSource?, play: Boolean, resume: Boolean): Boolean { + val didSet = when(audioSource) { + is LocalAudioSource -> {swapAudioSourceLocal(audioSource); true; } + is JSAudioUrlRangeSource -> { swapAudioSourceUrlRange(audioSource); true; } + is JSHLSManifestAudioSource -> { swapAudioSourceHLS(audioSource); true; } + is JSDashManifestRawAudioSource -> swapAudioSourceDashRaw(audioSource, play, resume); + is IAudioUrlWidevineSource -> { swapAudioSourceUrlWidevine(audioSource); true; } + is IAudioUrlSource -> { swapAudioSourceUrl(audioSource); true; } + null -> { _lastAudioMediaSource = null; true; } else -> throw IllegalArgumentException("Unsupported video source [${audioSource.javaClass.simpleName}]"); } lastAudioSource = audioSource; + return didSet; } //Video loads @@ -441,7 +476,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { @OptIn(UnstableApi::class) private fun swapVideoSourceUrl(videoSource: IVideoUrlSource) { Logger.i(TAG, "Loading VideoSource [Url]"); - val dataSource = if(videoSource is JSSource && videoSource.hasRequestModifier) + val dataSource = if(videoSource is JSSource && videoSource.requiresCustomDatasource) videoSource.getHttpDataSourceFactory() else DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT); @@ -451,7 +486,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { @OptIn(UnstableApi::class) private fun swapVideoSourceDash(videoSource: IDashManifestSource) { Logger.i(TAG, "Loading VideoSource [Dash]"); - val dataSource = if(videoSource is JSSource && videoSource.hasRequestModifier) + val dataSource = if(videoSource is JSSource && (videoSource.requiresCustomDatasource)) videoSource.getHttpDataSourceFactory() else DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT); @@ -459,9 +494,60 @@ abstract class FutoVideoPlayerBase : RelativeLayout { .createMediaSource(MediaItem.fromUri(videoSource.url)) } @OptIn(UnstableApi::class) + private fun swapVideoSourceDashRaw(videoSource: JSDashManifestRawSource, play: Boolean, resume: Boolean): Boolean { + Logger.i(TAG, "Loading VideoSource [Dash]"); + + if(videoSource.hasGenerate) { + findViewTreeLifecycleOwner()?.lifecycle?.coroutineScope?.launch(Dispatchers.IO) { + try { + val generated = videoSource.generate(); + if (generated != null) { + withContext(Dispatchers.Main) { + val dataSource = if(videoSource is JSSource && (videoSource.requiresCustomDatasource)) + videoSource.getHttpDataSourceFactory() + else + DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT); + + if(dataSource is JSHttpDataSource.Factory && videoSource is JSDashManifestMergingRawSource) + dataSource.setRequestExecutor2(videoSource.audio.getRequestExecutor()); + _lastVideoMediaSource = DashMediaSource.Factory(dataSource) + .createMediaSource( + DashManifestParser().parse( + Uri.parse(videoSource.url), + ByteArrayInputStream( + generated?.toByteArray() ?: ByteArray(0) + ) + ) + ); + if(lastVideoSource == videoSource || (videoSource is JSDashManifestMergingRawSource && videoSource.video == lastVideoSource)); + loadSelectedSources(play, resume); + } + } + } + catch(ex: Throwable) { + Logger.e(TAG, "DashRaw generator failed", ex); + } + } + return false; + } + else { + val dataSource = if(videoSource is JSSource && (videoSource.requiresCustomDatasource)) + videoSource.getHttpDataSourceFactory() + else + DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT); + + if(dataSource is JSHttpDataSource.Factory && videoSource is JSDashManifestMergingRawSource) + dataSource.setRequestExecutor2(videoSource.audio.getRequestExecutor()); + _lastVideoMediaSource = DashMediaSource.Factory(dataSource) + .createMediaSource(DashManifestParser().parse(Uri.parse(videoSource.url), + ByteArrayInputStream(videoSource.manifest?.toByteArray() ?: ByteArray(0)))); + return true; + } + } + @OptIn(UnstableApi::class) private fun swapVideoSourceHLS(videoSource: IHLSManifestSource) { Logger.i(TAG, "Loading VideoSource [HLS]"); - val dataSource = if(videoSource is JSSource && videoSource.hasRequestModifier) + val dataSource = if(videoSource is JSSource && videoSource.requiresCustomDatasource) videoSource.getHttpDataSourceFactory() else DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT); @@ -503,7 +589,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { @OptIn(UnstableApi::class) private fun swapAudioSourceUrl(audioSource: IAudioUrlSource) { Logger.i(TAG, "Loading AudioSource [Url]"); - val dataSource = if(audioSource is JSSource && audioSource.hasRequestModifier) + val dataSource = if(audioSource is JSSource && audioSource.requiresCustomDatasource) audioSource.getHttpDataSourceFactory() else DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT); @@ -513,7 +599,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { @OptIn(UnstableApi::class) private fun swapAudioSourceHLS(audioSource: IHLSManifestAudioSource) { Logger.i(TAG, "Loading AudioSource [HLS]"); - val dataSource = if(audioSource is JSSource && audioSource.hasRequestModifier) + val dataSource = if(audioSource is JSSource && audioSource.requiresCustomDatasource) audioSource.getHttpDataSourceFactory() else DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT); @@ -521,10 +607,42 @@ abstract class FutoVideoPlayerBase : RelativeLayout { .createMediaSource(MediaItem.fromUri(audioSource.url)); } + @OptIn(UnstableApi::class) + private fun swapAudioSourceDashRaw(audioSource: JSDashManifestRawAudioSource, play: Boolean, resume: Boolean): Boolean { + Logger.i(TAG, "Loading AudioSource [DashRaw]"); + val dataSource = if(audioSource is JSSource && (audioSource.requiresCustomDatasource)) + audioSource.getHttpDataSourceFactory() + else + DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT); + if(audioSource.hasGenerate) { + findViewTreeLifecycleOwner()?.lifecycle?.coroutineScope?.launch(Dispatchers.IO) { + val generated = audioSource.generate(); + if(generated != null) { + withContext(Dispatchers.Main) { + _lastVideoMediaSource = DashMediaSource.Factory(dataSource) + .createMediaSource(DashManifestParser().parse(Uri.parse(audioSource.url), + ByteArrayInputStream(generated?.toByteArray() ?: ByteArray(0)))); + loadSelectedSources(play, resume); + } + } + } + return false; + } + else { + _lastVideoMediaSource = DashMediaSource.Factory(dataSource) + .createMediaSource( + DashManifestParser().parse( + Uri.parse(audioSource.url), + ByteArrayInputStream(audioSource.manifest?.toByteArray() ?: ByteArray(0)) + ) + ); + return true; + } + } @OptIn(UnstableApi::class) private fun swapAudioSourceUrlWidevine(audioSource: IAudioUrlWidevineSource) { Logger.i(TAG, "Loading AudioSource [UrlWidevine]") - val dataSource = if (audioSource is JSSource && audioSource.hasRequestModifier) + val dataSource = if (audioSource is JSSource && audioSource.requiresCustomDatasource) audioSource.getHttpDataSourceFactory() else DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT) @@ -574,28 +692,37 @@ abstract class FutoVideoPlayerBase : RelativeLayout { val sourceAudio = _lastAudioMediaSource; val sourceSubs = _lastSubtitleMediaSource; - val sources = listOf(sourceVideo, sourceAudio, sourceSubs).filter { it != null }.map { it!! }.toTypedArray() beforeSourceChanged(); - _mediaSource = if(sources.size == 1) { + val source = mergeMediaSources(sourceVideo, sourceAudio, sourceSubs); + if(source == null) + return false; + _mediaSource = source; + + reloadMediaSource(play, resume); + return true; + } + + @OptIn(UnstableApi::class) + fun mergeMediaSources(sourceVideo: MediaSource?, sourceAudio: MediaSource?, sourceSubs: MediaSource?): MediaSource? { + val sources = listOf(sourceVideo, sourceAudio, sourceSubs).filter { it != null }.map { it!! }.toTypedArray() + if(sources.size == 1) { Logger.i(TAG, "Using single source mode") - (sourceVideo ?: sourceAudio); + return (sourceVideo ?: sourceAudio); } else if(sources.size > 1) { Logger.i(TAG, "Using multi source mode ${sources.size}") - MergingMediaSource(true, *sources); + return MergingMediaSource(true, *sources); } else { Logger.i(TAG, "Using no sources loaded"); stop(); - return false; + return null; } - - reloadMediaSource(play, resume); - return true; } + @OptIn(UnstableApi::class) private fun reloadMediaSource(play: Boolean = false, resume: Boolean = true) { val player = exoPlayer ?: return @@ -619,6 +746,10 @@ abstract class FutoVideoPlayerBase : RelativeLayout { fun clear() { exoPlayer?.player?.stop(); exoPlayer?.player?.clearMediaItems(); + _lastVideoMediaSource = null; + _lastAudioMediaSource = null; + _lastSubtitleMediaSource = null; + _mediaSource = null; } fun stop(){ @@ -697,8 +828,15 @@ abstract class FutoVideoPlayerBase : RelativeLayout { companion object { val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0"; - val PREFERED_VIDEO_CONTAINERS = arrayOf("video/mp4", "video/webm", "video/3gpp"); - val PREFERED_AUDIO_CONTAINERS = arrayOf("audio/mp3", "audio/mp4", "audio/webm", "audio/opus"); + val PREFERED_VIDEO_CONTAINERS_MP4Pref = arrayOf("video/mp4", "video/webm", "video/3gpp"); + val PREFERED_VIDEO_CONTAINERS_WEBMPref = arrayOf("video/webm", "video/mp4", "video/3gpp"); + val PREFERED_VIDEO_CONTAINERS: Array<String> get() { return if(Settings.instance.playback.preferWebmVideo) + PREFERED_VIDEO_CONTAINERS_WEBMPref else PREFERED_VIDEO_CONTAINERS_MP4Pref } + + val PREFERED_AUDIO_CONTAINERS_MP4Pref = arrayOf("audio/mp3", "audio/mp4", "audio/webm", "audio/opus"); + val PREFERED_AUDIO_CONTAINERS_WEBMPref = arrayOf("audio/webm", "audio/opus", "audio/mp3", "audio/mp4"); + val PREFERED_AUDIO_CONTAINERS: Array<String> get() { return if(Settings.instance.playback.preferWebmAudio) + PREFERED_AUDIO_CONTAINERS_WEBMPref else PREFERED_AUDIO_CONTAINERS_MP4Pref } val SUPPORTED_SUBTITLES = hashSetOf("text/vtt", "application/x-subrip"); } diff --git a/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java b/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java index 94b0fc9982dbde4b3c9cca861de2924bc862e697..40e1a50386f647425e5cd78cee3b46f0b51fbb6e 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java +++ b/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java @@ -13,6 +13,8 @@ import androidx.annotation.VisibleForTesting; import com.futo.platformplayer.api.media.models.modifier.IRequest; import com.futo.platformplayer.api.media.models.modifier.IRequestModifier; +import com.futo.platformplayer.api.media.platforms.js.models.JSRequest; +import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor; import com.futo.platformplayer.api.media.platforms.js.models.JSRequestModifier; import androidx.media3.common.C; import androidx.media3.common.PlaybackException; @@ -26,12 +28,16 @@ import androidx.media3.datasource.HttpUtil; import androidx.media3.datasource.TransferListener; import com.futo.platformplayer.engine.dev.V8RemoteObject; +import com.futo.platformplayer.engine.exceptions.PluginException; +import com.futo.platformplayer.engine.exceptions.ScriptException; import com.futo.platformplayer.logging.Logger; import com.google.common.base.Predicate; import com.google.common.collect.ForwardingMap; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Sets; import com.google.common.net.HttpHeaders; + +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.io.InterruptedIOException; @@ -67,6 +73,9 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { private boolean allowCrossProtocolRedirects; private boolean keepPostFor302Redirects; @Nullable private IRequestModifier requestModifier = null; + @Nullable public JSRequestExecutor requestExecutor = null; + @Nullable public JSRequestExecutor requestExecutor2 = null; + /** Creates an instance. */ public Factory() { @@ -93,6 +102,30 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { this.requestModifier = requestModifier; return this; } + /** + * Sets the request executor that will be used. + * + * <p>The default is {@code null}, which results in no request modification + * + * @param requestExecutor The request modifier that will be used, or {@code null} to use no request modifier + * @return This factory. + */ + public Factory setRequestExecutor(@Nullable JSRequestExecutor requestExecutor) { + this.requestExecutor = requestExecutor; + return this; + } + /** + * Sets the secondary request executor that will be used. + * + * <p>The default is {@code null}, which results in no request modification + * + * @param requestExecutor The request modifier that will be used, or {@code null} to use no request modifier + * @return This factory. + */ + public Factory setRequestExecutor2(@Nullable JSRequestExecutor requestExecutor) { + this.requestExecutor2 = requestExecutor; + return this; + } /** * Sets the user agent that will be used. @@ -199,7 +232,9 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { defaultRequestProperties, contentTypePredicate, keepPostFor302Redirects, - requestModifier); + requestModifier, + requestExecutor, + requestExecutor2); if (transferListener != null) { dataSource.addTransferListener(transferListener); } @@ -235,6 +270,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { private long bytesToRead; private long bytesRead; @Nullable private IRequestModifier requestModifier; + @Nullable public JSRequestExecutor requestExecutor; + @Nullable public JSRequestExecutor requestExecutor2; //Not ideal, but required for now to have 2 executors under 1 datasource + + private Uri fallbackUri = null; private JSHttpDataSource( @Nullable String userAgent, @@ -244,7 +283,9 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { @Nullable RequestProperties defaultRequestProperties, @Nullable Predicate<String> contentTypePredicate, boolean keepPostFor302Redirects, - @Nullable IRequestModifier requestModifier) { + @Nullable IRequestModifier requestModifier, + @Nullable JSRequestExecutor requestExecutor, + @Nullable JSRequestExecutor requestExecutor2) { super(/* isNetwork= */ true); this.userAgent = userAgent; this.connectTimeoutMillis = connectTimeoutMillis; @@ -255,12 +296,14 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { this.requestProperties = new RequestProperties(); this.keepPostFor302Redirects = keepPostFor302Redirects; this.requestModifier = requestModifier; + this.requestExecutor = requestExecutor; + this.requestExecutor2 = requestExecutor2; } @Override @Nullable public Uri getUri() { - return connection == null ? null : Uri.parse(connection.getURL().toString()); + return connection == null ? fallbackUri : Uri.parse(connection.getURL().toString()); } @Override @@ -310,119 +353,147 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { bytesToRead = 0; transferInitializing(dataSpec); - String responseMessage; - HttpURLConnection connection; - try { - this.connection = makeConnection(dataSpec); - connection = this.connection; - responseCode = connection.getResponseCode(); - responseMessage = connection.getResponseMessage(); - } catch (IOException e) { - closeConnectionQuietly(); - throw HttpDataSourceException.createForIOException( - e, dataSpec, HttpDataSourceException.TYPE_OPEN); - } - - // Check for a valid response code. - if (responseCode < 200 || responseCode > 299) { - Map<String, List<String>> headers = connection.getHeaderFields(); - if (responseCode == 416) { - long documentSize = HttpUtil.getDocumentSize(connection.getHeaderField(HttpHeaders.CONTENT_RANGE)); - if (dataSpec.position == documentSize) { - opened = true; - transferStarted(dataSpec); - return dataSpec.length != C.LENGTH_UNSET ? dataSpec.length : 0; - } - } + //Use executor 2 if it matches the urlPrefix + JSRequestExecutor executor = (requestExecutor2 != null && requestExecutor2.getUrlPrefix() != null && dataSpec.uri.toString().startsWith(requestExecutor2.getUrlPrefix())) ? + requestExecutor2 : requestExecutor; + + if(executor != null) { + try { + Logger.Companion.i(TAG, "Executor for " + dataSpec.uri.toString(), null); + byte[] data = executor.executeRequest(dataSpec.uri.toString(), dataSpec.httpRequestHeaders); + Logger.Companion.i(TAG, "Executor result for " + dataSpec.uri.toString() + " : " + data.length, null); + if (data == null) + throw new HttpDataSourceException( + "No response", + dataSpec, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN); + inputStream = new ByteArrayInputStream(data); + fallbackUri = dataSpec.uri; + bytesToRead = data.length; - @Nullable InputStream errorStream = connection.getErrorStream(); - byte[] errorResponseBody; + transferStarted(dataSpec); + return data.length; + } + catch(PluginException ex) { + throw HttpDataSourceException.createForIOException(new IOException("Executor failed: " + ex.getMessage(), ex), dataSpec, HttpDataSourceException.TYPE_OPEN); + } + } + else { + String responseMessage; + HttpURLConnection connection; try { - errorResponseBody = - errorStream != null ? Util.toByteArray(errorStream) : Util.EMPTY_BYTE_ARRAY; + this.connection = makeConnection(dataSpec); + connection = this.connection; + responseCode = connection.getResponseCode(); + responseMessage = connection.getResponseMessage(); } catch (IOException e) { - errorResponseBody = Util.EMPTY_BYTE_ARRAY; + closeConnectionQuietly(); + throw HttpDataSourceException.createForIOException( + e, dataSpec, HttpDataSourceException.TYPE_OPEN); } - closeConnectionQuietly(); - @Nullable - IOException cause = responseCode == 416 - ? new DataSourceException(PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE) - : null; - throw new InvalidResponseCodeException( - responseCode, responseMessage, cause, headers, dataSpec, errorResponseBody); - } + // Check for a valid response code. + if (responseCode < 200 || responseCode > 299) { + Map<String, List<String>> headers = connection.getHeaderFields(); + if (responseCode == 416) { + long documentSize = HttpUtil.getDocumentSize(connection.getHeaderField(HttpHeaders.CONTENT_RANGE)); + if (dataSpec.position == documentSize) { + opened = true; + transferStarted(dataSpec); + return dataSpec.length != C.LENGTH_UNSET ? dataSpec.length : 0; + } + } - // Check for a valid content type. - String contentType = connection.getContentType(); - if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) { - closeConnectionQuietly(); - throw new InvalidContentTypeException(contentType, dataSpec); - } + @Nullable InputStream errorStream = connection.getErrorStream(); + byte[] errorResponseBody; + try { + errorResponseBody = + errorStream != null ? Util.toByteArray(errorStream) : Util.EMPTY_BYTE_ARRAY; + } catch (IOException e) { + errorResponseBody = Util.EMPTY_BYTE_ARRAY; + } + closeConnectionQuietly(); + @Nullable + IOException cause = responseCode == 416 + ? new DataSourceException(PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE) + : null; + + throw new InvalidResponseCodeException( + responseCode, responseMessage, cause, headers, dataSpec, errorResponseBody); + } - // If we requested a range starting from a non-zero position and received a 200 rather than a - // 206, then the server does not support partial requests. We'll need to manually skip to the - // requested position. - long bytesToSkip; - if (requestModifier != null && !requestModifier.getAllowByteSkip()) { - bytesToSkip = 0; - } else { - bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0; - } + // Check for a valid content type. + String contentType = connection.getContentType(); + if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) { + closeConnectionQuietly(); + throw new InvalidContentTypeException(contentType, dataSpec); + } - // Determine the length of the data to be read, after skipping. - boolean isCompressed = isCompressed(connection); - if (!isCompressed) { - if (dataSpec.length != C.LENGTH_UNSET) { - bytesToRead = dataSpec.length; + // If we requested a range starting from a non-zero position and received a 200 rather than a + // 206, then the server does not support partial requests. We'll need to manually skip to the + // requested position. + long bytesToSkip; + if (requestModifier != null && !requestModifier.getAllowByteSkip()) { + bytesToSkip = 0; } else { - long contentLength = HttpUtil.getContentLength( - connection.getHeaderField(HttpHeaders.CONTENT_LENGTH), - connection.getHeaderField(HttpHeaders.CONTENT_RANGE) - ); + bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0; + } - bytesToRead = contentLength != C.LENGTH_UNSET ? (contentLength - bytesToSkip) : C.LENGTH_UNSET; + // Determine the length of the data to be read, after skipping. + boolean isCompressed = isCompressed(connection); + if (!isCompressed) { + if (dataSpec.length != C.LENGTH_UNSET) { + bytesToRead = dataSpec.length; + } else { + long contentLength = HttpUtil.getContentLength( + connection.getHeaderField(HttpHeaders.CONTENT_LENGTH), + connection.getHeaderField(HttpHeaders.CONTENT_RANGE) + ); + + bytesToRead = contentLength != C.LENGTH_UNSET ? (contentLength - bytesToSkip) : C.LENGTH_UNSET; + } + } else { + // Gzip is enabled. If the server opts to use gzip then the content length in the response + // will be that of the compressed data, which isn't what we want. Always use the dataSpec + // length in this case. + bytesToRead = dataSpec.length; } - } else { - // Gzip is enabled. If the server opts to use gzip then the content length in the response - // will be that of the compressed data, which isn't what we want. Always use the dataSpec - // length in this case. - bytesToRead = dataSpec.length; - } - try { - inputStream = connection.getInputStream(); - if (isCompressed) { - inputStream = new GZIPInputStream(inputStream); + try { + inputStream = connection.getInputStream(); + if (isCompressed) { + inputStream = new GZIPInputStream(inputStream); + } + } catch (IOException e) { + closeConnectionQuietly(); + throw new HttpDataSourceException( + e, + dataSpec, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN); } - } catch (IOException e) { - closeConnectionQuietly(); - throw new HttpDataSourceException( - e, - dataSpec, - PlaybackException.ERROR_CODE_IO_UNSPECIFIED, - HttpDataSourceException.TYPE_OPEN); - } - opened = true; - transferStarted(dataSpec); + opened = true; + transferStarted(dataSpec); - try { - skipFully(bytesToSkip, dataSpec); - } catch (IOException e) { - closeConnectionQuietly(); + try { + skipFully(bytesToSkip, dataSpec); + } catch (IOException e) { + closeConnectionQuietly(); - if (e instanceof HttpDataSourceException) { - throw (HttpDataSourceException) e; + if (e instanceof HttpDataSourceException) { + throw (HttpDataSourceException) e; + } + throw new HttpDataSourceException( + e, + dataSpec, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN); } - throw new HttpDataSourceException( - e, - dataSpec, - PlaybackException.ERROR_CODE_IO_UNSPECIFIED, - HttpDataSourceException.TYPE_OPEN); - } - return bytesToRead; + return bytesToRead; + } } @Override diff --git a/app/src/main/res/drawable/battery_full_24px.xml b/app/src/main/res/drawable/battery_full_24px.xml new file mode 100644 index 0000000000000000000000000000000000000000..af90cb27302f983506551e1e2637b4b210945312 --- /dev/null +++ b/app/src/main/res/drawable/battery_full_24px.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960"> + <path + android:fillColor="@android:color/white" + android:pathData="M320,880Q303,880 291.5,868.5Q280,857 280,840L280,200Q280,183 291.5,171.5Q303,160 320,160L400,160L400,80L560,80L560,160L640,160Q657,160 668.5,171.5Q680,183 680,200L680,840Q680,857 668.5,868.5Q657,880 640,880L320,880Z"/> +</vector> diff --git a/app/src/main/res/layout/overlay_slide_up_menu_option.xml b/app/src/main/res/layout/overlay_slide_up_menu_option.xml index c51590952d95c85577760d28de4ae8cc8d2a3394..82b2c512f16f74d9729267b93f539fc3643f96b7 100644 --- a/app/src/main/res/layout/overlay_slide_up_menu_option.xml +++ b/app/src/main/res/layout/overlay_slide_up_menu_option.xml @@ -1,44 +1,81 @@ <?xml version="1.0" encoding="utf-8"?> -<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" - android:layout_marginTop="10dp" - android:background="@drawable/background_slide_up_option" - android:id="@+id/slide_up_menu_item_root" android:gravity="center_vertical"> - - <ImageView - android:id="@+id/slide_up_menu_item_image" - android:layout_width="20dp" - android:layout_height="20dp" - android:layout_marginLeft="15dp" - android:scaleType="fitCenter" - app:srcCompat="@drawable/ic_share" /> - <TextView - android:id="@+id/slide_up_menu_item_text" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:gravity="center_vertical" - android:layout_alignParentLeft="true" - android:layout_marginLeft="45dp" - tools:text="Cat videos" - android:textSize="11dp" - android:layout_marginTop="2dp" - android:fontFamily="@font/inter_light" - android:textColor="@color/white" /> - <TextView - android:id="@+id/slide_up_menu_item_subtext" - android:layout_width="wrap_content" + <androidx.constraintlayout.widget.ConstraintLayout + android:layout_width="match_parent" android:layout_height="wrap_content" - android:gravity="center_vertical" - android:layout_alignParentRight="true" - android:layout_marginRight="15dp" - tools:text="3 videos" - android:textSize="11dp" - android:layout_marginTop="2dp" - android:fontFamily="@font/inter_light" - android:textColor="#ACACAC" /> + android:id="@+id/slide_up_menu_item_root" + android:layout_marginTop="10dp" + android:background="@drawable/background_slide_up_option" + app:layout_constraintLeft_toLeftOf="parent" + app:layout_constraintRight_toRightOf="parent" + app:layout_constraintTop_toTopOf="parent"> + <ImageView + android:id="@+id/slide_up_menu_item_image" + android:layout_width="20dp" + android:layout_height="20dp" + android:layout_marginLeft="15dp" + android:scaleType="fitCenter" + android:layout_gravity="center_vertical" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintLeft_toLeftOf="parent" + app:srcCompat="@drawable/ic_share" /> + <LinearLayout + android:layout_width="0dp" + android:layout_height="wrap_content" + android:paddingTop="12dp" + android:paddingBottom="12dp" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintLeft_toRightOf="@id/slide_up_menu_item_image" + app:layout_constraintRight_toLeftOf="@id/slide_up_menu_item_subtext" + android:gravity="start" + android:orientation="vertical"> + <TextView + android:id="@+id/slide_up_menu_item_text" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center_vertical" + android:layout_alignParentLeft="true" + android:layout_marginLeft="20dp" + tools:text="Cat videos" + android:textSize="11dp" + android:layout_marginTop="2dp" + android:fontFamily="@font/inter_light" + android:textColor="@color/white" /> + <TextView + android:id="@+id/slide_up_menu_item_description" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:gravity="center_vertical" + android:layout_alignParentLeft="true" + android:layout_marginLeft="20dp" + tools:text="test Description" + android:textSize="11dp" + android:layout_marginTop="2dp" + android:fontFamily="@font/inter_light" + android:textColor="#ACACAC" /> + </LinearLayout> + <TextView + android:id="@+id/slide_up_menu_item_subtext" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:gravity="center_vertical" + android:layout_alignParentRight="true" + android:layout_marginRight="15dp" + tools:text="3 videos" + android:textSize="11dp" + android:layout_marginTop="2dp" + android:fontFamily="@font/inter_light" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintRight_toRightOf="parent" + android:textColor="#ACACAC" /> + </androidx.constraintlayout.widget.ConstraintLayout> -</RelativeLayout> \ No newline at end of file +</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a8eef4fb70c5ca591666c5d904d83c8656c276cf..90d2160dd01475d71b5bcc50acff22cc8a96e57f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -285,6 +285,8 @@ <string name="attempt_to_utilize_byte_ranges">Attempt to utilize byte ranges</string> <string name="auto_update">Auto Update</string> <string name="auto_rotate">Auto-Rotate</string> + <string name="simplify_sources">Simplify sources</string> + <string name="simplify_sources_description">Deduplicate sources by resolution so that only more relevant sources are visible.</string> <string name="auto_rotate_dead_zone">Auto-Rotate Dead Zone</string> <string name="automatic_backup">Automatic Backup</string> <string name="background_behavior">Background Behavior</string> @@ -371,6 +373,10 @@ <string name="system_volume_descr">Gesture controls adjust system volume</string> <string name="live_chat_webview">Live Chat Webview</string> <string name="full_screen_portrait">Fullscreen portrait</string> + <string name="prefer_webm">Prefer Webm Video Codecs</string> + <string name="prefer_webm_description">If player should prefer Webm codecs (vp9/opus) over mp4 codecs (h264/AAC), may result in worse compatibility.</string> + <string name="prefer_webm_audio">Prefer Webm Audio Codecs</string> + <string name="prefer_webm_audio_description">If player should prefer Webm codecs (opus) over mp4 codecs (AAC), may result in worse compatibility.</string> <string name="allow_full_screen_portrait">Allow fullscreen portrait</string> <string name="background_switch_audio">Switch to Audio in Background</string> <string name="background_switch_audio_description">Optimize bandwidth usage by switching to audio-only stream in background if available, may cause stutter</string> diff --git a/app/src/stable/assets/sources/spotify b/app/src/stable/assets/sources/spotify index 4d554e93882d29542cb05d2956f4b2484d7df27f..c700081466038ee4782610feaa05cd4d34d024d8 160000 --- a/app/src/stable/assets/sources/spotify +++ b/app/src/stable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 4d554e93882d29542cb05d2956f4b2484d7df27f +Subproject commit c700081466038ee4782610feaa05cd4d34d024d8 diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index 24f9e4456faf97fbbb866e1df2df9d94628ffcc6..546d862342b10398d0737f0f2163691b611af8f2 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 24f9e4456faf97fbbb866e1df2df9d94628ffcc6 +Subproject commit 546d862342b10398d0737f0f2163691b611af8f2 diff --git a/app/src/test/java/com/futo/platformplayer/MdnsTests.kt b/app/src/test/java/com/futo/platformplayer/MdnsTests.kt new file mode 100644 index 0000000000000000000000000000000000000000..64a37d6e3563e220c433a3ff34ae2d14b6349ee6 --- /dev/null +++ b/app/src/test/java/com/futo/platformplayer/MdnsTests.kt @@ -0,0 +1,394 @@ +package com.futo.platformplayer + +import com.futo.platformplayer.mdns.DnsOpcode +import com.futo.platformplayer.mdns.DnsPacket +import com.futo.platformplayer.mdns.DnsPacketHeader +import com.futo.platformplayer.mdns.DnsQuestion +import com.futo.platformplayer.mdns.DnsReader +import com.futo.platformplayer.mdns.DnsResponseCode +import com.futo.platformplayer.mdns.DnsWriter +import com.futo.platformplayer.mdns.QueryResponse +import com.futo.platformplayer.mdns.QuestionClass +import com.futo.platformplayer.mdns.QuestionType +import com.futo.platformplayer.mdns.ResourceRecordClass +import com.futo.platformplayer.mdns.ResourceRecordType +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import java.io.ByteArrayOutputStream +import java.net.InetAddress +import kotlin.test.Test +import kotlin.test.assertContentEquals + + +class MdnsTests { + + @Test + fun `BasicOperation`() { + val expectedData = byteArrayOf( + 0x00, 0x01, + 0x00, 0x00, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, + 0x00, 0x01, + 0x00, 0x00, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03 + ) + + val writer = DnsWriter() + writer.write(1.toUShort()) + writer.write(2.toUInt()) + writer.write(3.toULong()) + writer.write(1.toShort()) + writer.write(2) + writer.write(3L) + + assertContentEquals(expectedData, writer.toByteArray()) + } + + @Test + fun `DnsQuestionFormat`() { + val expectedBytes = ubyteArrayOf( + 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x01u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x08u, 0x5fu, 0x61u, 0x69u, 0x72u, 0x70u, 0x6cu, 0x61u, 0x79u, 0x04u, 0x5fu, 0x74u, 0x63u, 0x70u, 0x05u, 0x6cu, 0x6fu, 0x63u, 0x61u, 0x6cu, 0x00u, 0x00u, 0x0cu, 0x00u, 0x01u + ).asByteArray() + + val writer = DnsWriter() + writer.writePacket( + header = DnsPacketHeader( + identifier = 0.toUShort(), + queryResponse = QueryResponse.Query.value.toInt(), + opcode = DnsOpcode.StandardQuery.value.toInt(), + authoritativeAnswer = false, + truncated = false, + recursionDesired = false, + recursionAvailable = false, + answerAuthenticated = false, + nonAuthenticatedData = false, + responseCode = DnsResponseCode.NoError + ), + questionCount = 1, + questionWriter = { w, _ -> + w.write(DnsQuestion( + name = "_airplay._tcp.local", + type = QuestionType.PTR.value.toInt(), + clazz = QuestionClass.IN.value.toInt(), + queryUnicast = false + )) + } + ) + + assertContentEquals(expectedBytes, writer.toByteArray()) + } + + @Test + fun `BeyondTests`() { + val data = byteArrayOf( + 0x00, 0x01, + 0x00, 0x00, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, + 0x00, 0x01, + 0x00, 0x00, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03 + ) + + val reader = DnsReader(data) + assertEquals(1, reader.readInt16()) + assertEquals(2, reader.readInt32()) + assertEquals(3L, reader.readInt64()) + assertEquals(1.toUShort(), reader.readUInt16()) + assertEquals(2.toUInt(), reader.readUInt32()) + assertEquals(3.toULong(), reader.readUInt64()) + } + + @Test + fun `ParseDnsPrinter`() { + val data = ubyteArrayOf( + 0x00u, 0x00u, + 0x84u, 0x00u, 0x00u, 0x00u, 0x00u, 0x01u, 0x00u, 0x00u, 0x00u, 0x06u, 0x04u, 0x5fu, 0x69u, 0x70u, 0x70u, 0x04u, + 0x5fu, 0x74u, 0x63u, 0x70u, 0x05u, 0x6cu, 0x6fu, 0x63u, 0x61u, 0x6cu, 0x00u, 0x00u, 0x0cu, 0x00u, 0x01u, 0x00u, + 0x00u, 0x11u, 0x94u, 0x00u, 0x1eu, 0x1bu, 0x42u, 0x72u, 0x6fu, 0x74u, 0x68u, 0x65u, 0x72u, 0x20u, 0x44u, 0x43u, + 0x50u, 0x2du, 0x4cu, 0x33u, 0x35u, 0x35u, 0x30u, 0x43u, 0x44u, 0x57u, 0x20u, 0x73u, 0x65u, 0x72u, 0x69u, 0x65u, + 0x73u, 0xc0u, 0x0cu, 0xc0u, 0x27u, 0x00u, 0x10u, 0x80u, 0x01u, 0x00u, 0x00u, 0x11u, 0x94u, 0x02u, 0x53u, 0x09u, + 0x74u, 0x78u, 0x74u, 0x76u, 0x65u, 0x72u, 0x73u, 0x3du, 0x31u, 0x08u, 0x71u, 0x74u, 0x6fu, 0x74u, 0x61u, 0x6cu, + 0x3du, 0x31u, 0x42u, 0x70u, 0x64u, 0x6cu, 0x3du, 0x61u, 0x70u, 0x70u, 0x6cu, 0x69u, 0x63u, 0x61u, 0x74u, 0x69u, + 0x6fu, 0x6eu, 0x2fu, 0x6fu, 0x63u, 0x74u, 0x65u, 0x74u, 0x2du, 0x73u, 0x74u, 0x72u, 0x65u, 0x61u, 0x6du, 0x2cu, + 0x69u, 0x6du, 0x61u, 0x67u, 0x65u, 0x2fu, 0x75u, 0x72u, 0x66u, 0x2cu, 0x69u, 0x6du, 0x61u, 0x67u, 0x65u, 0x2fu, + 0x6au, 0x70u, 0x65u, 0x67u, 0x2cu, 0x69u, 0x6du, 0x61u, 0x67u, 0x65u, 0x2fu, 0x70u, 0x77u, 0x67u, 0x2du, 0x72u, + 0x61u, 0x73u, 0x74u, 0x65u, 0x72u, 0x0cu, 0x72u, 0x70u, 0x3du, 0x69u, 0x70u, 0x70u, 0x2fu, 0x70u, 0x72u, 0x69u, + 0x6eu, 0x74u, 0x05u, 0x6eu, 0x6fu, 0x74u, 0x65u, 0x3du, 0x1eu, 0x74u, 0x79u, 0x3du, 0x42u, 0x72u, 0x6fu, 0x74u, + 0x68u, 0x65u, 0x72u, 0x20u, 0x44u, 0x43u, 0x50u, 0x2du, 0x4cu, 0x33u, 0x35u, 0x35u, 0x30u, 0x43u, 0x44u, 0x57u, + 0x20u, 0x73u, 0x65u, 0x72u, 0x69u, 0x65u, 0x73u, 0x25u, 0x70u, 0x72u, 0x6fu, 0x64u, 0x75u, 0x63u, 0x74u, 0x3du, + 0x28u, 0x42u, 0x72u, 0x6fu, 0x74u, 0x68u, 0x65u, 0x72u, 0x20u, 0x44u, 0x43u, 0x50u, 0x2du, 0x4cu, 0x33u, 0x35u, + 0x35u, 0x30u, 0x43u, 0x44u, 0x57u, 0x20u, 0x73u, 0x65u, 0x72u, 0x69u, 0x65u, 0x73u, 0x29u, 0x3cu, 0x61u, 0x64u, + 0x6du, 0x69u, 0x6eu, 0x75u, 0x72u, 0x6cu, 0x3du, 0x68u, 0x74u, 0x74u, 0x70u, 0x3au, 0x2fu, 0x2fu, 0x42u, 0x52u, + 0x57u, 0x31u, 0x30u, 0x35u, 0x42u, 0x41u, 0x44u, 0x34u, 0x41u, 0x31u, 0x35u, 0x37u, 0x30u, 0x2eu, 0x6cu, 0x6fu, + 0x63u, 0x61u, 0x6cu, 0x2eu, 0x2fu, 0x6eu, 0x65u, 0x74u, 0x2fu, 0x6eu, 0x65u, 0x74u, 0x2fu, 0x61u, 0x69u, 0x72u, + 0x70u, 0x72u, 0x69u, 0x6eu, 0x74u, 0x2eu, 0x68u, 0x74u, 0x6du, 0x6cu, 0x0bu, 0x70u, 0x72u, 0x69u, 0x6fu, 0x72u, + 0x69u, 0x74u, 0x79u, 0x3du, 0x32u, 0x35u, 0x0fu, 0x75u, 0x73u, 0x62u, 0x5fu, 0x4du, 0x46u, 0x47u, 0x3du, 0x42u, + 0x72u, 0x6fu, 0x74u, 0x68u, 0x65u, 0x72u, 0x1bu, 0x75u, 0x73u, 0x62u, 0x5fu, 0x4du, 0x44u, 0x4cu, 0x3du, 0x44u, + 0x43u, 0x50u, 0x2du, 0x4cu, 0x33u, 0x35u, 0x35u, 0x30u, 0x43u, 0x44u, 0x57u, 0x20u, 0x73u, 0x65u, 0x72u, 0x69u, + 0x65u, 0x73u, 0x19u, 0x75u, 0x73u, 0x62u, 0x5fu, 0x43u, 0x4du, 0x44u, 0x3du, 0x50u, 0x4au, 0x4cu, 0x2cu, 0x50u, + 0x43u, 0x4cu, 0x2cu, 0x50u, 0x43u, 0x4cu, 0x58u, 0x4cu, 0x2cu, 0x55u, 0x52u, 0x46u, 0x07u, 0x43u, 0x6fu, 0x6cu, + 0x6fu, 0x72u, 0x3du, 0x54u, 0x08u, 0x43u, 0x6fu, 0x70u, 0x69u, 0x65u, 0x73u, 0x3du, 0x54u, 0x08u, 0x44u, 0x75u, + 0x70u, 0x6cu, 0x65u, 0x78u, 0x3du, 0x54u, 0x05u, 0x46u, 0x61u, 0x78u, 0x3du, 0x46u, 0x06u, 0x53u, 0x63u, 0x61u, + 0x6eu, 0x3du, 0x54u, 0x0du, 0x50u, 0x61u, 0x70u, 0x65u, 0x72u, 0x43u, 0x75u, 0x73u, 0x74u, 0x6fu, 0x6du, 0x3du, + 0x54u, 0x08u, 0x42u, 0x69u, 0x6eu, 0x61u, 0x72u, 0x79u, 0x3du, 0x54u, 0x0du, 0x54u, 0x72u, 0x61u, 0x6eu, 0x73u, + 0x70u, 0x61u, 0x72u, 0x65u, 0x6eu, 0x74u, 0x3du, 0x54u, 0x06u, 0x54u, 0x42u, 0x43u, 0x50u, 0x3du, 0x46u, 0x3eu, + 0x55u, 0x52u, 0x46u, 0x3du, 0x53u, 0x52u, 0x47u, 0x42u, 0x32u, 0x34u, 0x2cu, 0x57u, 0x38u, 0x2cu, 0x43u, 0x50u, + 0x31u, 0x2cu, 0x49u, 0x53u, 0x34u, 0x2du, 0x31u, 0x2cu, 0x4du, 0x54u, 0x31u, 0x2du, 0x33u, 0x2du, 0x34u, 0x2du, + 0x35u, 0x2du, 0x38u, 0x2du, 0x31u, 0x31u, 0x2cu, 0x4fu, 0x42u, 0x31u, 0x30u, 0x2cu, 0x50u, 0x51u, 0x34u, 0x2cu, + 0x52u, 0x53u, 0x36u, 0x30u, 0x30u, 0x2cu, 0x56u, 0x31u, 0x2eu, 0x34u, 0x2cu, 0x44u, 0x4du, 0x31u, 0x25u, 0x6bu, + 0x69u, 0x6eu, 0x64u, 0x3du, 0x64u, 0x6fu, 0x63u, 0x75u, 0x6du, 0x65u, 0x6eu, 0x74u, 0x2cu, 0x65u, 0x6eu, 0x76u, + 0x65u, 0x6cu, 0x6fu, 0x70u, 0x65u, 0x2cu, 0x6cu, 0x61u, 0x62u, 0x65u, 0x6cu, 0x2cu, 0x70u, 0x6fu, 0x73u, 0x74u, + 0x63u, 0x61u, 0x72u, 0x64u, 0x11u, 0x50u, 0x61u, 0x70u, 0x65u, 0x72u, 0x4du, 0x61u, 0x78u, 0x3du, 0x6cu, 0x65u, + 0x67u, 0x61u, 0x6cu, 0x2du, 0x41u, 0x34u, 0x29u, 0x55u, 0x55u, 0x49u, 0x44u, 0x3du, 0x65u, 0x33u, 0x32u, 0x34u, + 0x38u, 0x30u, 0x30u, 0x30u, 0x2du, 0x38u, 0x30u, 0x63u, 0x65u, 0x2du, 0x31u, 0x31u, 0x64u, 0x62u, 0x2du, 0x38u, + 0x30u, 0x30u, 0x30u, 0x2du, 0x33u, 0x63u, 0x32u, 0x61u, 0x66u, 0x34u, 0x61u, 0x61u, 0x63u, 0x30u, 0x61u, 0x34u, + 0x0cu, 0x70u, 0x72u, 0x69u, 0x6eu, 0x74u, 0x5fu, 0x77u, 0x66u, 0x64u, 0x73u, 0x3du, 0x54u, 0x14u, 0x6du, 0x6fu, + 0x70u, 0x72u, 0x69u, 0x61u, 0x2du, 0x63u, 0x65u, 0x72u, 0x74u, 0x69u, 0x66u, 0x69u, 0x65u, 0x64u, 0x3du, 0x31u, + 0x2eu, 0x33u, 0x0fu, 0x42u, 0x52u, 0x57u, 0x31u, 0x30u, 0x35u, 0x42u, 0x41u, 0x44u, 0x34u, 0x41u, 0x31u, 0x35u, + 0x37u, 0x30u, 0xc0u, 0x16u, 0x00u, 0x01u, 0x80u, 0x01u, 0x00u, 0x00u, 0x00u, 0x78u, 0x00u, 0x04u, 0xc0u, 0xa8u, + 0x01u, 0xc5u, 0xc2u, 0xa4u, 0x00u, 0x1cu, 0x80u, 0x01u, 0x00u, 0x00u, 0x00u, 0x78u, 0x00u, 0x10u, 0xfeu, 0x80u, + 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x12u, 0x5bu, 0xadu, 0xffu, 0xfeu, 0x4au, 0x15u, 0x70u, 0xc0u, 0x27u, + 0x00u, 0x21u, 0x80u, 0x01u, 0x00u, 0x00u, 0x00u, 0x78u, 0x00u, 0x08u, 0x00u, 0x00u, 0x00u, 0x00u, 0x02u, 0x77u, + 0xc2u, 0xa4u, 0xc0u, 0x27u, 0x00u, 0x2fu, 0x80u, 0x01u, 0x00u, 0x00u, 0x11u, 0x94u, 0x00u, 0x09u, 0xc0u, 0x27u, + 0x00u, 0x05u, 0x00u, 0x00u, 0x80u, 0x00u, 0x40u, 0xc2u, 0xa4u, 0x00u, 0x2fu, 0x80u, 0x01u, 0x00u, 0x00u, 0x00u, + 0x78u, 0x00u, 0x08u, 0xc2u, 0xa4u, 0x00u, 0x04u, 0x40u, 0x00u, 0x00u, 0x08u + ) + + val packet = DnsPacket.parse(data.asByteArray()) + assertEquals(QueryResponse.Response.value.toInt(), packet.header.queryResponse) + assertEquals(DnsOpcode.StandardQuery.value.toInt(), packet.header.opcode) + assertTrue(packet.header.authoritativeAnswer) + assertEquals(false, packet.header.truncated) + assertEquals(false, packet.header.recursionDesired) + assertEquals(false, packet.header.recursionAvailable) + assertEquals(false, packet.header.answerAuthenticated) + assertEquals(false, packet.header.nonAuthenticatedData) + assertEquals(DnsResponseCode.NoError, packet.header.responseCode) + assertEquals(0, packet.questions.size) + assertEquals(1, packet.answers.size) + assertEquals(0, packet.authorities.size) + assertEquals(6, packet.additionals.size) + + val firstAnswer = packet.answers[0] + assertEquals("_ipp._tcp.local", firstAnswer.name) + assertEquals(ResourceRecordType.PTR.value.toInt(), firstAnswer.type) + assertEquals(ResourceRecordClass.IN.value.toInt(), firstAnswer.clazz) + assertEquals(false, firstAnswer.cacheFlush) + assertEquals(4500u, firstAnswer.timeToLive) + assertEquals(30, firstAnswer.dataLength) + assertEquals("Brother DCP-L3550CDW series._ipp._tcp.local", firstAnswer.getDataReader().readPTRRecord().domainName) + + val firstAdditional = packet.additionals[0] + assertEquals("Brother DCP-L3550CDW series._ipp._tcp.local", firstAdditional.name) + assertEquals(ResourceRecordType.TXT.value.toInt(), firstAdditional.type) + assertEquals(ResourceRecordClass.IN.value.toInt(), firstAdditional.clazz) + assertEquals(true, firstAdditional.cacheFlush) + assertEquals(4500u, firstAdditional.timeToLive) + assertEquals(595, firstAdditional.dataLength) + + val txtRecord = firstAdditional.getDataReader().readTXTRecord() + assertContentEquals(arrayOf( + "txtvers=1", + "qtotal=1", + "pdl=application/octet-stream,image/urf,image/jpeg,image/pwg-raster", + "rp=ipp/print", + "note=", + "ty=Brother DCP-L3550CDW series", + "product=(Brother DCP-L3550CDW series)", + "adminurl=http://BRW105BAD4A1570.local./net/net/airprint.html", + "priority=25", + "usb_MFG=Brother", + "usb_MDL=DCP-L3550CDW series", + "usb_CMD=PJL,PCL,PCLXL,URF", + "Color=T", + "Copies=T", + "Duplex=T", + "Fax=F", + "Scan=T", + "PaperCustom=T", + "Binary=T", + "Transparent=T", + "TBCP=F", + "URF=SRGB24,W8,CP1,IS4-1,MT1-3-4-5-8-11,OB10,PQ4,RS600,V1.4,DM1", + "kind=document,envelope,label,postcard", + "PaperMax=legal-A4", + "UUID=e3248000-80ce-11db-8000-3c2af4aac0a4", + "print_wfds=T", + "mopria-certified=1.3" + ), txtRecord.texts.toTypedArray()) + + val aRecord = packet.additionals[1].getDataReader().readARecord() + assertEquals(InetAddress.getByName("192.168.1.197"), aRecord.address) + + val aaaaRecord = packet.additionals[2].getDataReader().readAAAARecord() + assertEquals(InetAddress.getByName("fe80::125b:adff:fe4a:1570"), aaaaRecord.address) + + val srvRecord = packet.additionals[3].getDataReader().readSRVRecord() + assertEquals("BRW105BAD4A1570.local", srvRecord.target) + assertEquals(0, srvRecord.weight.toInt()) + assertEquals(0, srvRecord.priority.toInt()) + assertEquals(631, srvRecord.port.toInt()) + + val nSECRecord = packet.additionals[4].getDataReader().readNSECRecord() + assertEquals("Brother DCP-L3550CDW series._ipp._tcp.local", nSECRecord.ownerName) + assertEquals(1, nSECRecord.typeBitMaps.size) + assertEquals(0, nSECRecord.typeBitMaps[0].first) + assertContentEquals(byteArrayOf(0, 0, 128.toByte(), 0, 64), nSECRecord.typeBitMaps[0].second) + } + + @Test + fun `ParseSamsungTV`() { + val data = loadByteArray("samsung-airplay.hex") + val packet = DnsPacket.parse(data) + assertEquals(QueryResponse.Response.value.toInt(), packet.header.queryResponse) + assertEquals(DnsOpcode.StandardQuery.value.toInt(), packet.header.opcode) + assertTrue(packet.header.authoritativeAnswer) + assertEquals(false, packet.header.truncated) + assertEquals(false, packet.header.recursionDesired) + assertEquals(false, packet.header.recursionAvailable) + assertEquals(false, packet.header.answerAuthenticated) + assertEquals(false, packet.header.nonAuthenticatedData) + assertEquals(DnsResponseCode.NoError, packet.header.responseCode) + assertEquals(0, packet.questions.size) + assertEquals(6, packet.answers.size) + assertEquals(0, packet.authorities.size) + assertEquals(4, packet.additionals.size) + + assertEquals("9.1.168.192.in-addr.arpa", packet.answers[0].name) + assertEquals(ResourceRecordType.PTR.value.toInt(), packet.answers[0].type) + assertEquals(ResourceRecordClass.IN.value.toInt(), packet.answers[0].clazz) + assertTrue(packet.answers[0].cacheFlush) + assertEquals(120u, packet.answers[0].timeToLive) + assertEquals(15, packet.answers[0].dataLength) + assertEquals("Samsung.local", packet.answers[0].getDataReader().readPTRRecord().domainName) + + val txtRecord = packet.answers[1].getDataReader().readTXTRecord() + assertContentEquals(arrayOf( + "acl=0", + "deviceid=D4:9D:C0:2F:52:16", + "features=0x7F8AD0,0x38BCB46", + "rsf=0x3", + "fv=p20.0.1", + "flags=0x244", + "model=URU8000", + "manufacturer=Samsung", + "serialNumber=0EQC3HDM900064X", + "protovers=1.1", + "srcvers=377.17.24.6", + "pi=ED:0C:A5:ED:10:08", + "psi=00000000-0000-0000-0000-ED0CA5ED1008", + "gid=00000000-0000-0000-0000-ED0CA5ED1008", + "gcgl=0", + "pk=d25488cbff1334756165cd7229a235475ef591f2595f38ed251d46b8a4d2345d" + ), txtRecord.texts.toTypedArray()) + + val srvRecord = packet.answers[4].getDataReader().readSRVRecord() + assertEquals(33482, srvRecord.port.toInt()) + assertEquals(0, srvRecord.priority.toInt()) + assertEquals(0, srvRecord.weight.toInt()) + assertEquals("Samsung.local", srvRecord.target) + + val aRecord = packet.answers[5].getDataReader().readARecord() + assertEquals(InetAddress.getByName("192.168.1.9"), aRecord.address) + + val nSECRecord = packet.additionals[0].getDataReader().readNSECRecord() + assertEquals("9.1.168.192.in-addr.arpa", nSECRecord.ownerName) + assertEquals(1, nSECRecord.typeBitMaps.size) + assertEquals(0, nSECRecord.typeBitMaps[0].first) + assertContentEquals(byteArrayOf(0, 8), nSECRecord.typeBitMaps[0].second) + + val optRecord = packet.additionals[3].getDataReader().readOPTRecord() + assertEquals(1, optRecord.options.size) + assertEquals(65001, optRecord.options[0].code.toInt()) + assertEquals(5, optRecord.options[0].data.size) + assertContentEquals(byteArrayOf(0, 0, 116, 206.toByte(), 97), optRecord.options[0].data) + } + + @Test + fun `UnicodeTest`() { + val data = ubyteArrayOf( + 0x00u, 0x00u, 0x84u, 0x00u, 0x00u, 0x00u, 0x00u, 0x01u, 0x00u, 0x00u, 0x00u, 0x01u, 0x15u, 0x41u, 0x69u, 0x64u, + 0x61u, 0x6Eu, 0xE2u, 0x80u, 0x99u, 0x73u, 0x20u, 0x4Du, 0x61u, 0x63u, 0x42u, 0x6Fu, 0x6Fu, 0x6Bu, 0x20u, 0x50u, + 0x72u, 0x6Fu, 0x0Fu, 0x5Fu, 0x63u, 0x6Fu, 0x6Du, 0x70u, 0x61u, 0x6Eu, 0x69u, 0x6Fu, 0x6Eu, 0x2Du, 0x6Cu, 0x69u, + 0x6Eu, 0x6Bu, 0x04u, 0x5Fu, 0x74u, 0x63u, 0x70u, 0x05u, 0x6Cu, 0x6Fu, 0x63u, 0x61u, 0x6Cu, 0x00u, 0x00u, 0x10u, + 0x80u, 0x01u, 0x00u, 0x00u, 0x11u, 0x94u, 0x00u, 0x5Bu, 0x16u, 0x72u, 0x70u, 0x42u, 0x41u, 0x3Du, 0x30u, 0x33u, + 0x3Au, 0x43u, 0x32u, 0x3Au, 0x33u, 0x33u, 0x3Au, 0x38u, 0x36u, 0x3Au, 0x33u, 0x43u, 0x3Au, 0x45u, 0x45u, 0x11u, + 0x72u, 0x70u, 0x41u, 0x44u, 0x3Du, 0x66u, 0x33u, 0x33u, 0x37u, 0x61u, 0x38u, 0x61u, 0x32u, 0x38u, 0x64u, 0x35u, + 0x31u, 0x0Cu, 0x72u, 0x70u, 0x46u, 0x6Cu, 0x3Du, 0x30u, 0x78u, 0x32u, 0x30u, 0x30u, 0x30u, 0x30u, 0x11u, 0x72u, + 0x70u, 0x48u, 0x4Eu, 0x3Du, 0x31u, 0x66u, 0x66u, 0x64u, 0x64u, 0x64u, 0x66u, 0x33u, 0x63u, 0x39u, 0x65u, 0x33u, + 0x07u, 0x72u, 0x70u, 0x4Du, 0x61u, 0x63u, 0x3Du, 0x30u, 0x0Au, 0x72u, 0x70u, 0x56u, 0x72u, 0x3Du, 0x33u, 0x36u, + 0x30u, 0x2Eu, 0x34u, 0xC0u, 0x0Cu, 0x00u, 0x2Fu, 0x80u, 0x01u, 0x00u, 0x00u, 0x11u, 0x94u, 0x00u, 0x09u, 0xC0u, + 0x0Cu, 0x00u, 0x05u, 0x00u, 0x00u, 0x80u, 0x00u, 0x40u + ) + + val packet = DnsPacket.parse(data.asByteArray()) + assertEquals("Aidan’s MacBook Pro._companion-link._tcp.local", packet.additionals[0].name) + } + + /*@Test + fun `TestReadDomainName`() { + val data = ubyteArrayOf( + 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x04u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x0Bu, 0x5Fu, 0x67u, 0x6Fu, + 0x6Fu, 0x67u, 0x6Cu, 0x65u, 0x63u, 0x61u, 0x73u, 0x74u, 0x04u, 0x5Fu, 0x74u, 0x63u, 0x70u, 0x05u, 0x6Cu, 0x6Fu, + 0x63u, 0x61u, 0x6Cu, 0xC0u, 0x0Cu, 0x00u, 0x0Cu, 0x00u, 0x01u, 0x08u, 0x5Fu, 0x61u, 0x69u, 0x72u, 0x70u, 0x6Cu, + 0x61u, 0x79u, 0xC0u, 0x18u, 0x00u, 0x0Cu, 0x00u, 0x01u, 0x09u, 0x5Fu, 0x66u, 0x61u, 0x73u, 0x74u, 0x63u, 0x61u, + 0x73u, 0x74u, 0xC0u, 0x18u, 0x00u, 0x0Cu, 0x00u, 0x01u, 0x06u, 0x5Fu, 0x66u, 0x63u, 0x61u, 0x73u, 0x74u, 0xC0u, + 0x18u, 0x00u, 0x0Cu, 0x00u, 0x01u + ) + + val packet = DnsPacket.parse(data.asByteArray()) + println() + }*/ + + private fun loadByteArray(name: String): ByteArray { + javaClass.classLoader.getResourceAsStream(name).use { input -> + requireNotNull(input) { "File not found: $name" } + val result = ByteArrayOutputStream() + val buffer = ByteArray(4096) + var length: Int + + while ((input.read(buffer).also { length = it }) > 0) { + result.write(buffer, 0, length) + } + return result.toByteArray() + } + } + + @Test + fun `ReserializeDnsPrinter`() { + val data = loadByteArray("samsung-airplay.hex") + val packet = DnsPacket.parse(data) + val writer = DnsWriter() + writer.writePacket( + header = packet.header, + questionCount = packet.questions.size, + questionWriter = { _, _ -> }, + answerCount = packet.answers.size, + answerWriter = { w, i -> + w.write(packet.answers[i]) { v -> + val reader = packet.answers[i].getDataReader() + when (i) { + 0, 2, 3 -> v.write(reader.readPTRRecord()) + 1 -> v.write(reader.readTXTRecord()) + 4 -> v.write(reader.readSRVRecord()) + 5 -> v.write(reader.readARecord()) + } + } + }, + authorityCount = packet.authorities.size, + authorityWriter = { _, _ -> }, + additionalsCount = packet.additionals.size, + additionalWriter = { w, i -> + w.write(packet.additionals[i]) { v -> + val reader = packet.additionals[i].getDataReader() + when (i) { + 0, 1, 2 -> v.write(reader.readNSECRecord()) + 3 -> v.write(reader.readOPTRecord()) + } + } + } + ) + + assertContentEquals(data, writer.toByteArray()) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/futo/platformplayer/UtilityTests.kt b/app/src/test/java/com/futo/platformplayer/UtilityTests.kt index 6103a43810c385f21ac42195dacc812c10a89292..2586ed56d3ce87759a43a21a08b8b31b2fb847cd 100644 --- a/app/src/test/java/com/futo/platformplayer/UtilityTests.kt +++ b/app/src/test/java/com/futo/platformplayer/UtilityTests.kt @@ -1,6 +1,8 @@ package com.futo.platformplayer +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertTrue import org.junit.Assert.assertThrows import org.junit.Test @@ -52,4 +54,21 @@ class UtilityTests { assertEquals("\ud83d\udc80\ud83d\udd14", "\\ud83d\\udc80\\ud83d\\udd14".decodeUnicode()) assertEquals("String with a slash (/) in it", "String with a slash (/) in it".decodeUnicode()) } + + + @Test + fun testMatchDomain() { + //TLD + assertTrue("test.abc.com".matchesDomain(".abc.com")) + assertTrue("abc.com".matchesDomain("abc.com")) + assertFalse("test.abc.com".matchesDomain("abc.com")) + assertThrows(IllegalStateException::class.java, { "test.uk".matchesDomain(".uk") }); + + + //SLD + assertTrue("abc.co.uk".matchesDomain("abc.co.uk")) + assertTrue("test.abc.co.uk".matchesDomain("test.abc.co.uk")) + assertTrue("test.abc.co.uk".matchesDomain(".abc.co.uk")) + assertThrows(IllegalStateException::class.java, { "test.abc.co.uk".matchesDomain(".co.uk") }); + } } \ No newline at end of file diff --git a/app/src/test/resources/samsung-airplay.hex b/app/src/test/resources/samsung-airplay.hex new file mode 100644 index 0000000000000000000000000000000000000000..8938268ca32c62ea4b892dcaafc3dafe32cfce85 Binary files /dev/null and b/app/src/test/resources/samsung-airplay.hex differ diff --git a/app/src/unstable/assets/sources/spotify b/app/src/unstable/assets/sources/spotify index 4d554e93882d29542cb05d2956f4b2484d7df27f..c700081466038ee4782610feaa05cd4d34d024d8 160000 --- a/app/src/unstable/assets/sources/spotify +++ b/app/src/unstable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 4d554e93882d29542cb05d2956f4b2484d7df27f +Subproject commit c700081466038ee4782610feaa05cd4d34d024d8 diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index 24f9e4456faf97fbbb866e1df2df9d94628ffcc6..91639d939738d9cc81ebdb1cd047ead9edd3a5e8 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 24f9e4456faf97fbbb866e1df2df9d94628ffcc6 +Subproject commit 91639d939738d9cc81ebdb1cd047ead9edd3a5e8