From 1315eebe8588378aaa3bda0fb9fde21ed28f9690 Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Sun, 2 Mar 2025 16:19:01 +0100 Subject: [PATCH 01/30] Improve webview destruction to avoid rare screen lock --- README.md | 140 +++++++- gradle/wrapper/gradle-wrapper.jar | Bin 54329 -> 43504 bytes gradle/wrapper/gradle-wrapper.properties | 4 +- gradlew | 306 +++++++++++------- gradlew.bat | 178 +++++----- .../src/main/java/com/ketch/android/Ketch.kt | 40 ++- .../main/java/com/ketch/android/data/Index.kt | 19 +- .../ketch/android/ui/KetchDialogFragment.kt | 60 +++- .../java/com/ketch/android/ui/KetchWebView.kt | 76 +++-- 9 files changed, 574 insertions(+), 249 deletions(-) diff --git a/README.md b/README.md index 7498cc51..aa231dff 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,103 @@ If you want you can use our [Sample](https://github.com/ketch-sdk/ketch-samples) private const val ENVIRONMENT = "production" ``` -### 5. Add listener and Ketch to your activity : +### 5. Add listener and Ketch to your activity + +Feel free to skip the listeners you don't really need. ```kotlin + import android.util.Log + import com.ketch.android.Ketch + import com.ketch.android.KetchSdk + import com.ketch.android.Consent + // ... + + // Create a listener to handle Ketch SDK events private val listener = object : Ketch.Listener { - ... - } + override fun onShow() { + // Called when a consent or preferences dialog is displayed + Log.d("KetchApp", "Dialog shown") + } + + override fun onDismiss() { + // Called when a dialog is dismissed + Log.d("KetchApp", "Dialog dismissed") + } + + override fun onEnvironmentUpdated(environment: String?) { + // Called when the environment is updated + Log.d("KetchApp", "Environment updated: $environment") + } + + override fun onRegionInfoUpdated(regionInfo: String?) { + // Called when region info is updated + Log.d("KetchApp", "Region info updated: $regionInfo") + } + + override fun onJurisdictionUpdated(jurisdiction: String?) { + // Called when jurisdiction is updated + Log.d("KetchApp", "Jurisdiction updated: $jurisdiction") + } + + override fun onIdentitiesUpdated(identities: String?) { + // Called when identities are updated + Log.d("KetchApp", "Identities updated: $identities") + } + + override fun onConsentUpdated(consent: Consent) { + // Called when consent preferences are updated + Log.d("KetchApp", "Consent updated") + + // Here you can handle consent changes for your app features + // Example: Enable/disable tracking based on consent + val hasAnalyticsConsent = consent.purposes["analytics"] == true + val hasAdvertisingConsent = consent.purposes["advertising"] == true + + if (hasAnalyticsConsent) { + // Enable analytics tracking + } else { + // Disable analytics tracking + } + + if (hasAdvertisingConsent) { + // Enable advertising features + } else { + // Disable advertising features + } + } + + override fun onError(errMsg: String?) { + // Called when an error occurs + Log.e("KetchApp", "Error: $errMsg") + } + + override fun onUSPrivacyUpdated(values: Map) { + // Called when US Privacy values are updated + Log.d("KetchApp", "US Privacy updated") + + // You can access the US Privacy string + val privacyString = values["IABUSPrivacy_String"] as? String + Log.d("KetchApp", "US Privacy String: $privacyString") + } + + override fun onTCFUpdated(values: Map) { + // Called when TCF values are updated + Log.d("KetchApp", "TCF updated") + + // You can access the TC string + val tcString = values["IABTCF_TCString"] as? String + Log.d("KetchApp", "TCF TC String: $tcString") + } + + override fun onGPPUpdated(values: Map) { + // Called when GPP values are updated + Log.d("KetchApp", "GPP updated") + + // You can access the GPP string + val gppString = values["IABGPP_HDR_GppString"] as? String + Log.d("KetchApp", "GPP String: $gppString") + } + } ``` ### 6. Create the Ketch Object: @@ -107,6 +198,49 @@ If you want you can use our [Sample](https://github.com/ketch-sdk/ketch-samples) } ``` +## Local Development Setup + +If you're developing or modifying the SDK and want to test your changes with the sample app, you can use Gradle's composite builds feature to link them together. + +### Setting up the Sample App for Local Development + +1. Clone both repositories: + - Ketch Android SDK: `git clone https://github.com/ketch-com/ketch-android.git` + - Ketch Samples: `git clone https://github.com/ketch-sdk/ketch-samples.git` + +2. In the sample app's `settings.gradle` file, add the following: + +```gradle +// Include the Ketch SDK from the local repository +includeBuild('../../ketch-android') { + dependencySubstitution { + substitute module('com.github.ketch-com:ketch-android') using project(':ketchsdk') + } +} +``` + +We use relative path here under assumption that both repositories are in the same parent directory. +If using a different structure, adjust the path accordingly. + +### Development Workflow + +1. Make changes to the SDK code in the ketch-android repository +2. Build and run the sample app from Android Studio +3. Android Studio will automatically include your local SDK changes +4. You can debug through both codebases in a single session + +### Troubleshooting + +Make sure you're rebuilding the project after making changes to the SDK. +If the sample app isn't picking up the local SDK, try running `./gradlew clean` in both projects. + +### Reverting to Remote Dependencies + +To revert back to using the remote GitHub dependency: + +1. Remove or comment out the `includeBuild` section in the sample app's `settings.gradle` file +2. Rebuild the sample app + ## Developer's Documentations ### com.ketch.android.Ketch diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index f6b961fd5a86aa5fbfe90f707c3138408be7c718..2c3521197d7c4586c843d1d3e9090525f1898cde 100644 GIT binary patch literal 43504 zcma&N1CXTcmMvW9vTb(Rwr$&4wr$(C?dmSu>@vG-+vuvg^_??!{yS%8zW-#zn-LkA z5&1^$^{lnmUON?}LBF8_K|(?T0Ra(xUH{($5eN!MR#ZihR#HxkUPe+_R8Cn`RRs(P z_^*#_XlXmGv7!4;*Y%p4nw?{bNp@UZHv1?Um8r6)Fei3p@ClJn0ECfg1hkeuUU@Or zDaPa;U3fE=3L}DooL;8f;P0ipPt0Z~9P0)lbStMS)ag54=uL9ia-Lm3nh|@(Y?B`; zx_#arJIpXH!U{fbCbI^17}6Ri*H<>OLR%c|^mh8+)*h~K8Z!9)DPf zR2h?lbDZQ`p9P;&DQ4F0sur@TMa!Y}S8irn(%d-gi0*WxxCSk*A?3lGh=gcYN?FGl z7D=Js!i~0=u3rox^eO3i@$0=n{K1lPNU zwmfjRVmLOCRfe=seV&P*1Iq=^i`502keY8Uy-WNPwVNNtJFx?IwAyRPZo2Wo1+S(xF37LJZ~%i)kpFQ3Fw=mXfd@>%+)RpYQLnr}B~~zoof(JVm^^&f zxKV^+3D3$A1G;qh4gPVjhrC8e(VYUHv#dy^)(RoUFM?o%W-EHxufuWf(l*@-l+7vt z=l`qmR56K~F|v<^Pd*p~1_y^P0P^aPC##d8+HqX4IR1gu+7w#~TBFphJxF)T$2WEa zxa?H&6=Qe7d(#tha?_1uQys2KtHQ{)Qco)qwGjrdNL7thd^G5i8Os)CHqc>iOidS} z%nFEDdm=GXBw=yXe1W-ShHHFb?Cc70+$W~z_+}nAoHFYI1MV1wZegw*0y^tC*s%3h zhD3tN8b=Gv&rj}!SUM6|ajSPp*58KR7MPpI{oAJCtY~JECm)*m_x>AZEu>DFgUcby z1Qaw8lU4jZpQ_$;*7RME+gq1KySGG#Wql>aL~k9tLrSO()LWn*q&YxHEuzmwd1?aAtI zBJ>P=&$=l1efe1CDU;`Fd+_;&wI07?V0aAIgc(!{a z0Jg6Y=inXc3^n!U0Atk`iCFIQooHqcWhO(qrieUOW8X(x?(RD}iYDLMjSwffH2~tB z)oDgNBLB^AJBM1M^c5HdRx6fBfka`(LD-qrlh5jqH~);#nw|iyp)()xVYak3;Ybik z0j`(+69aK*B>)e_p%=wu8XC&9e{AO4c~O1U`5X9}?0mrd*m$_EUek{R?DNSh(=br# z#Q61gBzEpmy`$pA*6!87 zSDD+=@fTY7<4A?GLqpA?Pb2z$pbCc4B4zL{BeZ?F-8`s$?>*lXXtn*NC61>|*w7J* z$?!iB{6R-0=KFmyp1nnEmLsA-H0a6l+1uaH^g%c(p{iT&YFrbQ$&PRb8Up#X3@Zsk zD^^&LK~111%cqlP%!_gFNa^dTYT?rhkGl}5=fL{a`UViaXWI$k-UcHJwmaH1s=S$4 z%4)PdWJX;hh5UoK?6aWoyLxX&NhNRqKam7tcOkLh{%j3K^4Mgx1@i|Pi&}<^5>hs5 zm8?uOS>%)NzT(%PjVPGa?X%`N2TQCKbeH2l;cTnHiHppPSJ<7y-yEIiC!P*ikl&!B z%+?>VttCOQM@ShFguHVjxX^?mHX^hSaO_;pnyh^v9EumqSZTi+#f&_Vaija0Q-e*| z7ulQj6Fs*bbmsWp{`auM04gGwsYYdNNZcg|ph0OgD>7O}Asn7^Z=eI>`$2*v78;sj-}oMoEj&@)9+ycEOo92xSyY344^ z11Hb8^kdOvbf^GNAK++bYioknrpdN>+u8R?JxG=!2Kd9r=YWCOJYXYuM0cOq^FhEd zBg2puKy__7VT3-r*dG4c62Wgxi52EMCQ`bKgf*#*ou(D4-ZN$+mg&7$u!! z-^+Z%;-3IDwqZ|K=ah85OLwkO zKxNBh+4QHh)u9D?MFtpbl)us}9+V!D%w9jfAMYEb>%$A;u)rrI zuBudh;5PN}_6J_}l55P3l_)&RMlH{m!)ai-i$g)&*M`eN$XQMw{v^r@-125^RRCF0 z^2>|DxhQw(mtNEI2Kj(;KblC7x=JlK$@78`O~>V!`|1Lm-^JR$-5pUANAnb(5}B}JGjBsliK4& zk6y(;$e&h)lh2)L=bvZKbvh@>vLlreBdH8No2>$#%_Wp1U0N7Ank!6$dFSi#xzh|( zRi{Uw%-4W!{IXZ)fWx@XX6;&(m_F%c6~X8hx=BN1&q}*( zoaNjWabE{oUPb!Bt$eyd#$5j9rItB-h*5JiNi(v^e|XKAj*8(k<5-2$&ZBR5fF|JA z9&m4fbzNQnAU}r8ab>fFV%J0z5awe#UZ|bz?Ur)U9bCIKWEzi2%A+5CLqh?}K4JHi z4vtM;+uPsVz{Lfr;78W78gC;z*yTch~4YkLr&m-7%-xc ztw6Mh2d>_iO*$Rd8(-Cr1_V8EO1f*^@wRoSozS) zy1UoC@pruAaC8Z_7~_w4Q6n*&B0AjOmMWa;sIav&gu z|J5&|{=a@vR!~k-OjKEgPFCzcJ>#A1uL&7xTDn;{XBdeM}V=l3B8fE1--DHjSaxoSjNKEM9|U9#m2<3>n{Iuo`r3UZp;>GkT2YBNAh|b z^jTq-hJp(ebZh#Lk8hVBP%qXwv-@vbvoREX$TqRGTgEi$%_F9tZES@z8Bx}$#5eeG zk^UsLBH{bc2VBW)*EdS({yw=?qmevwi?BL6*=12k9zM5gJv1>y#ML4!)iiPzVaH9% zgSImetD@dam~e>{LvVh!phhzpW+iFvWpGT#CVE5TQ40n%F|p(sP5mXxna+Ev7PDwA zamaV4m*^~*xV+&p;W749xhb_X=$|LD;FHuB&JL5?*Y2-oIT(wYY2;73<^#46S~Gx| z^cez%V7x$81}UWqS13Gz80379Rj;6~WdiXWOSsdmzY39L;Hg3MH43o*y8ibNBBH`(av4|u;YPq%{R;IuYow<+GEsf@R?=@tT@!}?#>zIIn0CoyV!hq3mw zHj>OOjfJM3F{RG#6ujzo?y32m^tgSXf@v=J$ELdJ+=5j|=F-~hP$G&}tDZsZE?5rX ztGj`!S>)CFmdkccxM9eGIcGnS2AfK#gXwj%esuIBNJQP1WV~b~+D7PJTmWGTSDrR` zEAu4B8l>NPuhsk5a`rReSya2nfV1EK01+G!x8aBdTs3Io$u5!6n6KX%uv@DxAp3F@{4UYg4SWJtQ-W~0MDb|j-$lwVn znAm*Pl!?Ps&3wO=R115RWKb*JKoexo*)uhhHBncEDMSVa_PyA>k{Zm2(wMQ(5NM3# z)jkza|GoWEQo4^s*wE(gHz?Xsg4`}HUAcs42cM1-qq_=+=!Gk^y710j=66(cSWqUe zklbm8+zB_syQv5A2rj!Vbw8;|$@C!vfNmNV!yJIWDQ>{+2x zKjuFX`~~HKG~^6h5FntRpnnHt=D&rq0>IJ9#F0eM)Y-)GpRjiN7gkA8wvnG#K=q{q z9dBn8_~wm4J<3J_vl|9H{7q6u2A!cW{bp#r*-f{gOV^e=8S{nc1DxMHFwuM$;aVI^ zz6A*}m8N-&x8;aunp1w7_vtB*pa+OYBw=TMc6QK=mbA-|Cf* zvyh8D4LRJImooUaSb7t*fVfih<97Gf@VE0|z>NcBwBQze);Rh!k3K_sfunToZY;f2 z^HmC4KjHRVg+eKYj;PRN^|E0>Gj_zagfRbrki68I^#~6-HaHg3BUW%+clM1xQEdPYt_g<2K+z!$>*$9nQ>; zf9Bei{?zY^-e{q_*|W#2rJG`2fy@{%6u0i_VEWTq$*(ZN37|8lFFFt)nCG({r!q#9 z5VK_kkSJ3?zOH)OezMT{!YkCuSSn!K#-Rhl$uUM(bq*jY? zi1xbMVthJ`E>d>(f3)~fozjg^@eheMF6<)I`oeJYx4*+M&%c9VArn(OM-wp%M<-`x z7sLP1&3^%Nld9Dhm@$3f2}87!quhI@nwd@3~fZl_3LYW-B?Ia>ui`ELg z&Qfe!7m6ze=mZ`Ia9$z|ARSw|IdMpooY4YiPN8K z4B(ts3p%2i(Td=tgEHX z0UQ_>URBtG+-?0E;E7Ld^dyZ;jjw0}XZ(}-QzC6+NN=40oDb2^v!L1g9xRvE#@IBR zO!b-2N7wVfLV;mhEaXQ9XAU+>=XVA6f&T4Z-@AX!leJ8obP^P^wP0aICND?~w&NykJ#54x3_@r7IDMdRNy4Hh;h*!u(Ol(#0bJdwEo$5437-UBjQ+j=Ic>Q2z` zJNDf0yO6@mr6y1#n3)s(W|$iE_i8r@Gd@!DWDqZ7J&~gAm1#~maIGJ1sls^gxL9LLG_NhU!pTGty!TbhzQnu)I*S^54U6Yu%ZeCg`R>Q zhBv$n5j0v%O_j{QYWG!R9W?5_b&67KB$t}&e2LdMvd(PxN6Ir!H4>PNlerpBL>Zvyy!yw z-SOo8caEpDt(}|gKPBd$qND5#a5nju^O>V&;f890?yEOfkSG^HQVmEbM3Ugzu+UtH zC(INPDdraBN?P%kE;*Ae%Wto&sgw(crfZ#Qy(<4nk;S|hD3j{IQRI6Yq|f^basLY; z-HB&Je%Gg}Jt@={_C{L$!RM;$$|iD6vu#3w?v?*;&()uB|I-XqEKqZPS!reW9JkLewLb!70T7n`i!gNtb1%vN- zySZj{8-1>6E%H&=V}LM#xmt`J3XQoaD|@XygXjdZ1+P77-=;=eYpoEQ01B@L*a(uW zrZeZz?HJsw_4g0vhUgkg@VF8<-X$B8pOqCuWAl28uB|@r`19DTUQQsb^pfqB6QtiT z*`_UZ`fT}vtUY#%sq2{rchyfu*pCg;uec2$-$N_xgjZcoumE5vSI{+s@iLWoz^Mf; zuI8kDP{!XY6OP~q5}%1&L}CtfH^N<3o4L@J@zg1-mt{9L`s^z$Vgb|mr{@WiwAqKg zp#t-lhrU>F8o0s1q_9y`gQNf~Vb!F%70f}$>i7o4ho$`uciNf=xgJ>&!gSt0g;M>*x4-`U)ysFW&Vs^Vk6m%?iuWU+o&m(2Jm26Y(3%TL; zA7T)BP{WS!&xmxNw%J=$MPfn(9*^*TV;$JwRy8Zl*yUZi8jWYF>==j~&S|Xinsb%c z2?B+kpet*muEW7@AzjBA^wAJBY8i|#C{WtO_or&Nj2{=6JTTX05}|H>N2B|Wf!*3_ z7hW*j6p3TvpghEc6-wufFiY!%-GvOx*bZrhZu+7?iSrZL5q9}igiF^*R3%DE4aCHZ zqu>xS8LkW+Auv%z-<1Xs92u23R$nk@Pk}MU5!gT|c7vGlEA%G^2th&Q*zfg%-D^=f z&J_}jskj|Q;73NP4<4k*Y%pXPU2Thoqr+5uH1yEYM|VtBPW6lXaetokD0u z9qVek6Q&wk)tFbQ8(^HGf3Wp16gKmr>G;#G(HRBx?F`9AIRboK+;OfHaLJ(P>IP0w zyTbTkx_THEOs%Q&aPrxbZrJlio+hCC_HK<4%f3ZoSAyG7Dn`=X=&h@m*|UYO-4Hq0 z-Bq&+Ie!S##4A6OGoC~>ZW`Y5J)*ouaFl_e9GA*VSL!O_@xGiBw!AF}1{tB)z(w%c zS1Hmrb9OC8>0a_$BzeiN?rkPLc9%&;1CZW*4}CDDNr2gcl_3z+WC15&H1Zc2{o~i) z)LLW=WQ{?ricmC`G1GfJ0Yp4Dy~Ba;j6ZV4r{8xRs`13{dD!xXmr^Aga|C=iSmor% z8hi|pTXH)5Yf&v~exp3o+sY4B^^b*eYkkCYl*T{*=-0HniSA_1F53eCb{x~1k3*`W zr~};p1A`k{1DV9=UPnLDgz{aJH=-LQo<5%+Em!DNN252xwIf*wF_zS^!(XSm(9eoj z=*dXG&n0>)_)N5oc6v!>-bd(2ragD8O=M|wGW z!xJQS<)u70m&6OmrF0WSsr@I%T*c#Qo#Ha4d3COcX+9}hM5!7JIGF>7<~C(Ear^Sn zm^ZFkV6~Ula6+8S?oOROOA6$C&q&dp`>oR-2Ym3(HT@O7Sd5c~+kjrmM)YmgPH*tL zX+znN>`tv;5eOfX?h{AuX^LK~V#gPCu=)Tigtq9&?7Xh$qN|%A$?V*v=&-2F$zTUv z`C#WyIrChS5|Kgm_GeudCFf;)!WH7FI60j^0o#65o6`w*S7R@)88n$1nrgU(oU0M9 zx+EuMkC>(4j1;m6NoGqEkpJYJ?vc|B zOlwT3t&UgL!pX_P*6g36`ZXQ; z9~Cv}ANFnJGp(;ZhS(@FT;3e)0)Kp;h^x;$*xZn*k0U6-&FwI=uOGaODdrsp-!K$Ac32^c{+FhI-HkYd5v=`PGsg%6I`4d9Jy)uW0y%) zm&j^9WBAp*P8#kGJUhB!L?a%h$hJgQrx!6KCB_TRo%9{t0J7KW8!o1B!NC)VGLM5! zpZy5Jc{`r{1e(jd%jsG7k%I+m#CGS*BPA65ZVW~fLYw0dA-H_}O zrkGFL&P1PG9p2(%QiEWm6x;U-U&I#;Em$nx-_I^wtgw3xUPVVu zqSuKnx&dIT-XT+T10p;yjo1Y)z(x1fb8Dzfn8e yu?e%!_ptzGB|8GrCfu%p?(_ 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 TZiJi*F`$tkWLn) literal 54329 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

<5G2M1WZP5 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 F{|7XC?P&l2 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 41681a77..09523c0e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip \ No newline at end of file diff --git a/gradlew b/gradlew index 77f8b111..f5feea6d 100755 --- a/gradlew +++ b/gradlew @@ -1,78 +1,130 @@ -#!/usr/bin/env sh +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -81,94 +133,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=$((i+1)) + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=$(save "$@") - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" fi -exec "$JAVACMD" "$@" - +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index f9553162..9b42019c 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,84 +1,94 @@ -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index a34046bd..bc754d77 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -1,6 +1,7 @@ package com.ketch.android import android.content.Context +import android.os.Handler import android.util.Log import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle @@ -367,13 +368,30 @@ class Ketch private constructor( } override fun onClose(status: HideExperienceStatus) { - // Dismiss dialog fragment - findDialogFragment()?.let { - (it as? KetchDialogFragment)?.dismissAllowingStateLoss() + // Log the close event for debugging + Log.d(TAG, "onClose called with status: ${status.name}") + + // Dismiss dialog fragment with safety checks + try { + findDialogFragment()?.let { fragment -> + if (fragment.isAdded && !fragment.isRemoving) { + (fragment as? KetchDialogFragment)?.dismissAllowingStateLoss() + // Small delay to ensure the fragment has time to start dismissal + Handler(android.os.Looper.getMainLooper()).postDelayed({ + // Execute onDismiss event listener after dialog is dismissed + this@Ketch.listener?.onDismiss(status) + }, 50) + return@onClose + } + } + + // If we didn't return above, call the listener directly + this@Ketch.listener?.onDismiss(status) + } catch (e: Exception) { + Log.e(TAG, "Error dismissing dialog: ${e.message}", e) + // Ensure listener is called even if there's an exception + this@Ketch.listener?.onDismiss(status) } - - // Execute onDismiss event listener - this@Ketch.listener?.onDismiss(status) } override fun onWillShowExperience(experienceType: WillShowExperienceType) { @@ -381,16 +399,6 @@ class Ketch private constructor( this@Ketch.listener?.onWillShowExperience(experienceType) } - override fun onTapOutside() { - // Dismiss dialog fragment - findDialogFragment()?.let { - (it as? KetchDialogFragment)?.dismissAllowingStateLoss() - - // Execute onDismiss event listener - this@Ketch.listener?.onDismiss(HideExperienceStatus.None) - } - } - private fun showConsentPopup() { if (!isActivityActive()) { Log.d(TAG, "Not showing as activity is not active") diff --git a/ketchsdk/src/main/java/com/ketch/android/data/Index.kt b/ketchsdk/src/main/java/com/ketch/android/data/Index.kt index 3369f5a3..8837daaa 100644 --- a/ketchsdk/src/main/java/com/ketch/android/data/Index.kt +++ b/ketchsdk/src/main/java/com/ketch/android/data/Index.kt @@ -1,7 +1,7 @@ package com.ketch.android.data /* -* https://global.ketchcdn.com/web/v3//config/ketch_samples/android/boot.js?ketch_log=DEBUG +* https://global.ketchcdn.com/web/v3/config/ketch_samples/android/boot.js?ketch_log=DEBUG * &ketch_lang=en&ketch_jurisdiction=default&ketch_region=US * &ketch_show=preferences&ketch_preferences_tabs=overviewTab,rightsTab,consentsTab,subscriptionsTab */ @@ -156,12 +156,21 @@ fun getIndexHtml( " document.getElementsByTagName('head')[0].appendChild(e);\n" + " }\n" + " // We put the script inside body, otherwise document.body will be null\n" + - " // Trigger taps outside the dialog\n" + - " document.body.addEventListener('touchstart', function (e) {\n" + + " // Improved tap outside detection with multiple event types and protection against race conditions\n" + + " function handleTapOutside(e) {\n" + + " // Ensure we only handle taps on the body element\n" + " if (e.target === document.body) {\n" + - " emitEvent('tapOutside', [getDialogSize()]);\n" + + " console.log('Tap outside detected');\n" + + " // Use setTimeout to avoid race conditions with WebView destruction\n" + + " setTimeout(() => {\n" + + " emitEvent('hideExperience', ['close']);\n" + + " }, 0);\n" + " }\n" + - " });\n" + + " }\n" + + "\n" + + " // Handle both touchstart and mousedown for maximum compatibility\n" + + " document.body.addEventListener('touchstart', handleTapOutside, { passive: true });\n" + + " document.body.addEventListener('mousedown', handleTapOutside, { passive: true });\n" + " initKetchTag({" + "ketch_log: \"${logLevel}\"," + if (language?.isNotBlank() == true) { diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt index 3f969252..e42f1339 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt @@ -5,6 +5,9 @@ import android.content.res.Configuration import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log import android.view.Gravity import android.view.LayoutInflater import android.view.View @@ -49,14 +52,55 @@ internal class KetchDialogFragment() : DialogFragment() { } override fun onDestroyView() { - - binding.root.removeView(webView) - - // Cancel any coroutines in KetchWebView and fully tear down webview to prevent memory leaks - webView?.kill() - - // Set webview reference to null to prevent memory leaks - webView = null + try { + Log.d(TAG, "onDestroyView: Beginning WebView cleanup") + + // Get a local reference to the WebView before nulling it out + val webViewToCleanup = webView + + // Set the class reference to null first to prevent any new operations from being triggered + webView = null + + // Perform cleanup on the local reference if it exists + webViewToCleanup?.let { wv -> + // Prevent any new touch events or interactions + wv.setOnTouchListener { _, _ -> true } + + try { + // Prevent any JavaScript execution during cleanup + wv.evaluateJavascript( + "document.body.style.pointerEvents = 'none';" + + "document.body.removeEventListener('touchstart', handleTapOutside);" + + "document.body.removeEventListener('mousedown', handleTapOutside);", + null + ) + } catch (e: Exception) { + // Ignore JavaScript errors during cleanup + Log.e(TAG, "Error disabling JS events: ${e.message}") + } + + // Wait a moment for any pending events to clear + Handler(Looper.getMainLooper()).post { + try { + // Remove from view hierarchy + binding.root.removeView(wv) + + // Clean up the WebView + wv.destroy() + + // Suggest garbage collection + Runtime.getRuntime().gc() + } catch (e: Exception) { + Log.e(TAG, "Error in delayed WebView cleanup: ${e.message}", e) + } + } + } + + Log.d(TAG, "onDestroyView: WebView cleanup initiated") + } catch (e: Exception) { + Log.e(TAG, "Error cleaning up WebView: ${e.message}", e) + } + super.onDestroyView() } diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt index bad32aa6..90c6a19a 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt @@ -7,6 +7,7 @@ import android.graphics.Bitmap import android.os.Handler import android.os.Looper import android.util.Log +import android.view.ViewGroup import android.webkit.ConsoleMessage import android.webkit.JavascriptInterface import android.webkit.WebChromeClient @@ -75,15 +76,63 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con setWebContentsDebuggingEnabled(true) } - // Cancel any coroutines in KetchWebView and fully tear down webview to prevent memory leaks - fun kill() { - localContentWebViewClient.cancelCoroutines() - stopLoading() - clearHistory() - clearCache(true) - loadUrl("about:blank") - removeAllViews() - destroy() + // Properly clean up WebView resources to prevent memory leaks and renderer crashes + fun destroy() { + try { + Log.d(TAG, "Beginning WebView cleanup") + + // Prevent further page loads + stopLoading() + + // Add a blank/empty handler for JS errors during cleanup + evaluateJavascript("window.onerror = function(message, url, line, column, error) { return true; };", null) + + // Remove JavaScript interface first to prevent any further callbacks + removeJavascriptInterface("androidListener") + + // Set listener to null to prevent callbacks during cleanup + listener = null + + // Cancel all coroutines next + localContentWebViewClient.cancelCoroutines() + + // Disable JavaScript to prevent further execution + settings.javaScriptEnabled = false + + // Stop any ongoing loads or processing + onPause() + + // Small pause to allow WebView internals to stabilize + try { + Thread.sleep(50) + } catch (e: InterruptedException) { + Log.e(TAG, "Sleep interrupted during WebView cleanup", e) + } + + // Clear WebView state + clearHistory() + clearCache(true) + clearFormData() + clearSslPreferences() + + // Remove all views + removeAllViews() + + // Set a low global layout limit to reduce memory pressure + setLayoutParams( + ViewGroup.LayoutParams(1, 1) + ) + + // Finally call the parent WebView's destroy method + super.destroy() + + // Suggest garbage collection to reclaim memory + Runtime.getRuntime().gc() + + Log.d(TAG, "WebView cleanup completed successfully") + } catch (e: Exception) { + Log.e(TAG, "Error during WebView cleanup: ${e.message}", e) + } } class LocalContentWebViewClient(private var shouldRetry: Boolean = false) : WebViewClientCompat() { @@ -318,14 +367,6 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con } } - @JavascriptInterface - fun tapOutside(dialogSize: String?) { - Log.d(TAG, "tapOutside: $dialogSize") - runOnMainThread { - ketchWebView.listener?.onTapOutside() - } - } - @JavascriptInterface fun geoip(ip: String?) { } @@ -412,7 +453,6 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con fun changeDialog(display: ContentDisplay) fun onClose(status: HideExperienceStatus) fun onWillShowExperience(experienceType: WillShowExperienceType) - fun onTapOutside() } internal enum class ExperienceType { From aeac71af5db3f735166d208cee08530b88e1618f Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Sun, 2 Mar 2025 16:51:07 +0100 Subject: [PATCH 02/30] cleanup the redundant section --- README.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/README.md b/README.md index aa231dff..3113545f 100644 --- a/README.md +++ b/README.md @@ -222,13 +222,6 @@ includeBuild('../../ketch-android') { We use relative path here under assumption that both repositories are in the same parent directory. If using a different structure, adjust the path accordingly. -### Development Workflow - -1. Make changes to the SDK code in the ketch-android repository -2. Build and run the sample app from Android Studio -3. Android Studio will automatically include your local SDK changes -4. You can debug through both codebases in a single session - ### Troubleshooting Make sure you're rebuilding the project after making changes to the SDK. From 5b4885704efb22ac8c72624dd1755258d5d625ab Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Sun, 2 Mar 2025 17:08:52 +0100 Subject: [PATCH 03/30] comments fix --- .../src/main/java/com/ketch/android/ui/KetchDialogFragment.kt | 3 +-- ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt index e42f1339..a2b69ccb 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt @@ -67,9 +67,8 @@ internal class KetchDialogFragment() : DialogFragment() { wv.setOnTouchListener { _, _ -> true } try { - // Prevent any JavaScript execution during cleanup + // Prevent any JavaScript execution by removeing event listeners wv.evaluateJavascript( - "document.body.style.pointerEvents = 'none';" + "document.body.removeEventListener('touchstart', handleTapOutside);" + "document.body.removeEventListener('mousedown', handleTapOutside);", null diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt index 90c6a19a..e4ee173e 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt @@ -119,6 +119,8 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con removeAllViews() // Set a low global layout limit to reduce memory pressure + // This is a common practice for WebView cleanup to ensure the view doesn't + // maintain large layout allocations while waiting for destruction setLayoutParams( ViewGroup.LayoutParams(1, 1) ) From 6ce233b38f265b1a243f63c1316be2db643f184c Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Sun, 2 Mar 2025 18:48:04 +0100 Subject: [PATCH 04/30] switched to reuse the webview between dialog runs --- .../src/main/java/com/ketch/android/Ketch.kt | 321 +++++++++++------- .../ketch/android/ui/KetchDialogFragment.kt | 11 +- .../java/com/ketch/android/ui/KetchWebView.kt | 2 +- 3 files changed, 196 insertions(+), 138 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index bc754d77..459d0b70 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -32,6 +32,10 @@ class Ketch private constructor( private var language: String? = null private var jurisdiction: String? = null private var region: String? = null + + // Add a WebView instance and dialog state flag + private var currentWebView: KetchWebView? = null + private var isActive = false /** * Retrieve a String value from the preferences. @@ -207,7 +211,13 @@ class Ketch private constructor( fun dismissDialog() { findDialogFragment()?.let { (it as? KetchDialogFragment)?.dismissAllowingStateLoss() + Handler(android.os.Looper.getMainLooper()).postDelayed({ + this@Ketch.listener?.onDismiss(HideExperienceStatus.None) + isActive = false + }, 100) + } ?: run { this@Ketch.listener?.onDismiss(HideExperienceStatus.None) + isActive = false } } @@ -267,172 +277,203 @@ class Ketch private constructor( } private fun createWebView(shouldRetry: Boolean = false, synchronousPreferences: Boolean = false): KetchWebView? { - - val webView = context.get()?.let { KetchWebView(it, shouldRetry) } ?: return null - - // Enable debug mode - if (logLevel === LogLevel.DEBUG) { - webView.setDebugMode() + if (isActive) { + Log.d(TAG, "WebView creation blocked - dialog operation in progress") + return null } - - webView.listener = object : KetchWebView.WebViewListener { - - private var config: KetchConfig? = null - private var showConsent: Boolean = false - - override fun showConsent() { - if (config == null) { - showConsent = true - return + + if (findDialogFragment() != null) { + Log.d(TAG, "WebView creation blocked - dialog already exists") + return null + } + + isActive = true + + try { + if (currentWebView != null) { + val contextRef = context.get() + if (contextRef != null && contextRef is LifecycleOwner && + contextRef.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) { + Log.d(TAG, "Reusing current WebView instance") + return currentWebView + } else { + Log.d(TAG, "Context invalid, cleaning up WebView") + currentWebView?.destroy() + currentWebView = null } - showConsentPopup() } + + val webView = context.get()?.let { KetchWebView(it, shouldRetry) } ?: run { + isActive = false + return null + } + + currentWebView = webView - override fun showPreferences() { - if (!isActivityActive()) { - Log.d(TAG, "Not showing as activity is not active") - return - } + if (logLevel === LogLevel.DEBUG) { + webView.setDebugMode() + } - if (findDialogFragment() != null) { - Log.d(TAG, "Not showing as dialog already exists") - return + webView.listener = object : KetchWebView.WebViewListener { + + private var config: KetchConfig? = null + private var showConsent: Boolean = false + + override fun showConsent() { + if (config == null) { + showConsent = true + return + } + showConsentPopup() } - val dialog = KetchDialogFragment.newInstance() + override fun showPreferences() { + if (!isActivityActive()) { + Log.d(TAG, "Not showing as activity is not active") + return + } + + if (findDialogFragment() != null) { + Log.d(TAG, "Not showing as dialog already exists") + return + } + + val dialog = KetchDialogFragment.newInstance() - fragmentManager.get()?.let { - dialog.show(it, webView) - this@Ketch.listener?.onShow() + fragmentManager.get()?.let { + dialog.show(it, webView) + this@Ketch.listener?.onShow() + } } - } - override fun onUSPrivacyUpdated(values: Map) { - getPreferences().saveValues(values, "USPrivacy", synchronousPreferences) - this@Ketch.listener?.onUSPrivacyUpdated(values) - } + override fun onUSPrivacyUpdated(values: Map) { + getPreferences().saveValues(values, "USPrivacy", synchronousPreferences) + this@Ketch.listener?.onUSPrivacyUpdated(values) + } - override fun onTCFUpdated(values: Map) { - getPreferences().saveValues(values, "TCF", synchronousPreferences) - this@Ketch.listener?.onTCFUpdated(values) - } + override fun onTCFUpdated(values: Map) { + getPreferences().saveValues(values, "TCF", synchronousPreferences) + this@Ketch.listener?.onTCFUpdated(values) + } - override fun onGPPUpdated(values: Map) { - getPreferences().saveValues(values, "GPP", synchronousPreferences) - this@Ketch.listener?.onGPPUpdated(values) - } + override fun onGPPUpdated(values: Map) { + getPreferences().saveValues(values, "GPP", synchronousPreferences) + this@Ketch.listener?.onGPPUpdated(values) + } - override fun onConfigUpdated(config: KetchConfig?) { - // Set internal config field - this.config = config + override fun onConfigUpdated(config: KetchConfig?) { + // Set internal config field + this.config = config - // Call config update listener - this@Ketch.listener?.onConfigUpdated(config) + // Call config update listener + this@Ketch.listener?.onConfigUpdated(config) - if (!showConsent) { - return + if (!showConsent) { + return + } + showConsentPopup() } - showConsentPopup() - } - override fun onEnvironmentUpdated(environment: String?) { - this@Ketch.listener?.onEnvironmentUpdated(environment) - } + override fun onEnvironmentUpdated(environment: String?) { + this@Ketch.listener?.onEnvironmentUpdated(environment) + } - override fun onRegionInfoUpdated(regionInfo: String?) { - this@Ketch.listener?.onRegionInfoUpdated(regionInfo) - } + override fun onRegionInfoUpdated(regionInfo: String?) { + this@Ketch.listener?.onRegionInfoUpdated(regionInfo) + } - override fun onJurisdictionUpdated(jurisdiction: String?) { - this@Ketch.listener?.onJurisdictionUpdated(jurisdiction) - } + override fun onJurisdictionUpdated(jurisdiction: String?) { + this@Ketch.listener?.onJurisdictionUpdated(jurisdiction) + } - override fun onIdentitiesUpdated(identities: String?) { - this@Ketch.listener?.onIdentitiesUpdated(identities) - } + override fun onIdentitiesUpdated(identities: String?) { + this@Ketch.listener?.onIdentitiesUpdated(identities) + } - override fun onConsentUpdated(consent: Consent) { - this@Ketch.listener?.onConsentUpdated(consent) - } + override fun onConsentUpdated(consent: Consent) { + this@Ketch.listener?.onConsentUpdated(consent) + } - override fun onError(errMsg: String?) { - this@Ketch.listener?.onError(errMsg) - } + override fun onError(errMsg: String?) { + this@Ketch.listener?.onError(errMsg) + } - override fun changeDialog(display: ContentDisplay) { - findDialogFragment()?.let { - (it as? KetchDialogFragment)?.apply { - isCancelable = getDisposableContentInteractions(display) + override fun changeDialog(display: ContentDisplay) { + findDialogFragment()?.let { + (it as? KetchDialogFragment)?.apply { + isCancelable = getDisposableContentInteractions(display) + } } } - } - override fun onClose(status: HideExperienceStatus) { - // Log the close event for debugging - Log.d(TAG, "onClose called with status: ${status.name}") - - // Dismiss dialog fragment with safety checks - try { - findDialogFragment()?.let { fragment -> - if (fragment.isAdded && !fragment.isRemoving) { - (fragment as? KetchDialogFragment)?.dismissAllowingStateLoss() - // Small delay to ensure the fragment has time to start dismissal - Handler(android.os.Looper.getMainLooper()).postDelayed({ - // Execute onDismiss event listener after dialog is dismissed - this@Ketch.listener?.onDismiss(status) - }, 50) - return@onClose + override fun onClose(status: HideExperienceStatus) { + Log.d(TAG, "onClose called with status: ${status.name}") + + try { + findDialogFragment()?.let { fragment -> + if (fragment.isAdded && !fragment.isRemoving) { + (fragment as? KetchDialogFragment)?.dismissAllowingStateLoss() + Handler(android.os.Looper.getMainLooper()).postDelayed({ + this@Ketch.listener?.onDismiss(status) + isActive = false + }, 100) + return@onClose + } } + + this@Ketch.listener?.onDismiss(status) + isActive = false + } catch (e: Exception) { + Log.e(TAG, "Error dismissing dialog: ${e.message}", e) + this@Ketch.listener?.onDismiss(status) + isActive = false } - - // If we didn't return above, call the listener directly - this@Ketch.listener?.onDismiss(status) - } catch (e: Exception) { - Log.e(TAG, "Error dismissing dialog: ${e.message}", e) - // Ensure listener is called even if there's an exception - this@Ketch.listener?.onDismiss(status) } - } - - override fun onWillShowExperience(experienceType: WillShowExperienceType) { - // Execute onWillShowExperience listener - this@Ketch.listener?.onWillShowExperience(experienceType) - } - private fun showConsentPopup() { - if (!isActivityActive()) { - Log.d(TAG, "Not showing as activity is not active") - return + override fun onWillShowExperience(experienceType: WillShowExperienceType) { + // Execute onWillShowExperience listener + this@Ketch.listener?.onWillShowExperience(experienceType) } - if (findDialogFragment() != null) { - Log.d(TAG, "Not showing as dialog already exists") - return - } + private fun showConsentPopup() { + if (!isActivityActive()) { + Log.d(TAG, "Not showing as activity is not active") + return + } - val dialog = KetchDialogFragment.newInstance().apply { - val disableContentInteractions = getDisposableContentInteractions( - config?.experiences?.consent?.display ?: ContentDisplay.Banner - ) - isCancelable = !disableContentInteractions - } - fragmentManager.get()?.let { - dialog.show(it, webView) - this@Ketch.listener?.onShow() + if (findDialogFragment() != null) { + Log.d(TAG, "Not showing as dialog already exists") + return + } + + val dialog = KetchDialogFragment.newInstance().apply { + val disableContentInteractions = getDisposableContentInteractions( + config?.experiences?.consent?.display ?: ContentDisplay.Banner + ) + isCancelable = !disableContentInteractions + } + fragmentManager.get()?.let { + dialog.show(it, webView) + this@Ketch.listener?.onShow() + } + showConsent = false } - showConsent = false - } - private fun getDisposableContentInteractions(display: ContentDisplay): Boolean = - config?.let { - if (display == ContentDisplay.Modal) { - it.theme?.modal?.container?.backdrop?.disableContentInteractions == true - } else if (display == ContentDisplay.Banner) { - it.theme?.modal?.container?.backdrop?.disableContentInteractions == true - } else false - } ?: false + private fun getDisposableContentInteractions(display: ContentDisplay): Boolean = + config?.let { + if (display == ContentDisplay.Modal) { + it.theme?.modal?.container?.backdrop?.disableContentInteractions == true + } else if (display == ContentDisplay.Banner) { + it.theme?.modal?.container?.backdrop?.disableContentInteractions == true + } else false + } ?: false + } + return webView + } catch (e: Exception) { + Log.e(TAG, "Error creating WebView: ${e.message}", e) + isActive = false + return null } - return webView } private fun findDialogFragment() = @@ -550,4 +591,24 @@ class Ketch private constructor( logLevel ) } + + /** + * Clean up resources when the host app is being destroyed or paused for a long time + * This should be called from onDestroy or when the app knows it won't use the SDK for a while + */ + fun cleanup() { + dismissDialog() + + Handler(android.os.Looper.getMainLooper()).postDelayed({ + try { + Log.d(TAG, "Cleaning up WebView resources") + currentWebView?.destroy() + currentWebView = null + Runtime.getRuntime().gc() + } catch (e: Exception) { + Log.e(TAG, "Error during cleanup: ${e.message}", e) + } + isActive = false + }, 200) + } } diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt index a2b69ccb..78a01647 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt @@ -67,7 +67,7 @@ internal class KetchDialogFragment() : DialogFragment() { wv.setOnTouchListener { _, _ -> true } try { - // Prevent any JavaScript execution by removeing event listeners + // Prevent any JavaScript execution by removing event listeners wv.evaluateJavascript( "document.body.removeEventListener('touchstart', handleTapOutside);" + "document.body.removeEventListener('mousedown', handleTapOutside);", @@ -81,14 +81,11 @@ internal class KetchDialogFragment() : DialogFragment() { // Wait a moment for any pending events to clear Handler(Looper.getMainLooper()).post { try { - // Remove from view hierarchy + // Just remove from view hierarchy + (wv.parent as? ViewGroup)?.removeView(wv) binding.root.removeView(wv) - // Clean up the WebView - wv.destroy() - - // Suggest garbage collection - Runtime.getRuntime().gc() + Log.d(TAG, "onDestroyView: WebView removed from view hierarchy") } catch (e: Exception) { Log.e(TAG, "Error in delayed WebView cleanup: ${e.message}", e) } diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt index e4ee173e..25bcc077 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt @@ -77,7 +77,7 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con } // Properly clean up WebView resources to prevent memory leaks and renderer crashes - fun destroy() { + override fun destroy() { try { Log.d(TAG, "Beginning WebView cleanup") From 8c1ed777859484d67830006d01de43d74d1d7ab4 Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Sun, 2 Mar 2025 19:29:20 +0100 Subject: [PATCH 05/30] seems to be working --- .../src/main/java/com/ketch/android/Ketch.kt | 324 ++++++++++++++++-- .../ketch/android/ui/KetchDialogFragment.kt | 114 +++++- 2 files changed, 404 insertions(+), 34 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 459d0b70..2aed881a 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -3,6 +3,7 @@ package com.ketch.android import android.content.Context import android.os.Handler import android.util.Log +import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -212,15 +213,188 @@ class Ketch private constructor( findDialogFragment()?.let { (it as? KetchDialogFragment)?.dismissAllowingStateLoss() Handler(android.os.Looper.getMainLooper()).postDelayed({ + // Ensure WebView is properly cleaned up + currentWebView?.let { webView -> + Log.d(TAG, "Cleaning up WebView after dialog dismissal") + webView.setOnTouchListener { _, _ -> true } // Disable touch events during cleanup + webView.clearCache(true) + webView.clearHistory() + webView.destroy() + currentWebView = null + } + this@Ketch.listener?.onDismiss(HideExperienceStatus.None) isActive = false }, 100) } ?: run { + // Even if no dialog is found, clean up any lingering WebView resources + currentWebView?.let { webView -> + Log.d(TAG, "Cleaning up orphaned WebView") + webView.destroy() + currentWebView = null + } this@Ketch.listener?.onDismiss(HideExperienceStatus.None) isActive = false } } + /** + * Force cleanup of any existing dialog fragments - use this if dialogs appear to be stuck + */ + fun forceCleanupDialogs() { + // First, set isActive to false to allow new WebView creation + isActive = false + + // Thoroughly clean up WebView + if (currentWebView != null) { + try { + Log.d(TAG, "Force cleaning up WebView") + currentWebView?.setOnTouchListener { _, _ -> true } // Disable touch interactions + // Clear out all content and event handlers + currentWebView?.evaluateJavascript( + """ + (function() { + // Remove all event listeners from document and window + var oldElement = document.documentElement; + var newElement = oldElement.cloneNode(true); + oldElement.parentNode.replaceChild(newElement, oldElement); + + // Empty the body to remove any content + document.body.innerHTML = ''; + + // Disable all pointer events + document.body.style.pointerEvents = 'none'; + + // Remove any overlay elements that might block input + var overlays = document.querySelectorAll('.ketch-backdrop, .ketch-modal, .ketch-banner'); + for (var i = 0; i < overlays.length; i++) { + if (overlays[i].parentNode) { + overlays[i].parentNode.removeChild(overlays[i]); + } + } + })(); + """, + null + ) + + // Force WebView to reset its internal state + currentWebView?.clearCache(true) + currentWebView?.clearHistory() + currentWebView?.clearFocus() // Clear focus to prevent lingering keyboard or focus issues + currentWebView?.reload() // Force refresh the WebView to clear any stuck state + + // Schedule delayed destruction to ensure JS has executed + Handler(android.os.Looper.getMainLooper()).postDelayed({ + currentWebView?.destroy() + currentWebView = null + + // Suggest garbage collection + System.gc() + }, 100) + } catch (e: Exception) { + Log.e(TAG, "Error cleaning up WebView: ${e.message}", e) + // Ensure WebView is nulled out even if error occurs + currentWebView = null + } + } + + findDialogFragment()?.let { fragment -> + try { + if (fragment is KetchDialogFragment) { + fragment.dismissAllowingStateLoss() + } + + // Ensure it's really removed if still hanging around + Handler(android.os.Looper.getMainLooper()).postDelayed({ + fragmentManager.get()?.let { fm -> + try { + // Check any fragment with our tag and remove it + fm.findFragmentByTag(KetchDialogFragment.TAG)?.let { foundFragment -> + if (foundFragment.isAdded) { + Log.d(TAG, "Fragment still exists - force removing it") + fm.beginTransaction() + .remove(foundFragment) + .commitNowAllowingStateLoss() + } + } + } catch (e: Exception) { + Log.e(TAG, "Error force removing fragment: ${e.message}", e) + } + } + }, 200) + } catch (e: Exception) { + Log.e(TAG, "Error in force cleanup: ${e.message}", e) + } + } + } + + /** + * Provides a complete reset of the SDK state. + * Call this method if the UI becomes unresponsive to restore normal operation. + */ + fun resetState() { + Log.d(TAG, "Performing full SDK state reset") + + // First, aggressively clean up dialogs + try { + forceCleanupDialogs() + } catch (e: Exception) { + Log.e(TAG, "Error during force cleanup: ${e.message}", e) + } + + // Make sure isActive flag is reset + isActive = false + + // Clean up WebView + try { + if (currentWebView != null) { + currentWebView?.destroy() + currentWebView = null + } + } catch (e: Exception) { + Log.e(TAG, "Error clearing WebView: ${e.message}", e) + currentWebView = null + } + + // Make one more attempt to cleanup fragments + try { + val fm = fragmentManager.get() + if (fm != null) { + val tag = KetchDialogFragment.TAG + val fragment = fm.findFragmentByTag(tag) + if (fragment != null) { + try { + fm.beginTransaction() + .remove(fragment) + .commitNowAllowingStateLoss() + Log.d(TAG, "Removed lingering fragment during reset") + } catch (e: Exception) { + Log.e(TAG, "Error removing fragment: ${e.message}", e) + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error during fragment cleanup: ${e.message}", e) + } + + // Try to reclaim memory + try { + System.gc() + } catch (e: Exception) { + Log.e(TAG, "Error during GC: ${e.message}", e) + } + + // Schedule status completion message + try { + Handler(android.os.Looper.getMainLooper()).postDelayed( + { Log.d(TAG, "State reset completed") }, + 100 + ) + } catch (e: Exception) { + Log.e(TAG, "Error scheduling completion message: ${e.message}", e) + } + } + /** * Set identities * @@ -258,13 +432,42 @@ class Ketch private constructor( } init { - getPreferences() - findDialogFragment()?.let { dialog -> - (dialog as KetchDialogFragment).dismissAllowingStateLoss() - this@Ketch.listener?.onDismiss(HideExperienceStatus.None) + // Aggressively clean up any existing dialogs that might be leftover + fragmentManager.get()?.let { fm -> + try { + val existingDialog = fm.findFragmentByTag(KetchDialogFragment.TAG) + if (existingDialog != null) { + Log.d(TAG, "Found existing dialog during initialization - removing it") + + if (existingDialog is KetchDialogFragment) { + existingDialog.dismissAllowingStateLoss() + } else { + // Non-KetchDialogFragment, still attempt to dismiss + try { + (existingDialog as? DialogFragment)?.dismissAllowingStateLoss() + } catch (e: Exception) { + Log.e(TAG, "Error dismissing non-KetchDialogFragment: ${e.message}") + } + } + + // Force remove it from the fragment manager + fm.beginTransaction() + .remove(existingDialog) + .commitNowAllowingStateLoss() + + this@Ketch.listener?.onDismiss(HideExperienceStatus.None) + } else { + // No existing dialog found, nothing to clean up + } + } catch (e: Exception) { + Log.e(TAG, "Error cleaning up existing dialogs: ${e.message}", e) + } } + + // Ensure the isActive flag is reset to a clean state + isActive = false } // Get the singleton KetchSharedPreferences object @@ -282,24 +485,38 @@ class Ketch private constructor( return null } - if (findDialogFragment() != null) { + val existingDialog = findDialogFragment() + if (existingDialog != null) { Log.d(TAG, "WebView creation blocked - dialog already exists") + + // Extra safety: if we detected a dialog but isActive is false, + // force dismiss the dialog as it may be stuck + if (!isActive) { + Log.d(TAG, "Detected orphaned dialog - force cleaning up") + (existingDialog as? KetchDialogFragment)?.dismissAllowingStateLoss() + Handler(android.os.Looper.getMainLooper()).postDelayed({ + forceCleanupDialogs() + }, 100) + } + return null } isActive = true try { + // Always create a new WebView instance for better reliability + // This prevents potential issues with reuse of WebView instances if (currentWebView != null) { - val contextRef = context.get() - if (contextRef != null && contextRef is LifecycleOwner && - contextRef.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) { - Log.d(TAG, "Reusing current WebView instance") - return currentWebView - } else { - Log.d(TAG, "Context invalid, cleaning up WebView") - currentWebView?.destroy() - currentWebView = null + Log.d(TAG, "Cleaning up previous WebView instance") + currentWebView?.destroy() + currentWebView = null + + // Short delay to ensure cleanup is complete + try { + Thread.sleep(50) + } catch (e: InterruptedException) { + Log.e(TAG, "Sleep interrupted during WebView cleanup", e) } } @@ -330,19 +547,41 @@ class Ketch private constructor( override fun showPreferences() { if (!isActivityActive()) { Log.d(TAG, "Not showing as activity is not active") + isActive = false return } - if (findDialogFragment() != null) { + val existingDialog = findDialogFragment() + if (existingDialog != null) { Log.d(TAG, "Not showing as dialog already exists") + + // Extra safety: ensure the dialog state is consistent + if (!isActive) { + Log.d(TAG, "Dialog exists but isActive=false - fixing state") + isActive = true + } + return } val dialog = KetchDialogFragment.newInstance() fragmentManager.get()?.let { + // Make sure any existing dialogs with the same tag are removed first + val existingFragment = it.findFragmentByTag(KetchDialogFragment.TAG) + if (existingFragment != null) { + try { + it.beginTransaction().remove(existingFragment).commitNow() + Log.d(TAG, "Removed existing fragment before showing new one") + } catch (e: Exception) { + Log.e(TAG, "Error removing existing fragment: ${e.message}", e) + } + } + dialog.show(it, webView) this@Ketch.listener?.onShow() + } ?: run { + isActive = false } } @@ -438,11 +677,20 @@ class Ketch private constructor( private fun showConsentPopup() { if (!isActivityActive()) { Log.d(TAG, "Not showing as activity is not active") + isActive = false return } - if (findDialogFragment() != null) { + val existingDialog = findDialogFragment() + if (existingDialog != null) { Log.d(TAG, "Not showing as dialog already exists") + + // Extra safety: ensure the dialog state is consistent + if (!isActive) { + Log.d(TAG, "Dialog exists but isActive=false - fixing state") + isActive = true + } + return } @@ -452,21 +700,43 @@ class Ketch private constructor( ) isCancelable = !disableContentInteractions } + fragmentManager.get()?.let { + // Make sure any existing dialogs with the same tag are removed first + val existingFragment = it.findFragmentByTag(KetchDialogFragment.TAG) + if (existingFragment != null) { + try { + it.beginTransaction().remove(existingFragment).commitNow() + Log.d(TAG, "Removed existing fragment before showing new one") + } catch (e: Exception) { + Log.e(TAG, "Error removing existing fragment: ${e.message}", e) + } + } + dialog.show(it, webView) this@Ketch.listener?.onShow() + } ?: run { + isActive = false } + showConsent = false } - private fun getDisposableContentInteractions(display: ContentDisplay): Boolean = - config?.let { - if (display == ContentDisplay.Modal) { - it.theme?.modal?.container?.backdrop?.disableContentInteractions == true - } else if (display == ContentDisplay.Banner) { - it.theme?.modal?.container?.backdrop?.disableContentInteractions == true - } else false + private fun getDisposableContentInteractions(display: ContentDisplay): Boolean { + return config?.let { + when (display) { + ContentDisplay.Modal -> { + it.theme?.modal?.container?.backdrop?.disableContentInteractions == true + } + ContentDisplay.Banner -> { + it.theme?.modal?.container?.backdrop?.disableContentInteractions == true + } + else -> { + false + } + } } ?: false + } } return webView } catch (e: Exception) { @@ -476,12 +746,12 @@ class Ketch private constructor( } } - private fun findDialogFragment() = - fragmentManager.get()?.findFragmentByTag(KetchDialogFragment.TAG) + private fun findDialogFragment(): androidx.fragment.app.Fragment? { + return fragmentManager.get()?.findFragmentByTag(KetchDialogFragment.TAG) + } private fun isActivityActive(): Boolean { - return (context.get() as? LifecycleOwner)?.lifecycle?.currentState?.isAtLeast(Lifecycle.State.STARTED) - ?: false + return (context.get() as? LifecycleOwner)?.lifecycle?.currentState?.isAtLeast(Lifecycle.State.STARTED) ?: false } enum class PreferencesTab { diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt index 78a01647..179e13d5 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt @@ -26,6 +26,9 @@ internal class KetchDialogFragment() : DialogFragment() { private var webView: KetchWebView? = null + // Add a flag to track if we're in the process of cleaning up + private var isCleaningUp = false + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -34,12 +37,24 @@ internal class KetchDialogFragment() : DialogFragment() { binding = KetchDialogLayoutBinding.bind(inflater.inflate(R.layout.ketch_dialog_layout, container)) webView?.let { web -> + // Make sure the WebView is detached from any previous parent (web.parent as? ViewGroup)?.removeView(web) + + // Reset WebView to a clean state + web.clearFocus() + web.setOnTouchListener(null) // Remove any touch blockers + + // Add the WebView to our layout binding.root.addView( web, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT ) + + // Ensure WebView is interactive + web.isClickable = true + web.isFocusable = true + web.isFocusableInTouchMode = true } return binding.root } @@ -53,6 +68,8 @@ internal class KetchDialogFragment() : DialogFragment() { override fun onDestroyView() { try { + isCleaningUp = true + Log.d(TAG, "onDestroyView: Beginning WebView cleanup") // Get a local reference to the WebView before nulling it out @@ -67,10 +84,19 @@ internal class KetchDialogFragment() : DialogFragment() { wv.setOnTouchListener { _, _ -> true } try { - // Prevent any JavaScript execution by removing event listeners + // Execute JavaScript to clean up event listeners wv.evaluateJavascript( - "document.body.removeEventListener('touchstart', handleTapOutside);" + - "document.body.removeEventListener('mousedown', handleTapOutside);", + """ + (function() { + // Remove all event listeners from document and window + var oldElement = document.documentElement; + var newElement = oldElement.cloneNode(true); + oldElement.parentNode.replaceChild(newElement, oldElement); + + // Disable interaction with any content + document.body.style.pointerEvents = 'none'; + })(); + """, null ) } catch (e: Exception) { @@ -78,14 +104,28 @@ internal class KetchDialogFragment() : DialogFragment() { Log.e(TAG, "Error disabling JS events: ${e.message}") } - // Wait a moment for any pending events to clear + // Immediately remove from view hierarchy + try { + (wv.parent as? ViewGroup)?.removeView(wv) + binding.root.removeView(wv) + Log.d(TAG, "onDestroyView: WebView immediately removed from view hierarchy") + } catch (e: Exception) { + Log.e(TAG, "Error removing WebView from view hierarchy: ${e.message}", e) + } + + // Additional delayed cleanup to ensure proper UI thread operations Handler(Looper.getMainLooper()).post { try { - // Just remove from view hierarchy + // Force another removal attempt in case the immediate one failed (wv.parent as? ViewGroup)?.removeView(wv) binding.root.removeView(wv) - Log.d(TAG, "onDestroyView: WebView removed from view hierarchy") + // Explicitly invalidate the WebView + wv.clearHistory() + wv.clearFormData() + wv.clearCache(true) + + Log.d(TAG, "onDestroyView: WebView additional cleanup completed") } catch (e: Exception) { Log.e(TAG, "Error in delayed WebView cleanup: ${e.message}", e) } @@ -103,10 +143,12 @@ internal class KetchDialogFragment() : DialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) dialog?.window?.also { window -> + // Keep background transparent to allow interaction with WebView content window.clearFlags(FLAG_DIM_BEHIND) window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + + // Ensure dialog takes full screen val displayMetrics = requireActivity().resources.displayMetrics - val width = displayMetrics.widthPixels val height = displayMetrics.heightPixels @@ -117,7 +159,12 @@ internal class KetchDialogFragment() : DialogFragment() { } window.attributes = params + + // Add window animations for smoother transitions + window.setWindowAnimations(android.R.style.Animation_Dialog) } + + // Don't set background on root view so content remains fully interactive } override fun onConfigurationChanged(newConfig: Configuration) { @@ -139,10 +186,63 @@ internal class KetchDialogFragment() : DialogFragment() { } fun show(manager: FragmentManager, webView: KetchWebView) { + // Reset the cleanup flag + isCleaningUp = false + + // First check if there are any existing fragments with our tag and remove them + try { + val existingFragment = manager.findFragmentByTag(TAG) + if (existingFragment != null) { + Log.d(TAG, "Found existing fragment with same tag - removing it first") + manager.beginTransaction() + .remove(existingFragment) + .commitNowAllowingStateLoss() + } + } catch (e: Exception) { + Log.e(TAG, "Error cleaning up before show: ${e.message}", e) + } + + // Store the WebView reference this.webView = webView + + // Ensure WebView is in a good state for display + webView.clearFocus() + webView.setOnTouchListener(null) // Remove any touch blockers + webView.isClickable = true + webView.isFocusable = true + + // Now show the dialog super.show(manager, TAG) } + override fun dismiss() { + try { + // Set cleanup flag first + isCleaningUp = true + + // Detach the WebView from touch events before dismissing + webView?.setOnTouchListener { _, _ -> true } + } catch (e: Exception) { + Log.e(TAG, "Error in dismiss pre-cleanup: ${e.message}", e) + } + + super.dismiss() + } + + override fun dismissAllowingStateLoss() { + try { + // Set cleanup flag first + isCleaningUp = true + + // Detach the WebView from touch events before dismissing + webView?.setOnTouchListener { _, _ -> true } + } catch (e: Exception) { + Log.e(TAG, "Error in dismissAllowingStateLoss pre-cleanup: ${e.message}", e) + } + + super.dismissAllowingStateLoss() + } + companion object { internal val TAG = KetchDialogFragment::class.java.simpleName From 7743894b8add225ef3365ca9e90e57a08ac4720b Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Sun, 2 Mar 2025 19:36:26 +0100 Subject: [PATCH 06/30] cleanup of troubleshooting code --- .../src/main/java/com/ketch/android/Ketch.kt | 106 +++--------------- .../ketch/android/ui/KetchDialogFragment.kt | 102 +++-------------- 2 files changed, 31 insertions(+), 177 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 2aed881a..406b6dc6 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -213,26 +213,16 @@ class Ketch private constructor( findDialogFragment()?.let { (it as? KetchDialogFragment)?.dismissAllowingStateLoss() Handler(android.os.Looper.getMainLooper()).postDelayed({ - // Ensure WebView is properly cleaned up - currentWebView?.let { webView -> - Log.d(TAG, "Cleaning up WebView after dialog dismissal") - webView.setOnTouchListener { _, _ -> true } // Disable touch events during cleanup - webView.clearCache(true) - webView.clearHistory() - webView.destroy() - currentWebView = null - } + // Clean up WebView resources + currentWebView?.destroy() + currentWebView = null this@Ketch.listener?.onDismiss(HideExperienceStatus.None) isActive = false }, 100) } ?: run { - // Even if no dialog is found, clean up any lingering WebView resources - currentWebView?.let { webView -> - Log.d(TAG, "Cleaning up orphaned WebView") - webView.destroy() - currentWebView = null - } + currentWebView?.destroy() + currentWebView = null this@Ketch.listener?.onDismiss(HideExperienceStatus.None) isActive = false } @@ -242,83 +232,31 @@ class Ketch private constructor( * Force cleanup of any existing dialog fragments - use this if dialogs appear to be stuck */ fun forceCleanupDialogs() { - // First, set isActive to false to allow new WebView creation + // Reset state flag isActive = false - // Thoroughly clean up WebView - if (currentWebView != null) { - try { - Log.d(TAG, "Force cleaning up WebView") - currentWebView?.setOnTouchListener { _, _ -> true } // Disable touch interactions - // Clear out all content and event handlers - currentWebView?.evaluateJavascript( - """ - (function() { - // Remove all event listeners from document and window - var oldElement = document.documentElement; - var newElement = oldElement.cloneNode(true); - oldElement.parentNode.replaceChild(newElement, oldElement); - - // Empty the body to remove any content - document.body.innerHTML = ''; - - // Disable all pointer events - document.body.style.pointerEvents = 'none'; - - // Remove any overlay elements that might block input - var overlays = document.querySelectorAll('.ketch-backdrop, .ketch-modal, .ketch-banner'); - for (var i = 0; i < overlays.length; i++) { - if (overlays[i].parentNode) { - overlays[i].parentNode.removeChild(overlays[i]); - } - } - })(); - """, - null - ) - - // Force WebView to reset its internal state - currentWebView?.clearCache(true) - currentWebView?.clearHistory() - currentWebView?.clearFocus() // Clear focus to prevent lingering keyboard or focus issues - currentWebView?.reload() // Force refresh the WebView to clear any stuck state - - // Schedule delayed destruction to ensure JS has executed - Handler(android.os.Looper.getMainLooper()).postDelayed({ - currentWebView?.destroy() - currentWebView = null - - // Suggest garbage collection - System.gc() - }, 100) - } catch (e: Exception) { - Log.e(TAG, "Error cleaning up WebView: ${e.message}", e) - // Ensure WebView is nulled out even if error occurs - currentWebView = null - } - } + // Clean up WebView + currentWebView?.destroy() + currentWebView = null + // Remove any lingering dialog fragments findDialogFragment()?.let { fragment -> try { if (fragment is KetchDialogFragment) { fragment.dismissAllowingStateLoss() } - // Ensure it's really removed if still hanging around + // Ensure it's really removed Handler(android.os.Looper.getMainLooper()).postDelayed({ fragmentManager.get()?.let { fm -> try { - // Check any fragment with our tag and remove it - fm.findFragmentByTag(KetchDialogFragment.TAG)?.let { foundFragment -> - if (foundFragment.isAdded) { - Log.d(TAG, "Fragment still exists - force removing it") - fm.beginTransaction() - .remove(foundFragment) - .commitNowAllowingStateLoss() + fm.findFragmentByTag(KetchDialogFragment.TAG)?.let { f -> + if (f.isAdded) { + fm.beginTransaction().remove(f).commitNowAllowingStateLoss() } } } catch (e: Exception) { - Log.e(TAG, "Error force removing fragment: ${e.message}", e) + Log.e(TAG, "Error removing fragment: ${e.message}", e) } } }, 200) @@ -494,9 +432,7 @@ class Ketch private constructor( if (!isActive) { Log.d(TAG, "Detected orphaned dialog - force cleaning up") (existingDialog as? KetchDialogFragment)?.dismissAllowingStateLoss() - Handler(android.os.Looper.getMainLooper()).postDelayed({ - forceCleanupDialogs() - }, 100) + forceCleanupDialogs() } return null @@ -505,19 +441,11 @@ class Ketch private constructor( isActive = true try { - // Always create a new WebView instance for better reliability - // This prevents potential issues with reuse of WebView instances + // Always create a new WebView instance to avoid stale state if (currentWebView != null) { Log.d(TAG, "Cleaning up previous WebView instance") currentWebView?.destroy() currentWebView = null - - // Short delay to ensure cleanup is complete - try { - Thread.sleep(50) - } catch (e: InterruptedException) { - Log.e(TAG, "Sleep interrupted during WebView cleanup", e) - } } val webView = context.get()?.let { KetchWebView(it, shouldRetry) } ?: run { diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt index 179e13d5..318fcce9 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt @@ -26,9 +26,6 @@ internal class KetchDialogFragment() : DialogFragment() { private var webView: KetchWebView? = null - // Add a flag to track if we're in the process of cleaning up - private var isCleaningUp = false - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -37,14 +34,10 @@ internal class KetchDialogFragment() : DialogFragment() { binding = KetchDialogLayoutBinding.bind(inflater.inflate(R.layout.ketch_dialog_layout, container)) webView?.let { web -> - // Make sure the WebView is detached from any previous parent + // Remove from any previous parent (web.parent as? ViewGroup)?.removeView(web) - // Reset WebView to a clean state - web.clearFocus() - web.setOnTouchListener(null) // Remove any touch blockers - - // Add the WebView to our layout + // Add to our layout binding.root.addView( web, FrameLayout.LayoutParams.MATCH_PARENT, @@ -68,71 +61,23 @@ internal class KetchDialogFragment() : DialogFragment() { override fun onDestroyView() { try { - isCleaningUp = true - Log.d(TAG, "onDestroyView: Beginning WebView cleanup") - // Get a local reference to the WebView before nulling it out val webViewToCleanup = webView - - // Set the class reference to null first to prevent any new operations from being triggered webView = null - // Perform cleanup on the local reference if it exists webViewToCleanup?.let { wv -> - // Prevent any new touch events or interactions + // Disable interaction during cleanup wv.setOnTouchListener { _, _ -> true } - try { - // Execute JavaScript to clean up event listeners - wv.evaluateJavascript( - """ - (function() { - // Remove all event listeners from document and window - var oldElement = document.documentElement; - var newElement = oldElement.cloneNode(true); - oldElement.parentNode.replaceChild(newElement, oldElement); - - // Disable interaction with any content - document.body.style.pointerEvents = 'none'; - })(); - """, - null - ) - } catch (e: Exception) { - // Ignore JavaScript errors during cleanup - Log.e(TAG, "Error disabling JS events: ${e.message}") - } - - // Immediately remove from view hierarchy - try { - (wv.parent as? ViewGroup)?.removeView(wv) - binding.root.removeView(wv) - Log.d(TAG, "onDestroyView: WebView immediately removed from view hierarchy") - } catch (e: Exception) { - Log.e(TAG, "Error removing WebView from view hierarchy: ${e.message}", e) - } + // Remove from view hierarchy + (wv.parent as? ViewGroup)?.removeView(wv) + binding.root.removeView(wv) - // Additional delayed cleanup to ensure proper UI thread operations - Handler(Looper.getMainLooper()).post { - try { - // Force another removal attempt in case the immediate one failed - (wv.parent as? ViewGroup)?.removeView(wv) - binding.root.removeView(wv) - - // Explicitly invalidate the WebView - wv.clearHistory() - wv.clearFormData() - wv.clearCache(true) - - Log.d(TAG, "onDestroyView: WebView additional cleanup completed") - } catch (e: Exception) { - Log.e(TAG, "Error in delayed WebView cleanup: ${e.message}", e) - } - } + // Let standard cleanup handle the rest + // We don't need to manually destroy the WebView here as it will be + // handled by the Ketch class } - - Log.d(TAG, "onDestroyView: WebView cleanup initiated") } catch (e: Exception) { Log.e(TAG, "Error cleaning up WebView: ${e.message}", e) } @@ -186,14 +131,10 @@ internal class KetchDialogFragment() : DialogFragment() { } fun show(manager: FragmentManager, webView: KetchWebView) { - // Reset the cleanup flag - isCleaningUp = false - - // First check if there are any existing fragments with our tag and remove them + // Check for existing fragments try { val existingFragment = manager.findFragmentByTag(TAG) if (existingFragment != null) { - Log.d(TAG, "Found existing fragment with same tag - removing it first") manager.beginTransaction() .remove(existingFragment) .commitNowAllowingStateLoss() @@ -202,28 +143,16 @@ internal class KetchDialogFragment() : DialogFragment() { Log.e(TAG, "Error cleaning up before show: ${e.message}", e) } - // Store the WebView reference this.webView = webView - - // Ensure WebView is in a good state for display - webView.clearFocus() - webView.setOnTouchListener(null) // Remove any touch blockers - webView.isClickable = true - webView.isFocusable = true - - // Now show the dialog super.show(manager, TAG) } override fun dismiss() { try { - // Set cleanup flag first - isCleaningUp = true - - // Detach the WebView from touch events before dismissing + // Detach WebView from touch events during dismissal webView?.setOnTouchListener { _, _ -> true } } catch (e: Exception) { - Log.e(TAG, "Error in dismiss pre-cleanup: ${e.message}", e) + Log.e(TAG, "Error in dismiss: ${e.message}", e) } super.dismiss() @@ -231,13 +160,10 @@ internal class KetchDialogFragment() : DialogFragment() { override fun dismissAllowingStateLoss() { try { - // Set cleanup flag first - isCleaningUp = true - - // Detach the WebView from touch events before dismissing + // Detach WebView from touch events during dismissal webView?.setOnTouchListener { _, _ -> true } } catch (e: Exception) { - Log.e(TAG, "Error in dismissAllowingStateLoss pre-cleanup: ${e.message}", e) + Log.e(TAG, "Error in dismissAllowingStateLoss: ${e.message}", e) } super.dismissAllowingStateLoss() From 5ed37414c8cee4f2daf7c9da1832dc80070645c3 Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Mon, 3 Mar 2025 00:38:30 +0100 Subject: [PATCH 07/30] updates --- README.md | 2 - .../src/main/java/com/ketch/android/Ketch.kt | 252 ++++++++---------- 2 files changed, 105 insertions(+), 149 deletions(-) diff --git a/README.md b/README.md index 3113545f..5113f5b8 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,6 @@ Feel free to skip the listeners you don't really need. import com.ketch.android.KetchSdk import com.ketch.android.Consent // ... - - // Create a listener to handle Ketch SDK events private val listener = object : Ketch.Listener { override fun onShow() { // Called when a consent or preferences dialog is displayed diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 406b6dc6..fe7a4aef 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -210,21 +210,8 @@ class Ketch private constructor( * Dismiss the dialog */ fun dismissDialog() { - findDialogFragment()?.let { - (it as? KetchDialogFragment)?.dismissAllowingStateLoss() - Handler(android.os.Looper.getMainLooper()).postDelayed({ - // Clean up WebView resources - currentWebView?.destroy() - currentWebView = null - - this@Ketch.listener?.onDismiss(HideExperienceStatus.None) - isActive = false - }, 100) - } ?: run { - currentWebView?.destroy() - currentWebView = null - this@Ketch.listener?.onDismiss(HideExperienceStatus.None) - isActive = false + cleanupDialogFragment { status -> + this@Ketch.listener?.onDismiss(status ?: HideExperienceStatus.None) } } @@ -235,35 +222,9 @@ class Ketch private constructor( // Reset state flag isActive = false - // Clean up WebView - currentWebView?.destroy() - currentWebView = null - - // Remove any lingering dialog fragments - findDialogFragment()?.let { fragment -> - try { - if (fragment is KetchDialogFragment) { - fragment.dismissAllowingStateLoss() - } - - // Ensure it's really removed - Handler(android.os.Looper.getMainLooper()).postDelayed({ - fragmentManager.get()?.let { fm -> - try { - fm.findFragmentByTag(KetchDialogFragment.TAG)?.let { f -> - if (f.isAdded) { - fm.beginTransaction().remove(f).commitNowAllowingStateLoss() - } - } - } catch (e: Exception) { - Log.e(TAG, "Error removing fragment: ${e.message}", e) - } - } - }, 200) - } catch (e: Exception) { - Log.e(TAG, "Error in force cleanup: ${e.message}", e) - } - } + // Clean up WebView and fragments + cleanupWebView() + cleanupDialogFragment(forceRemove = true) } /** @@ -273,64 +234,25 @@ class Ketch private constructor( fun resetState() { Log.d(TAG, "Performing full SDK state reset") - // First, aggressively clean up dialogs - try { - forceCleanupDialogs() - } catch (e: Exception) { - Log.e(TAG, "Error during force cleanup: ${e.message}", e) - } - - // Make sure isActive flag is reset - isActive = false - - // Clean up WebView - try { - if (currentWebView != null) { - currentWebView?.destroy() - currentWebView = null - } - } catch (e: Exception) { - Log.e(TAG, "Error clearing WebView: ${e.message}", e) - currentWebView = null - } - - // Make one more attempt to cleanup fragments - try { - val fm = fragmentManager.get() - if (fm != null) { - val tag = KetchDialogFragment.TAG - val fragment = fm.findFragmentByTag(tag) - if (fragment != null) { - try { - fm.beginTransaction() - .remove(fragment) - .commitNowAllowingStateLoss() - Log.d(TAG, "Removed lingering fragment during reset") - } catch (e: Exception) { - Log.e(TAG, "Error removing fragment: ${e.message}", e) - } - } - } - } catch (e: Exception) { - Log.e(TAG, "Error during fragment cleanup: ${e.message}", e) - } - - // Try to reclaim memory + // Aggressively clean up dialogs and WebView try { + cleanupDialogFragment(forceRemove = true) + cleanupWebView() + + // Reset active state + isActive = false + + // Try to reclaim memory System.gc() } catch (e: Exception) { - Log.e(TAG, "Error during GC: ${e.message}", e) + Log.e(TAG, "Error during state reset: ${e.message}", e) } // Schedule status completion message - try { - Handler(android.os.Looper.getMainLooper()).postDelayed( - { Log.d(TAG, "State reset completed") }, - 100 - ) - } catch (e: Exception) { - Log.e(TAG, "Error scheduling completion message: ${e.message}", e) - } + Handler(android.os.Looper.getMainLooper()).postDelayed( + { Log.d(TAG, "State reset completed") }, + 100 + ) } /** @@ -378,26 +300,12 @@ class Ketch private constructor( val existingDialog = fm.findFragmentByTag(KetchDialogFragment.TAG) if (existingDialog != null) { Log.d(TAG, "Found existing dialog during initialization - removing it") - - if (existingDialog is KetchDialogFragment) { - existingDialog.dismissAllowingStateLoss() - } else { - // Non-KetchDialogFragment, still attempt to dismiss - try { - (existingDialog as? DialogFragment)?.dismissAllowingStateLoss() - } catch (e: Exception) { - Log.e(TAG, "Error dismissing non-KetchDialogFragment: ${e.message}") - } + cleanupDialogFragment(forceRemove = true) { _ -> + this@Ketch.listener?.onDismiss(HideExperienceStatus.None) } - - // Force remove it from the fragment manager - fm.beginTransaction() - .remove(existingDialog) - .commitNowAllowingStateLoss() - - this@Ketch.listener?.onDismiss(HideExperienceStatus.None) } else { - // No existing dialog found, nothing to clean up + // No existing dialog found + Log.d(TAG, "No existing dialog found during initialization") } } catch (e: Exception) { Log.e(TAG, "Error cleaning up existing dialogs: ${e.message}", e) @@ -431,8 +339,7 @@ class Ketch private constructor( // force dismiss the dialog as it may be stuck if (!isActive) { Log.d(TAG, "Detected orphaned dialog - force cleaning up") - (existingDialog as? KetchDialogFragment)?.dismissAllowingStateLoss() - forceCleanupDialogs() + cleanupDialogFragment(forceRemove = true) } return null @@ -442,11 +349,7 @@ class Ketch private constructor( try { // Always create a new WebView instance to avoid stale state - if (currentWebView != null) { - Log.d(TAG, "Cleaning up previous WebView instance") - currentWebView?.destroy() - currentWebView = null - } + cleanupWebView() val webView = context.get()?.let { KetchWebView(it, shouldRetry) } ?: run { isActive = false @@ -576,22 +479,7 @@ class Ketch private constructor( override fun onClose(status: HideExperienceStatus) { Log.d(TAG, "onClose called with status: ${status.name}") - try { - findDialogFragment()?.let { fragment -> - if (fragment.isAdded && !fragment.isRemoving) { - (fragment as? KetchDialogFragment)?.dismissAllowingStateLoss() - Handler(android.os.Looper.getMainLooper()).postDelayed({ - this@Ketch.listener?.onDismiss(status) - isActive = false - }, 100) - return@onClose - } - } - - this@Ketch.listener?.onDismiss(status) - isActive = false - } catch (e: Exception) { - Log.e(TAG, "Error dismissing dialog: ${e.message}", e) + cleanupDialogFragment { _ -> this@Ketch.listener?.onDismiss(status) isActive = false } @@ -795,18 +683,88 @@ class Ketch private constructor( * This should be called from onDestroy or when the app knows it won't use the SDK for a while */ fun cleanup() { - dismissDialog() - - Handler(android.os.Looper.getMainLooper()).postDelayed({ - try { - Log.d(TAG, "Cleaning up WebView resources") + // First dismiss the dialog + cleanupDialogFragment { _ -> + // Then clean up WebView resources with a short delay + Handler(android.os.Looper.getMainLooper()).postDelayed({ + try { + Log.d(TAG, "Cleaning up WebView resources") + cleanupWebView() + Runtime.getRuntime().gc() + } catch (e: Exception) { + Log.e(TAG, "Error during cleanup: ${e.message}", e) + } + isActive = false + }, 200) + } + } + + /** + * Centralized helper to clean up WebView resources + */ + private fun cleanupWebView() { + try { + if (currentWebView != null) { + Log.d(TAG, "Cleaning up WebView instance") currentWebView?.destroy() currentWebView = null - Runtime.getRuntime().gc() - } catch (e: Exception) { - Log.e(TAG, "Error during cleanup: ${e.message}", e) } - isActive = false - }, 200) + } catch (e: Exception) { + Log.e(TAG, "Error during WebView cleanup: ${e.message}", e) + currentWebView = null + } + } + + /** + * Centralized helper to clean up dialog fragments + * + * @param forceRemove Whether to forcefully remove the fragment from the FragmentManager + * @param onComplete Optional callback to execute after cleanup + */ + private fun cleanupDialogFragment(forceRemove: Boolean = false, onComplete: ((HideExperienceStatus?) -> Unit) = {}) { + try { + val fragment = findDialogFragment() + if (fragment != null) { + // Try to dismiss the dialog first + if (fragment is KetchDialogFragment) { + fragment.dismissAllowingStateLoss() + } else { + // Fallback for non-KetchDialogFragment + try { + (fragment as? DialogFragment)?.dismissAllowingStateLoss() + } catch (e: Exception) { + Log.e(TAG, "Error dismissing non-KetchDialogFragment: ${e.message}") + } + } + + // If requested, forcefully remove the fragment + if (forceRemove) { + fragmentManager.get()?.let { fm -> + try { + fm.beginTransaction() + .remove(fragment) + .commitNowAllowingStateLoss() + Log.d(TAG, "Forcefully removed fragment") + } catch (e: Exception) { + Log.e(TAG, "Error forcefully removing fragment: ${e.message}", e) + } + } + + // Execute completion callback immediately for force removals + onComplete.invoke(null) + } else { + // For normal dismissals, wait a bit for the animation + Handler(android.os.Looper.getMainLooper()).postDelayed({ + onComplete.invoke(null) + }, 100) + } + } else { + // No fragment found, execute completion callback immediately + onComplete.invoke(null) + } + } catch (e: Exception) { + Log.e(TAG, "Error during fragment cleanup: ${e.message}", e) + onComplete.invoke(null) + } } } From 6cc111610db2fe8fa93fe2460b0578f6decc5de3 Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Mon, 3 Mar 2025 02:51:09 +0100 Subject: [PATCH 08/30] logs cleanup --- .../src/main/java/com/ketch/android/Ketch.kt | 58 +------------------ 1 file changed, 1 insertion(+), 57 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index fe7a4aef..1cb3e18e 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -219,10 +219,7 @@ class Ketch private constructor( * Force cleanup of any existing dialog fragments - use this if dialogs appear to be stuck */ fun forceCleanupDialogs() { - // Reset state flag isActive = false - - // Clean up WebView and fragments cleanupWebView() cleanupDialogFragment(forceRemove = true) } @@ -234,25 +231,14 @@ class Ketch private constructor( fun resetState() { Log.d(TAG, "Performing full SDK state reset") - // Aggressively clean up dialogs and WebView try { cleanupDialogFragment(forceRemove = true) cleanupWebView() - - // Reset active state isActive = false - - // Try to reclaim memory System.gc() } catch (e: Exception) { Log.e(TAG, "Error during state reset: ${e.message}", e) } - - // Schedule status completion message - Handler(android.os.Looper.getMainLooper()).postDelayed( - { Log.d(TAG, "State reset completed") }, - 100 - ) } /** @@ -294,25 +280,21 @@ class Ketch private constructor( init { getPreferences() - // Aggressively clean up any existing dialogs that might be leftover fragmentManager.get()?.let { fm -> try { val existingDialog = fm.findFragmentByTag(KetchDialogFragment.TAG) if (existingDialog != null) { - Log.d(TAG, "Found existing dialog during initialization - removing it") cleanupDialogFragment(forceRemove = true) { _ -> this@Ketch.listener?.onDismiss(HideExperienceStatus.None) } } else { - // No existing dialog found - Log.d(TAG, "No existing dialog found during initialization") + // No action needed when no dialog exists } } catch (e: Exception) { Log.e(TAG, "Error cleaning up existing dialogs: ${e.message}", e) } } - // Ensure the isActive flag is reset to a clean state isActive = false } @@ -327,18 +309,12 @@ class Ketch private constructor( private fun createWebView(shouldRetry: Boolean = false, synchronousPreferences: Boolean = false): KetchWebView? { if (isActive) { - Log.d(TAG, "WebView creation blocked - dialog operation in progress") return null } val existingDialog = findDialogFragment() if (existingDialog != null) { - Log.d(TAG, "WebView creation blocked - dialog already exists") - - // Extra safety: if we detected a dialog but isActive is false, - // force dismiss the dialog as it may be stuck if (!isActive) { - Log.d(TAG, "Detected orphaned dialog - force cleaning up") cleanupDialogFragment(forceRemove = true) } @@ -348,7 +324,6 @@ class Ketch private constructor( isActive = true try { - // Always create a new WebView instance to avoid stale state cleanupWebView() val webView = context.get()?.let { KetchWebView(it, shouldRetry) } ?: run { @@ -377,18 +352,13 @@ class Ketch private constructor( override fun showPreferences() { if (!isActivityActive()) { - Log.d(TAG, "Not showing as activity is not active") isActive = false return } val existingDialog = findDialogFragment() if (existingDialog != null) { - Log.d(TAG, "Not showing as dialog already exists") - - // Extra safety: ensure the dialog state is consistent if (!isActive) { - Log.d(TAG, "Dialog exists but isActive=false - fixing state") isActive = true } @@ -398,12 +368,10 @@ class Ketch private constructor( val dialog = KetchDialogFragment.newInstance() fragmentManager.get()?.let { - // Make sure any existing dialogs with the same tag are removed first val existingFragment = it.findFragmentByTag(KetchDialogFragment.TAG) if (existingFragment != null) { try { it.beginTransaction().remove(existingFragment).commitNow() - Log.d(TAG, "Removed existing fragment before showing new one") } catch (e: Exception) { Log.e(TAG, "Error removing existing fragment: ${e.message}", e) } @@ -432,10 +400,7 @@ class Ketch private constructor( } override fun onConfigUpdated(config: KetchConfig?) { - // Set internal config field this.config = config - - // Call config update listener this@Ketch.listener?.onConfigUpdated(config) if (!showConsent) { @@ -477,8 +442,6 @@ class Ketch private constructor( } override fun onClose(status: HideExperienceStatus) { - Log.d(TAG, "onClose called with status: ${status.name}") - cleanupDialogFragment { _ -> this@Ketch.listener?.onDismiss(status) isActive = false @@ -486,24 +449,18 @@ class Ketch private constructor( } override fun onWillShowExperience(experienceType: WillShowExperienceType) { - // Execute onWillShowExperience listener this@Ketch.listener?.onWillShowExperience(experienceType) } private fun showConsentPopup() { if (!isActivityActive()) { - Log.d(TAG, "Not showing as activity is not active") isActive = false return } val existingDialog = findDialogFragment() if (existingDialog != null) { - Log.d(TAG, "Not showing as dialog already exists") - - // Extra safety: ensure the dialog state is consistent if (!isActive) { - Log.d(TAG, "Dialog exists but isActive=false - fixing state") isActive = true } @@ -518,12 +475,10 @@ class Ketch private constructor( } fragmentManager.get()?.let { - // Make sure any existing dialogs with the same tag are removed first val existingFragment = it.findFragmentByTag(KetchDialogFragment.TAG) if (existingFragment != null) { try { it.beginTransaction().remove(existingFragment).commitNow() - Log.d(TAG, "Removed existing fragment before showing new one") } catch (e: Exception) { Log.e(TAG, "Error removing existing fragment: ${e.message}", e) } @@ -683,12 +638,9 @@ class Ketch private constructor( * This should be called from onDestroy or when the app knows it won't use the SDK for a while */ fun cleanup() { - // First dismiss the dialog cleanupDialogFragment { _ -> - // Then clean up WebView resources with a short delay Handler(android.os.Looper.getMainLooper()).postDelayed({ try { - Log.d(TAG, "Cleaning up WebView resources") cleanupWebView() Runtime.getRuntime().gc() } catch (e: Exception) { @@ -705,7 +657,6 @@ class Ketch private constructor( private fun cleanupWebView() { try { if (currentWebView != null) { - Log.d(TAG, "Cleaning up WebView instance") currentWebView?.destroy() currentWebView = null } @@ -725,11 +676,9 @@ class Ketch private constructor( try { val fragment = findDialogFragment() if (fragment != null) { - // Try to dismiss the dialog first if (fragment is KetchDialogFragment) { fragment.dismissAllowingStateLoss() } else { - // Fallback for non-KetchDialogFragment try { (fragment as? DialogFragment)?.dismissAllowingStateLoss() } catch (e: Exception) { @@ -737,29 +686,24 @@ class Ketch private constructor( } } - // If requested, forcefully remove the fragment if (forceRemove) { fragmentManager.get()?.let { fm -> try { fm.beginTransaction() .remove(fragment) .commitNowAllowingStateLoss() - Log.d(TAG, "Forcefully removed fragment") } catch (e: Exception) { Log.e(TAG, "Error forcefully removing fragment: ${e.message}", e) } } - // Execute completion callback immediately for force removals onComplete.invoke(null) } else { - // For normal dismissals, wait a bit for the animation Handler(android.os.Looper.getMainLooper()).postDelayed({ onComplete.invoke(null) }, 100) } } else { - // No fragment found, execute completion callback immediately onComplete.invoke(null) } } catch (e: Exception) { From 78395fdde115bdd232e7872880eb239b9f76b530 Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Mon, 3 Mar 2025 02:58:39 +0100 Subject: [PATCH 09/30] comments updates --- README.md | 40 ++++++------------- .../src/main/java/com/ketch/android/Ketch.kt | 2 +- .../ketch/android/ui/KetchDialogFragment.kt | 3 -- 3 files changed, 14 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 5113f5b8..9944a49f 100644 --- a/README.md +++ b/README.md @@ -67,38 +67,31 @@ Feel free to skip the listeners you don't really need. // ... private val listener = object : Ketch.Listener { override fun onShow() { - // Called when a consent or preferences dialog is displayed - Log.d("KetchApp", "Dialog shown") + Log.d("KetchApp", "Dialog shown") // Called when a consent or preferences dialog is displayed } override fun onDismiss() { - // Called when a dialog is dismissed - Log.d("KetchApp", "Dialog dismissed") + Log.d("KetchApp", "Dialog dismissed") // Called when a dialog is dismissed } override fun onEnvironmentUpdated(environment: String?) { - // Called when the environment is updated - Log.d("KetchApp", "Environment updated: $environment") + Log.d("KetchApp", "Environment updated: $environment") // Called when the environment is updated } override fun onRegionInfoUpdated(regionInfo: String?) { - // Called when region info is updated - Log.d("KetchApp", "Region info updated: $regionInfo") + Log.d("KetchApp", "Region info updated: $regionInfo") // Called when region info is updated } override fun onJurisdictionUpdated(jurisdiction: String?) { - // Called when jurisdiction is updated - Log.d("KetchApp", "Jurisdiction updated: $jurisdiction") + Log.d("KetchApp", "Jurisdiction updated: $jurisdiction") // Called when jurisdiction is updated } override fun onIdentitiesUpdated(identities: String?) { - // Called when identities are updated - Log.d("KetchApp", "Identities updated: $identities") + Log.d("KetchApp", "Identities updated: $identities") // Called when identities are updated } override fun onConsentUpdated(consent: Consent) { - // Called when consent preferences are updated - Log.d("KetchApp", "Consent updated") + Log.d("KetchApp", "Consent updated") // Called when consent preferences are updated // Here you can handle consent changes for your app features // Example: Enable/disable tracking based on consent @@ -119,13 +112,11 @@ Feel free to skip the listeners you don't really need. } override fun onError(errMsg: String?) { - // Called when an error occurs - Log.e("KetchApp", "Error: $errMsg") + Log.e("KetchApp", "Error: $errMsg") // Called when an error occurs } override fun onUSPrivacyUpdated(values: Map) { - // Called when US Privacy values are updated - Log.d("KetchApp", "US Privacy updated") + Log.d("KetchApp", "US Privacy updated") // Called when US Privacy values are updated // You can access the US Privacy string val privacyString = values["IABUSPrivacy_String"] as? String @@ -133,20 +124,15 @@ Feel free to skip the listeners you don't really need. } override fun onTCFUpdated(values: Map) { - // Called when TCF values are updated - Log.d("KetchApp", "TCF updated") - - // You can access the TC string - val tcString = values["IABTCF_TCString"] as? String + Log.d("KetchApp", "TCF updated") // Called when TCF values are updated + val tcString = values["IABTCF_TCString"] as? String // You can access the TC string Log.d("KetchApp", "TCF TC String: $tcString") } override fun onGPPUpdated(values: Map) { - // Called when GPP values are updated - Log.d("KetchApp", "GPP updated") + Log.d("KetchApp", "GPP updated") // Called when GPP values are updated - // You can access the GPP string - val gppString = values["IABGPP_HDR_GppString"] as? String + val gppString = values["IABGPP_HDR_GppString"] as? String // You can access the GPP string Log.d("KetchApp", "GPP String: $gppString") } } diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 1cb3e18e..41130e46 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -216,7 +216,7 @@ class Ketch private constructor( } /** - * Force cleanup of any existing dialog fragments - use this if dialogs appear to be stuck + * Force cleanup of any existing dialog fragments, to be used when dialogs appear to be stuck */ fun forceCleanupDialogs() { isActive = false diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt index 318fcce9..fcf336e6 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt @@ -88,7 +88,6 @@ internal class KetchDialogFragment() : DialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) dialog?.window?.also { window -> - // Keep background transparent to allow interaction with WebView content window.clearFlags(FLAG_DIM_BEHIND) window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) @@ -108,8 +107,6 @@ internal class KetchDialogFragment() : DialogFragment() { // Add window animations for smoother transitions window.setWindowAnimations(android.R.style.Animation_Dialog) } - - // Don't set background on root view so content remains fully interactive } override fun onConfigurationChanged(newConfig: Configuration) { From 13880d0981bfbc01a4d3e628ce30a1bef2576667 Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Mon, 3 Mar 2025 05:51:45 +0100 Subject: [PATCH 10/30] seems to be working again --- .../src/main/java/com/ketch/android/Ketch.kt | 17 ++++++++++++----- .../ketch/android/ui/KetchDialogFragment.kt | 19 ++++++++++++++++--- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 41130e46..11b639e2 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -365,8 +365,13 @@ class Ketch private constructor( return } - val dialog = KetchDialogFragment.newInstance() - + val dialog = KetchDialogFragment.newInstance()?.apply { + val disableContentInteractions = getDisposableContentInteractions( + config?.experiences?.consent?.display ?: ContentDisplay.Banner + ) + isCancelable = !disableContentInteractions + } + fragmentManager.get()?.let { val existingFragment = it.findFragmentByTag(KetchDialogFragment.TAG) if (existingFragment != null) { @@ -377,11 +382,13 @@ class Ketch private constructor( } } - dialog.show(it, webView) + dialog?.show(it, webView) this@Ketch.listener?.onShow() } ?: run { isActive = false } + + showConsent = false } override fun onUSPrivacyUpdated(values: Map) { @@ -467,7 +474,7 @@ class Ketch private constructor( return } - val dialog = KetchDialogFragment.newInstance().apply { + val dialog = KetchDialogFragment.newInstance()?.apply { val disableContentInteractions = getDisposableContentInteractions( config?.experiences?.consent?.display ?: ContentDisplay.Banner ) @@ -484,7 +491,7 @@ class Ketch private constructor( } } - dialog.show(it, webView) + dialog?.show(it, webView) this@Ketch.listener?.onShow() } ?: run { isActive = false diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt index fcf336e6..c28db972 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt @@ -168,9 +168,22 @@ internal class KetchDialogFragment() : DialogFragment() { companion object { internal val TAG = KetchDialogFragment::class.java.simpleName - - fun newInstance(): KetchDialogFragment { - return KetchDialogFragment() + private var isCurrentlyShowing = false + + fun newInstance(): KetchDialogFragment? { + // Only allow ONE instance at a time + return if (!isCurrentlyShowing) { + isCurrentlyShowing = true + KetchDialogFragment() + } else { + Log.w(TAG, "DialogFragment already showing, ignoring request") + null + } } } + + override fun onDestroy() { + super.onDestroy() + isCurrentlyShowing = false + } } \ No newline at end of file From 52590d3b4855512cc2236622be2d276e0156cfc0 Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Mon, 3 Mar 2025 05:58:51 +0100 Subject: [PATCH 11/30] cleanup --- .../ketch/android/ui/KetchDialogFragment.kt | 151 ++++++++---------- 1 file changed, 70 insertions(+), 81 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt index c28db972..511ea531 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt @@ -31,53 +31,33 @@ internal class KetchDialogFragment() : DialogFragment() { container: ViewGroup?, savedInstanceState: Bundle? ): View { - binding = - KetchDialogLayoutBinding.bind(inflater.inflate(R.layout.ketch_dialog_layout, container)) - webView?.let { web -> - // Remove from any previous parent - (web.parent as? ViewGroup)?.removeView(web) - - // Add to our layout - binding.root.addView( - web, - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT - ) - - // Ensure WebView is interactive - web.isClickable = true - web.isFocusable = true - web.isFocusableInTouchMode = true - } + binding = KetchDialogLayoutBinding.bind( + inflater.inflate(R.layout.ketch_dialog_layout, container) + ) + + addWebViewToLayout() return binding.root } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val dialog = super.onCreateDialog(savedInstanceState).apply { + return super.onCreateDialog(savedInstanceState).apply { requestWindowFeature(Window.FEATURE_NO_TITLE) + setCanceledOnTouchOutside(isCancelable) } - return dialog } override fun onDestroyView() { try { Log.d(TAG, "onDestroyView: Beginning WebView cleanup") - - val webViewToCleanup = webView - webView = null - - webViewToCleanup?.let { wv -> + webView?.let { wv -> // Disable interaction during cleanup wv.setOnTouchListener { _, _ -> true } // Remove from view hierarchy (wv.parent as? ViewGroup)?.removeView(wv) binding.root.removeView(wv) - - // Let standard cleanup handle the rest - // We don't need to manually destroy the WebView here as it will be - // handled by the Ketch class } + webView = null } catch (e: Exception) { Log.e(TAG, "Error cleaning up WebView: ${e.message}", e) } @@ -90,30 +70,54 @@ internal class KetchDialogFragment() : DialogFragment() { dialog?.window?.also { window -> window.clearFlags(FLAG_DIM_BEHIND) window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) - - // Ensure dialog takes full screen - val displayMetrics = requireActivity().resources.displayMetrics - val width = displayMetrics.widthPixels - val height = displayMetrics.heightPixels - - val params = window.attributes.apply { - this.width = width - this.height = height - gravity = Gravity.CENTER - } - - window.attributes = params - - // Add window animations for smoother transitions - window.setWindowAnimations(android.R.style.Animation_Dialog) } + updateDialogSize() } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) + updateDialogSize() + } + + fun show(manager: FragmentManager, webView: KetchWebView) { + if (!isAdded) { + try { + // Check for existing fragments + manager.findFragmentByTag(TAG)?.let { existingFragment -> + manager.beginTransaction() + .remove(existingFragment) + .commitNowAllowingStateLoss() + } + + this.webView = webView + super.show(manager, TAG) + } catch (e: Exception) { + Log.e(TAG, "Error showing dialog: ${e.message}", e) + isCurrentlyShowing = false // Reset flag if we failed to show + } + } + } + + override fun dismiss() { + prepareForDismissal() + super.dismiss() + } + + override fun dismissAllowingStateLoss() { + prepareForDismissal() + super.dismissAllowingStateLoss() + } + + override fun onDestroy() { + super.onDestroy() + isCurrentlyShowing = false + } + + // Helper methods + + private fun updateDialogSize() { dialog?.window?.also { window -> val displayMetrics = requireActivity().resources.displayMetrics - val width = displayMetrics.widthPixels val height = displayMetrics.heightPixels @@ -127,49 +131,39 @@ internal class KetchDialogFragment() : DialogFragment() { } } - fun show(manager: FragmentManager, webView: KetchWebView) { - // Check for existing fragments - try { - val existingFragment = manager.findFragmentByTag(TAG) - if (existingFragment != null) { - manager.beginTransaction() - .remove(existingFragment) - .commitNowAllowingStateLoss() - } - } catch (e: Exception) { - Log.e(TAG, "Error cleaning up before show: ${e.message}", e) - } - - this.webView = webView - super.show(manager, TAG) - } - - override fun dismiss() { - try { - // Detach WebView from touch events during dismissal - webView?.setOnTouchListener { _, _ -> true } - } catch (e: Exception) { - Log.e(TAG, "Error in dismiss: ${e.message}", e) + private fun addWebViewToLayout() { + webView?.let { web -> + // Remove from any previous parent + (web.parent as? ViewGroup)?.removeView(web) + + // Add to our layout with appropriate properties + binding.root.addView( + web, + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + + // Ensure WebView is interactive + web.isClickable = true + web.isFocusable = true + web.isFocusableInTouchMode = true } - - super.dismiss() } - override fun dismissAllowingStateLoss() { + private fun prepareForDismissal() { try { // Detach WebView from touch events during dismissal webView?.setOnTouchListener { _, _ -> true } } catch (e: Exception) { - Log.e(TAG, "Error in dismissAllowingStateLoss: ${e.message}", e) + Log.e(TAG, "Error preparing for dismissal: ${e.message}", e) } - - super.dismissAllowingStateLoss() } companion object { internal val TAG = KetchDialogFragment::class.java.simpleName - private var isCurrentlyShowing = false + @Volatile private var isCurrentlyShowing = false + @Synchronized fun newInstance(): KetchDialogFragment? { // Only allow ONE instance at a time return if (!isCurrentlyShowing) { @@ -181,9 +175,4 @@ internal class KetchDialogFragment() : DialogFragment() { } } } - - override fun onDestroy() { - super.onDestroy() - isCurrentlyShowing = false - } } \ No newline at end of file From 7246975f05c58ec0e56a96ee6c39f847313e23a6 Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Mon, 3 Mar 2025 06:08:20 +0100 Subject: [PATCH 12/30] update --- .../src/main/java/com/ketch/android/Ketch.kt | 323 ++++++++---------- 1 file changed, 140 insertions(+), 183 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 11b639e2..52bbe36d 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -34,7 +34,7 @@ class Ketch private constructor( private var jurisdiction: String? = null private var region: String? = null - // Add a WebView instance and dialog state flag + // WebView instance and state flags private var currentWebView: KetchWebView? = null private var isActive = false @@ -80,27 +80,23 @@ class Ketch private constructor( synchronousPreferences: Boolean = false, bottomPadding: Int = 0, ): Boolean { - val webView = createWebView(shouldRetry, synchronousPreferences) - return if (webView != null) { - webView.load( - orgCode, - property, - language, - jurisdiction, - region, - environment, - identities, - null, - emptyList(), - null, - ketchUrl, - logLevel, - bottomPadding, - ) - true - } else { - false - } + val webView = createWebView(shouldRetry, synchronousPreferences) ?: return false + webView.load( + orgCode, + property, + language, + jurisdiction, + region, + environment, + identities, + null, + emptyList(), + null, + ketchUrl, + logLevel, + bottomPadding, + ) + return true } /** @@ -113,27 +109,23 @@ class Ketch private constructor( synchronousPreferences: Boolean = false, bottomPadding: Int = 0, ): Boolean { - val webView = createWebView(shouldRetry, synchronousPreferences) - return if (webView != null) { - webView.load( - orgCode, - property, - language, - jurisdiction, - region, - environment, - identities, - KetchWebView.ExperienceType.CONSENT, - emptyList(), - null, - ketchUrl, - logLevel, - bottomPadding - ) - true - } else { - false - } + val webView = createWebView(shouldRetry, synchronousPreferences) ?: return false + webView.load( + orgCode, + property, + language, + jurisdiction, + region, + environment, + identities, + KetchWebView.ExperienceType.CONSENT, + emptyList(), + null, + ketchUrl, + logLevel, + bottomPadding + ) + return true } /** @@ -146,27 +138,23 @@ class Ketch private constructor( synchronousPreferences: Boolean = false, bottomPadding: Int = 0, ): Boolean { - val webView = createWebView(shouldRetry, synchronousPreferences) - return if (webView != null) { - webView.load( - orgCode, - property, - language, - jurisdiction, - region, - environment, - identities, - KetchWebView.ExperienceType.PREFERENCES, - emptyList(), - null, - ketchUrl, - logLevel, - bottomPadding - ) - true - } else { - false - } + val webView = createWebView(shouldRetry, synchronousPreferences) ?: return false + webView.load( + orgCode, + property, + language, + jurisdiction, + region, + environment, + identities, + KetchWebView.ExperienceType.PREFERENCES, + emptyList(), + null, + ketchUrl, + logLevel, + bottomPadding + ) + return true } /** @@ -183,27 +171,23 @@ class Ketch private constructor( synchronousPreferences: Boolean = false, bottomPadding: Int = 0, ): Boolean { - val webView = createWebView(shouldRetry, synchronousPreferences) - return if (webView != null) { - webView.load( - orgCode, - property, - language, - jurisdiction, - region, - environment, - identities, - KetchWebView.ExperienceType.PREFERENCES, - tabs, - tab, - ketchUrl, - logLevel, - bottomPadding - ) - true - } else { - false - } + val webView = createWebView(shouldRetry, synchronousPreferences) ?: return false + webView.load( + orgCode, + property, + language, + jurisdiction, + region, + environment, + identities, + KetchWebView.ExperienceType.PREFERENCES, + tabs, + tab, + ketchUrl, + logLevel, + bottomPadding + ) + return true } /** @@ -282,13 +266,14 @@ class Ketch private constructor( fragmentManager.get()?.let { fm -> try { - val existingDialog = fm.findFragmentByTag(KetchDialogFragment.TAG) - if (existingDialog != null) { + val initialExistingDialog = fm.findFragmentByTag(KetchDialogFragment.TAG) + if (initialExistingDialog != null) { cleanupDialogFragment(forceRemove = true) { _ -> this@Ketch.listener?.onDismiss(HideExperienceStatus.None) } } else { - // No action needed when no dialog exists + // No existing dialog to clean up + null } } catch (e: Exception) { Log.e(TAG, "Error cleaning up existing dialogs: ${e.message}", e) @@ -312,12 +297,11 @@ class Ketch private constructor( return null } - val existingDialog = findDialogFragment() - if (existingDialog != null) { + val existingFragment = findDialogFragment() + if (existingFragment != null) { if (!isActive) { cleanupDialogFragment(forceRemove = true) } - return null } @@ -326,11 +310,12 @@ class Ketch private constructor( try { cleanupWebView() - val webView = context.get()?.let { KetchWebView(it, shouldRetry) } ?: run { + val ctx = context.get() ?: run { isActive = false return null } + val webView = KetchWebView(ctx, shouldRetry) currentWebView = webView if (logLevel === LogLevel.DEBUG) { @@ -356,37 +341,11 @@ class Ketch private constructor( return } - val existingDialog = findDialogFragment() - if (existingDialog != null) { - if (!isActive) { - isActive = true - } - - return - } + if (isFragmentAlreadyShowing()) return - val dialog = KetchDialogFragment.newInstance()?.apply { - val disableContentInteractions = getDisposableContentInteractions( - config?.experiences?.consent?.display ?: ContentDisplay.Banner - ) - isCancelable = !disableContentInteractions - } - - fragmentManager.get()?.let { - val existingFragment = it.findFragmentByTag(KetchDialogFragment.TAG) - if (existingFragment != null) { - try { - it.beginTransaction().remove(existingFragment).commitNow() - } catch (e: Exception) { - Log.e(TAG, "Error removing existing fragment: ${e.message}", e) - } - } - - dialog?.show(it, webView) - this@Ketch.listener?.onShow() - } ?: run { - isActive = false - } + showDialogWithWebView( + webView = webView + ) showConsent = false } @@ -410,10 +369,9 @@ class Ketch private constructor( this.config = config this@Ketch.listener?.onConfigUpdated(config) - if (!showConsent) { - return + if (showConsent) { + showConsentPopup() } - showConsentPopup() } override fun onEnvironmentUpdated(environment: String?) { @@ -465,53 +423,34 @@ class Ketch private constructor( return } - val existingDialog = findDialogFragment() - if (existingDialog != null) { - if (!isActive) { - isActive = true - } - - return - } + if (isFragmentAlreadyShowing()) return - val dialog = KetchDialogFragment.newInstance()?.apply { - val disableContentInteractions = getDisposableContentInteractions( - config?.experiences?.consent?.display ?: ContentDisplay.Banner - ) - isCancelable = !disableContentInteractions - } + showDialogWithWebView( + webView = webView + ) - fragmentManager.get()?.let { - val existingFragment = it.findFragmentByTag(KetchDialogFragment.TAG) - if (existingFragment != null) { - try { - it.beginTransaction().remove(existingFragment).commitNow() - } catch (e: Exception) { - Log.e(TAG, "Error removing existing fragment: ${e.message}", e) - } + showConsent = false + } + + // Helper method to check if a fragment is already showing + private fun isFragmentAlreadyShowing(): Boolean { + val currentFragment = findDialogFragment() + if (currentFragment != null) { + if (!isActive) { + isActive = true } - - dialog?.show(it, webView) - this@Ketch.listener?.onShow() - } ?: run { - isActive = false + return true } - - showConsent = false + return false } private fun getDisposableContentInteractions(display: ContentDisplay): Boolean { return config?.let { when (display) { - ContentDisplay.Modal -> { - it.theme?.modal?.container?.backdrop?.disableContentInteractions == true - } - ContentDisplay.Banner -> { + ContentDisplay.Modal, ContentDisplay.Banner -> { it.theme?.modal?.container?.backdrop?.disableContentInteractions == true } - else -> { - false - } + // ContentDisplay is an enum, so this covers all cases } } ?: false } @@ -523,6 +462,33 @@ class Ketch private constructor( return null } } + + /** + * Helper method to show a dialog with the WebView + */ + private fun showDialogWithWebView( + webView: KetchWebView + ) { + // Since we can't access the config property from the WebViewListener, + // we'll use a default value for disableContentInteractions + val disableContentInteractions = false + + val dialog = KetchDialogFragment.newInstance()?.apply { + isCancelable = !disableContentInteractions + } + + fragmentManager.get()?.let { manager -> + try { + dialog?.show(manager, webView) + this@Ketch.listener?.onShow() + } catch (e: Exception) { + Log.e(TAG, "Error showing dialog: ${e.message}", e) + isActive = false + } + } ?: run { + isActive = false + } + } private fun findDialogFragment(): androidx.fragment.app.Fragment? { return fragmentManager.get()?.findFragmentByTag(KetchDialogFragment.TAG) @@ -645,15 +611,11 @@ class Ketch private constructor( * This should be called from onDestroy or when the app knows it won't use the SDK for a while */ fun cleanup() { + isActive = false cleanupDialogFragment { _ -> Handler(android.os.Looper.getMainLooper()).postDelayed({ - try { - cleanupWebView() - Runtime.getRuntime().gc() - } catch (e: Exception) { - Log.e(TAG, "Error during cleanup: ${e.message}", e) - } - isActive = false + cleanupWebView() + Runtime.getRuntime().gc() }, 200) } } @@ -663,10 +625,8 @@ class Ketch private constructor( */ private fun cleanupWebView() { try { - if (currentWebView != null) { - currentWebView?.destroy() - currentWebView = null - } + currentWebView?.destroy() + currentWebView = null } catch (e: Exception) { Log.e(TAG, "Error during WebView cleanup: ${e.message}", e) currentWebView = null @@ -683,14 +643,10 @@ class Ketch private constructor( try { val fragment = findDialogFragment() if (fragment != null) { - if (fragment is KetchDialogFragment) { - fragment.dismissAllowingStateLoss() - } else { - try { - (fragment as? DialogFragment)?.dismissAllowingStateLoss() - } catch (e: Exception) { - Log.e(TAG, "Error dismissing non-KetchDialogFragment: ${e.message}") - } + // Dismiss the fragment if it's a DialogFragment + when (fragment) { + is KetchDialogFragment -> fragment.dismissAllowingStateLoss() + is DialogFragment -> fragment.dismissAllowingStateLoss() } if (forceRemove) { @@ -699,12 +655,13 @@ class Ketch private constructor( fm.beginTransaction() .remove(fragment) .commitNowAllowingStateLoss() + + onComplete.invoke(null) } catch (e: Exception) { Log.e(TAG, "Error forcefully removing fragment: ${e.message}", e) + onComplete.invoke(null) } - } - - onComplete.invoke(null) + } ?: onComplete.invoke(null) } else { Handler(android.os.Looper.getMainLooper()).postDelayed({ onComplete.invoke(null) From fdaedbd721931d0dc079b0236719ccfbdeb8c9f6 Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Mon, 3 Mar 2025 09:17:06 +0100 Subject: [PATCH 13/30] code cleanup --- .../src/main/java/com/ketch/android/Ketch.kt | 72 +++++-------------- 1 file changed, 17 insertions(+), 55 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 52bbe36d..8f174c99 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -42,33 +42,27 @@ class Ketch private constructor( * Retrieve a String value from the preferences. * * @param key The name of the preference to retrieve. - * * @return Returns the preference value if it exists */ fun getSavedString(key: String) = getPreferences().getSavedValue(key) /** * Retrieve IABTCF_TCString value from the preferences. - * * @return Returns the preference value if it exists */ fun getTCFTCString() = getPreferences().getSavedValue(KetchSharedPreferences.IAB_TCF_TC_STRING) /** * Retrieve IABUSPrivacy_String value from the preferences. - * * @return Returns the preference value if it exists */ - fun getUSPrivacyString() = - getPreferences().getSavedValue(KetchSharedPreferences.IAB_US_PRIVACY_STRING) + fun getUSPrivacyString() = getPreferences().getSavedValue(KetchSharedPreferences.IAB_US_PRIVACY_STRING) /** * Retrieve IABGPP_HDR_GppString value from the preferences. - * * @return Returns the preference value if it exists */ - fun getGPPHDRGppString() = - getPreferences().getSavedValue(KetchSharedPreferences.IAB_GPP_HDR_GPP_STRING) + fun getGPPHDRGppString() = getPreferences().getSavedValue(KetchSharedPreferences.IAB_GPP_HDR_GPP_STRING) /** * Loads a web page and shows a popup if necessary @@ -227,39 +221,27 @@ class Ketch private constructor( /** * Set identities - * * @param identities: Map */ - fun setIdentities(identities: Map) { - this.identities = identities - } + fun setIdentities(identities: Map) { this.identities = identities } /** * Set the language - * * @param language: a language name (EN, FR, etc.) */ - fun setLanguage(language: String?) { - this.language = language - } + fun setLanguage(language: String?) { this.language = language } /** * Set the jurisdiction - * * @param jurisdiction: the jurisdiction value */ - fun setJurisdiction(jurisdiction: String?) { - this.jurisdiction = jurisdiction - } + fun setJurisdiction(jurisdiction: String?) { this.jurisdiction = jurisdiction } /** * Set Region - * * @param region: the region name */ - fun setRegion(region: String?) { - this.region = region - } + fun setRegion(region: String?) { this.region = region } init { getPreferences() @@ -401,7 +383,7 @@ class Ketch private constructor( override fun changeDialog(display: ContentDisplay) { findDialogFragment()?.let { (it as? KetchDialogFragment)?.apply { - isCancelable = getDisposableContentInteractions(display) + isCancelable = getDisposableContentInteractions() } } } @@ -444,16 +426,7 @@ class Ketch private constructor( return false } - private fun getDisposableContentInteractions(display: ContentDisplay): Boolean { - return config?.let { - when (display) { - ContentDisplay.Modal, ContentDisplay.Banner -> { - it.theme?.modal?.container?.backdrop?.disableContentInteractions == true - } - // ContentDisplay is an enum, so this covers all cases - } - } ?: false - } + private fun getDisposableContentInteractions() = config?.theme?.modal?.container?.backdrop?.disableContentInteractions ?: false } return webView } catch (e: Exception) { @@ -490,13 +463,9 @@ class Ketch private constructor( } } - private fun findDialogFragment(): androidx.fragment.app.Fragment? { - return fragmentManager.get()?.findFragmentByTag(KetchDialogFragment.TAG) - } + private fun findDialogFragment() = fragmentManager.get()?.findFragmentByTag(KetchDialogFragment.TAG) - private fun isActivityActive(): Boolean { - return (context.get() as? LifecycleOwner)?.lifecycle?.currentState?.isAtLeast(Lifecycle.State.STARTED) ?: false - } + private fun isActivityActive() = (context.get() as? LifecycleOwner)?.lifecycle?.currentState?.isAtLeast(Lifecycle.State.STARTED) ?: false enum class PreferencesTab { OVERVIEW, @@ -504,12 +473,7 @@ class Ketch private constructor( CONSENTS, SUBSCRIPTIONS; - fun getUrlParameter(): String = when (this) { - OVERVIEW -> "overviewTab" - RIGHTS -> "rightsTab" - CONSENTS -> "consentsTab" - SUBSCRIPTIONS -> "subscriptionsTab" - } + fun getUrlParameter() = "${name.lowercase()}Tab" } enum class LogLevel { @@ -623,14 +587,12 @@ class Ketch private constructor( /** * Centralized helper to clean up WebView resources */ - private fun cleanupWebView() { - try { - currentWebView?.destroy() - currentWebView = null - } catch (e: Exception) { - Log.e(TAG, "Error during WebView cleanup: ${e.message}", e) - currentWebView = null - } + private fun cleanupWebView() = runCatching { + currentWebView?.destroy() + }.onFailure { + Log.e(TAG, "Error during WebView cleanup: ${it.message}", it) + }.also { + currentWebView = null } /** From 96528d77e57d6647b869507b3800d26a721362d5 Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Mon, 3 Mar 2025 09:51:58 +0100 Subject: [PATCH 14/30] remove redundant handler --- .../main/java/com/ketch/android/data/Index.kt | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/data/Index.kt b/ketchsdk/src/main/java/com/ketch/android/data/Index.kt index 8837daaa..ab3edb6b 100644 --- a/ketchsdk/src/main/java/com/ketch/android/data/Index.kt +++ b/ketchsdk/src/main/java/com/ketch/android/data/Index.kt @@ -156,21 +156,12 @@ fun getIndexHtml( " document.getElementsByTagName('head')[0].appendChild(e);\n" + " }\n" + " // We put the script inside body, otherwise document.body will be null\n" + - " // Improved tap outside detection with multiple event types and protection against race conditions\n" + - " function handleTapOutside(e) {\n" + - " // Ensure we only handle taps on the body element\n" + + " // Trigger taps outside the dialog\n" + + " document.body.addEventListener('touchstart', function (e) {\n" + " if (e.target === document.body) {\n" + - " console.log('Tap outside detected');\n" + - " // Use setTimeout to avoid race conditions with WebView destruction\n" + - " setTimeout(() => {\n" + - " emitEvent('hideExperience', ['close']);\n" + - " }, 0);\n" + + " emitEvent('hideExperience', ['close']);\n" + " }\n" + - " }\n" + - "\n" + - " // Handle both touchstart and mousedown for maximum compatibility\n" + - " document.body.addEventListener('touchstart', handleTapOutside, { passive: true });\n" + - " document.body.addEventListener('mousedown', handleTapOutside, { passive: true });\n" + + " });\n" + " initKetchTag({" + "ketch_log: \"${logLevel}\"," + if (language?.isNotBlank() == true) { From 973bc3aec6dffcd618880c2ca6a8f6cc124f2ba1 Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Mon, 3 Mar 2025 10:08:08 +0100 Subject: [PATCH 15/30] performance improvements --- .../src/main/java/com/ketch/android/Ketch.kt | 13 +-- .../ketch/android/ui/KetchDialogFragment.kt | 12 +++ .../java/com/ketch/android/ui/KetchWebView.kt | 79 ++++++++----------- 3 files changed, 51 insertions(+), 53 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 8f174c99..16688eff 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -473,7 +473,12 @@ class Ketch private constructor( CONSENTS, SUBSCRIPTIONS; - fun getUrlParameter() = "${name.lowercase()}Tab" + fun getUrlParameter(): String = when (this) { + OVERVIEW -> "overviewTab" + RIGHTS -> "rightsTab" + CONSENTS -> "consentsTab" + SUBSCRIPTIONS -> "subscriptionsTab" + } } enum class LogLevel { @@ -577,10 +582,8 @@ class Ketch private constructor( fun cleanup() { isActive = false cleanupDialogFragment { _ -> - Handler(android.os.Looper.getMainLooper()).postDelayed({ - cleanupWebView() - Runtime.getRuntime().gc() - }, 200) + cleanupWebView() + Runtime.getRuntime().gc() } } diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt index 511ea531..4a311dd3 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt @@ -79,6 +79,18 @@ internal class KetchDialogFragment() : DialogFragment() { updateDialogSize() } + override fun onStart() { + super.onStart() + dialog?.window?.let { window -> + window.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + window.setGravity(position.gravity) + window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + } + } + fun show(manager: FragmentManager, webView: KetchWebView) { if (!isAdded) { try { diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt index 25bcc077..46e83828 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt @@ -37,26 +37,20 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicBoolean -const val INITIAL_RELOAD_DELAY = 4000L - -@SuppressLint("SetJavaScriptEnabled", "ViewConstructor") -class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(context) { +class KetchWebView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : WebView(context, attrs, defStyleAttr) { var listener: WebViewListener? = null - private val localContentWebViewClient = LocalContentWebViewClient(shouldRetry) + private val localContentWebViewClient = LocalContentWebViewClient() + private var isPageLoaded = false + private var currentUrl: String? = null init { webViewClient = localContentWebViewClient - settings.javaScriptEnabled = true - setBackgroundColor(context.getColor(android.R.color.transparent)) - - // Explicitly set to false to address android webview security concern - setWebContentsDebuggingEnabled(false) - - addJavascriptInterface( - PreferenceCenterJavascriptInterface(this), - "androidListener" - ) + setupWebView() //receive console messages from the WebView webChromeClient = object : WebChromeClient() { @@ -72,6 +66,21 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con } } + private fun setupWebView() { + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + setGeolocationEnabled(false) + mediaPlaybackRequiresUserGesture = false + } + } + + override fun loadUrl(url: String) { + currentUrl = url + isPageLoaded = false + super.loadUrl(url) + } + fun setDebugMode() { setWebContentsDebuggingEnabled(true) } @@ -137,16 +146,7 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con } } - class LocalContentWebViewClient(private var shouldRetry: Boolean = false) : WebViewClientCompat() { - - // Flag indicating if the webview has finished loading - // We use atomic boolean here because we are using it within a coroutine - private var isLoaded = AtomicBoolean(false) - - // Reload delay, increases exponentially in onPageStarted - private var reloadDelay = INITIAL_RELOAD_DELAY - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + class LocalContentWebViewClient : WebViewClientCompat() { override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { val intent = Intent(Intent.ACTION_VIEW, request.url) @@ -185,41 +185,23 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con super.onPageStarted(view, url, favicon) Log.d(TAG, "onPageStarted: $url") - // Reset loaded flag - isLoaded.set(false) - - // Launch retry if flag set - if (shouldRetry) { - scope.launch(Dispatchers.Main) { - delay(reloadDelay) - - // If not yet loaded stop current webview, reload, and increase future delay - if (!isLoaded.get()) { - Log.d(TAG, "Reloading webview after $reloadDelay ms") - view?.stopLoading() - view?.reload() - reloadDelay *= 2 // Exponentially increase reload delay - } - } + if (url == currentUrl) { + isPageLoaded = false } } override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) - // Set loaded flag - isLoaded.set(true) - - // Only reset reload delay when second onPageFinished callback has fired - if (url === "data:text/html;charset=utf-8;base64,") { - reloadDelay = INITIAL_RELOAD_DELAY + if (url == currentUrl && !isPageLoaded) { + isPageLoaded = true + onPageLoadedListener?.onPageLoaded() } Log.d(TAG, "onPageFinished: $url") } // Cancel all coroutines fun cancelCoroutines() { - scope.cancel() Log.d(TAG, "webViewClient coroutines cancelled") } } @@ -455,6 +437,7 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con fun changeDialog(display: ContentDisplay) fun onClose(status: HideExperienceStatus) fun onWillShowExperience(experienceType: WillShowExperienceType) + fun onPageLoaded() } internal enum class ExperienceType { From 7d15c92e8e20d6ba9342af205c948bb5254a8b2c Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Mon, 3 Mar 2025 10:44:42 +0100 Subject: [PATCH 16/30] Fix WebView issues and improve resource management --- .../src/main/java/com/ketch/android/Ketch.kt | 13 ++++++++- .../ketch/android/ui/KetchDialogFragment.kt | 10 +++++++ .../java/com/ketch/android/ui/KetchWebView.kt | 27 ++++++++++++------- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 16688eff..992f1c3e 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -297,7 +297,13 @@ class Ketch private constructor( return null } - val webView = KetchWebView(ctx, shouldRetry) + val webView = KetchWebView(ctx) + // Set retry flag if needed + if (shouldRetry) { + // Handle retry logic if needed + Log.d(TAG, "WebView created with retry enabled") + } + currentWebView = webView if (logLevel === LogLevel.DEBUG) { @@ -398,6 +404,11 @@ class Ketch private constructor( override fun onWillShowExperience(experienceType: WillShowExperienceType) { this@Ketch.listener?.onWillShowExperience(experienceType) } + + override fun onPageLoaded() { + // Handle page loaded event + Log.d(TAG, "WebView page loaded") + } private fun showConsentPopup() { if (!isActivityActive()) { diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt index 4a311dd3..c264b063 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt @@ -25,6 +25,16 @@ internal class KetchDialogFragment() : DialogFragment() { private lateinit var binding: KetchDialogLayoutBinding private var webView: KetchWebView? = null + + // Define the position property with a default value + private val position: DialogPosition = DialogPosition.CENTER + + // Add DialogPosition enum + enum class DialogPosition(val gravity: Int) { + TOP(Gravity.TOP), + CENTER(Gravity.CENTER), + BOTTOM(Gravity.BOTTOM) + } override fun onCreateView( inflater: LayoutInflater, diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt index 46e83828..24a67496 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt @@ -6,6 +6,7 @@ import android.content.Intent import android.graphics.Bitmap import android.os.Handler import android.os.Looper +import android.util.AttributeSet import android.util.Log import android.view.ViewGroup import android.webkit.ConsoleMessage @@ -45,8 +46,9 @@ class KetchWebView @JvmOverloads constructor( var listener: WebViewListener? = null private val localContentWebViewClient = LocalContentWebViewClient() - private var isPageLoaded = false - private var currentUrl: String? = null + internal var isPageLoaded = false + internal var currentUrl: String? = null + private var onPageLoadedListener: OnPageLoadedListener? = null init { webViewClient = localContentWebViewClient @@ -147,6 +149,8 @@ class KetchWebView @JvmOverloads constructor( } class LocalContentWebViewClient : WebViewClientCompat() { + private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private val isRetrying = AtomicBoolean(false) override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { val intent = Intent(Intent.ACTION_VIEW, request.url) @@ -185,23 +189,24 @@ class KetchWebView @JvmOverloads constructor( super.onPageStarted(view, url, favicon) Log.d(TAG, "onPageStarted: $url") - if (url == currentUrl) { - isPageLoaded = false + if (view is KetchWebView && url == view.currentUrl) { + view.isPageLoaded = false } } override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) - if (url == currentUrl && !isPageLoaded) { - isPageLoaded = true - onPageLoadedListener?.onPageLoaded() + if (view is KetchWebView && url == view.currentUrl && !view.isPageLoaded) { + view.isPageLoaded = true + view.listener?.onPageLoaded() } Log.d(TAG, "onPageFinished: $url") } // Cancel all coroutines fun cancelCoroutines() { + coroutineScope.cancel() Log.d(TAG, "webViewClient coroutines cancelled") } } @@ -421,7 +426,7 @@ class KetchWebView @JvmOverloads constructor( } } - interface WebViewListener { + interface WebViewListener : OnPageLoadedListener { fun showConsent() fun showPreferences() fun onUSPrivacyUpdated(values: Map) @@ -437,7 +442,6 @@ class KetchWebView @JvmOverloads constructor( fun changeDialog(display: ContentDisplay) fun onClose(status: HideExperienceStatus) fun onWillShowExperience(experienceType: WillShowExperienceType) - fun onPageLoaded() } internal enum class ExperienceType { @@ -450,6 +454,11 @@ class KetchWebView @JvmOverloads constructor( } } + // Interface for page load events + interface OnPageLoadedListener { + fun onPageLoaded() + } + companion object { private val TAG: String = KetchWebView::class.java.simpleName } From 0dbc2faccfc7afb8a6abb57a078fc23542815718 Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Mon, 3 Mar 2025 10:50:55 +0100 Subject: [PATCH 17/30] code cleanup --- .../src/main/java/com/ketch/android/Ketch.kt | 2 - .../ketch/android/ui/KetchDialogFragment.kt | 37 +++++++++---------- .../java/com/ketch/android/ui/KetchWebView.kt | 9 +---- 3 files changed, 19 insertions(+), 29 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 992f1c3e..41a55acf 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -298,9 +298,7 @@ class Ketch private constructor( } val webView = KetchWebView(ctx) - // Set retry flag if needed if (shouldRetry) { - // Handle retry logic if needed Log.d(TAG, "WebView created with retry enabled") } diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt index c264b063..434adacc 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt @@ -45,7 +45,23 @@ internal class KetchDialogFragment() : DialogFragment() { inflater.inflate(R.layout.ketch_dialog_layout, container) ) - addWebViewToLayout() + webView?.let { web -> + // Remove from any previous parent + (web.parent as? ViewGroup)?.removeView(web) + + // Add to our layout with appropriate properties + binding.root.addView( + web, + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + + // Ensure WebView is interactive + web.isClickable = true + web.isFocusable = true + web.isFocusableInTouchMode = true + } + return binding.root } @@ -153,25 +169,6 @@ internal class KetchDialogFragment() : DialogFragment() { } } - private fun addWebViewToLayout() { - webView?.let { web -> - // Remove from any previous parent - (web.parent as? ViewGroup)?.removeView(web) - - // Add to our layout with appropriate properties - binding.root.addView( - web, - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT - ) - - // Ensure WebView is interactive - web.isClickable = true - web.isFocusable = true - web.isFocusableInTouchMode = true - } - } - private fun prepareForDismissal() { try { // Detach WebView from touch events during dismissal diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt index 24a67496..3ac9d40f 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt @@ -48,7 +48,6 @@ class KetchWebView @JvmOverloads constructor( private val localContentWebViewClient = LocalContentWebViewClient() internal var isPageLoaded = false internal var currentUrl: String? = null - private var onPageLoadedListener: OnPageLoadedListener? = null init { webViewClient = localContentWebViewClient @@ -426,7 +425,7 @@ class KetchWebView @JvmOverloads constructor( } } - interface WebViewListener : OnPageLoadedListener { + interface WebViewListener { fun showConsent() fun showPreferences() fun onUSPrivacyUpdated(values: Map) @@ -442,6 +441,7 @@ class KetchWebView @JvmOverloads constructor( fun changeDialog(display: ContentDisplay) fun onClose(status: HideExperienceStatus) fun onWillShowExperience(experienceType: WillShowExperienceType) + fun onPageLoaded() } internal enum class ExperienceType { @@ -454,11 +454,6 @@ class KetchWebView @JvmOverloads constructor( } } - // Interface for page load events - interface OnPageLoadedListener { - fun onPageLoaded() - } - companion object { private val TAG: String = KetchWebView::class.java.simpleName } From 7c0fadd0e2554e930546ca27653bd45a3da0037d Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Mon, 3 Mar 2025 10:54:39 +0100 Subject: [PATCH 18/30] re-add the js interface --- ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt index 3ac9d40f..2a10328b 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt @@ -53,6 +53,12 @@ class KetchWebView @JvmOverloads constructor( webViewClient = localContentWebViewClient setupWebView() + // Add JavaScript interface for communication with WebView + addJavascriptInterface( + PreferenceCenterJavascriptInterface(this), + "androidListener" + ) + //receive console messages from the WebView webChromeClient = object : WebChromeClient() { override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { From 381f4de0b99d0b79700179d739bb1d3a282d6e2e Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Mon, 3 Mar 2025 11:22:23 +0100 Subject: [PATCH 19/30] cleanup --- .../src/main/java/com/ketch/android/Ketch.kt | 221 +++++++++++------- 1 file changed, 132 insertions(+), 89 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 41a55acf..c10166f4 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -74,23 +74,27 @@ class Ketch private constructor( synchronousPreferences: Boolean = false, bottomPadding: Int = 0, ): Boolean { - val webView = createWebView(shouldRetry, synchronousPreferences) ?: return false - webView.load( - orgCode, - property, - language, - jurisdiction, - region, - environment, - identities, - null, - emptyList(), - null, - ketchUrl, - logLevel, - bottomPadding, - ) - return true + val webView = createWebView(shouldRetry, synchronousPreferences) + return if (webView != null) { + webView.load( + orgCode, + property, + language, + jurisdiction, + region, + environment, + identities, + null, + emptyList(), + null, + ketchUrl, + logLevel, + bottomPadding, + ) + true + } else { + false + } } /** @@ -103,23 +107,27 @@ class Ketch private constructor( synchronousPreferences: Boolean = false, bottomPadding: Int = 0, ): Boolean { - val webView = createWebView(shouldRetry, synchronousPreferences) ?: return false - webView.load( - orgCode, - property, - language, - jurisdiction, - region, - environment, - identities, - KetchWebView.ExperienceType.CONSENT, - emptyList(), - null, - ketchUrl, - logLevel, - bottomPadding - ) - return true + val webView = createWebView(shouldRetry, synchronousPreferences) + return if (webView != null) { + webView.load( + orgCode, + property, + language, + jurisdiction, + region, + environment, + identities, + KetchWebView.ExperienceType.CONSENT, + emptyList(), + null, + ketchUrl, + logLevel, + bottomPadding + ) + true + } else { + false + } } /** @@ -132,23 +140,27 @@ class Ketch private constructor( synchronousPreferences: Boolean = false, bottomPadding: Int = 0, ): Boolean { - val webView = createWebView(shouldRetry, synchronousPreferences) ?: return false - webView.load( - orgCode, - property, - language, - jurisdiction, - region, - environment, - identities, - KetchWebView.ExperienceType.PREFERENCES, - emptyList(), - null, - ketchUrl, - logLevel, - bottomPadding - ) - return true + val webView = createWebView(shouldRetry, synchronousPreferences) + return if (webView != null) { + webView.load( + orgCode, + property, + language, + jurisdiction, + region, + environment, + identities, + KetchWebView.ExperienceType.PREFERENCES, + emptyList(), + null, + ketchUrl, + logLevel, + bottomPadding + ) + true + } else { + false + } } /** @@ -165,23 +177,27 @@ class Ketch private constructor( synchronousPreferences: Boolean = false, bottomPadding: Int = 0, ): Boolean { - val webView = createWebView(shouldRetry, synchronousPreferences) ?: return false - webView.load( - orgCode, - property, - language, - jurisdiction, - region, - environment, - identities, - KetchWebView.ExperienceType.PREFERENCES, - tabs, - tab, - ketchUrl, - logLevel, - bottomPadding - ) - return true + val webView = createWebView(shouldRetry, synchronousPreferences) + return if (webView != null) { + webView.load( + orgCode, + property, + language, + jurisdiction, + region, + environment, + identities, + KetchWebView.ExperienceType.PREFERENCES, + tabs, + tab, + ketchUrl, + logLevel, + bottomPadding + ) + true + } else { + false + } } /** @@ -323,15 +339,34 @@ class Ketch private constructor( override fun showPreferences() { if (!isActivityActive()) { + Log.d(TAG, "Not showing as activity is not active") isActive = false return } - if (isFragmentAlreadyShowing()) return + if (findDialogFragment() != null) { + Log.d(TAG, "Not showing as dialog already exists") + if (!isActive) { + isActive = true + } + return + } + + val dialog = KetchDialogFragment.newInstance()?.apply { + isCancelable = !getDisposableContentInteractions() + } - showDialogWithWebView( - webView = webView - ) + fragmentManager.get()?.let { manager -> + try { + dialog?.show(manager, webView) + this@Ketch.listener?.onShow() + } catch (e: Exception) { + Log.e(TAG, "Error showing dialog: ${e.message}", e) + isActive = false + } + } ?: run { + isActive = false + } showConsent = false } @@ -410,32 +445,40 @@ class Ketch private constructor( private fun showConsentPopup() { if (!isActivityActive()) { + Log.d(TAG, "Not showing as activity is not active") isActive = false return } - if (isFragmentAlreadyShowing()) return - - showDialogWithWebView( - webView = webView - ) - - showConsent = false - } - - // Helper method to check if a fragment is already showing - private fun isFragmentAlreadyShowing(): Boolean { - val currentFragment = findDialogFragment() - if (currentFragment != null) { + if (findDialogFragment() != null) { + Log.d(TAG, "Not showing as dialog already exists") if (!isActive) { isActive = true } - return true + return } - return false - } - private fun getDisposableContentInteractions() = config?.theme?.modal?.container?.backdrop?.disableContentInteractions ?: false + val dialog = KetchDialogFragment.newInstance()?.apply { + isCancelable = !getDisposableContentInteractions() + } + + fragmentManager.get()?.let { manager -> + try { + dialog?.show(manager, webView) + this@Ketch.listener?.onShow() + } catch (e: Exception) { + Log.e(TAG, "Error showing dialog: ${e.message}", e) + isActive = false + } + } ?: run { + isActive = false + } + + showConsent = false + } + + private fun getDisposableContentInteractions(): Boolean = + config?.theme?.modal?.container?.backdrop?.disableContentInteractions ?: false } return webView } catch (e: Exception) { From b2e6aafbdbbf8d725cbbed0e1e0bd576751e942d Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Mon, 3 Mar 2025 12:02:06 +0100 Subject: [PATCH 20/30] clean up --- .../src/main/java/com/ketch/android/Ketch.kt | 38 ------------------- .../ketch/android/ui/KetchDialogFragment.kt | 14 ++++++- .../java/com/ketch/android/ui/KetchWebView.kt | 3 ++ 3 files changed, 16 insertions(+), 39 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index c10166f4..2854f59f 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -209,32 +209,6 @@ class Ketch private constructor( } } - /** - * Force cleanup of any existing dialog fragments, to be used when dialogs appear to be stuck - */ - fun forceCleanupDialogs() { - isActive = false - cleanupWebView() - cleanupDialogFragment(forceRemove = true) - } - - /** - * Provides a complete reset of the SDK state. - * Call this method if the UI becomes unresponsive to restore normal operation. - */ - fun resetState() { - Log.d(TAG, "Performing full SDK state reset") - - try { - cleanupDialogFragment(forceRemove = true) - cleanupWebView() - isActive = false - System.gc() - } catch (e: Exception) { - Log.e(TAG, "Error during state reset: ${e.message}", e) - } - } - /** * Set identities * @param identities: Map @@ -627,18 +601,6 @@ class Ketch private constructor( ) } - /** - * Clean up resources when the host app is being destroyed or paused for a long time - * This should be called from onDestroy or when the app knows it won't use the SDK for a while - */ - fun cleanup() { - isActive = false - cleanupDialogFragment { _ -> - cleanupWebView() - Runtime.getRuntime().gc() - } - } - /** * Centralized helper to clean up WebView resources */ diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt index 434adacc..24b48c99 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt @@ -45,6 +45,9 @@ internal class KetchDialogFragment() : DialogFragment() { inflater.inflate(R.layout.ketch_dialog_layout, container) ) + // Ensure the root view is transparent + binding.root.setBackgroundColor(Color.TRANSPARENT) + webView?.let { web -> // Remove from any previous parent (web.parent as? ViewGroup)?.removeView(web) @@ -56,10 +59,11 @@ internal class KetchDialogFragment() : DialogFragment() { FrameLayout.LayoutParams.MATCH_PARENT ) - // Ensure WebView is interactive + // Ensure WebView is interactive and transparent web.isClickable = true web.isFocusable = true web.isFocusableInTouchMode = true + web.setBackgroundColor(Color.TRANSPARENT) } return binding.root @@ -113,7 +117,15 @@ internal class KetchDialogFragment() : DialogFragment() { ViewGroup.LayoutParams.MATCH_PARENT ) window.setGravity(position.gravity) + + // Ensure complete transparency window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + window.clearFlags(FLAG_DIM_BEHIND) + + // Make sure the window has a transparent background + val attributes = window.attributes + attributes.dimAmount = 0f + window.attributes = attributes } } diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt index 2a10328b..704c7fcb 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt @@ -52,6 +52,9 @@ class KetchWebView @JvmOverloads constructor( init { webViewClient = localContentWebViewClient setupWebView() + + // Ensure the WebView background is transparent + setBackgroundColor(android.graphics.Color.TRANSPARENT) // Add JavaScript interface for communication with WebView addJavascriptInterface( From 59ffc03eb9ba4a9340696bac090896e259f3812c Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Mon, 3 Mar 2025 12:07:53 +0100 Subject: [PATCH 21/30] keeping the backward compatibility --- ketchsdk/src/main/java/com/ketch/android/Ketch.kt | 15 ++++++++++++--- .../java/com/ketch/android/ui/KetchWebView.kt | 7 +++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 2854f59f..4f56a64f 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -412,9 +412,18 @@ class Ketch private constructor( this@Ketch.listener?.onWillShowExperience(experienceType) } - override fun onPageLoaded() { - // Handle page loaded event - Log.d(TAG, "WebView page loaded") + /** + * @deprecated This method is deprecated and will be removed in a future release + */ + @Deprecated("This method is deprecated and will be removed in a future release") + override fun onTapOutside() { + // Dismiss dialog fragment + findDialogFragment()?.let { + (it as? KetchDialogFragment)?.dismissAllowingStateLoss() + + // Execute onDismiss event listener + this@Ketch.listener?.onDismiss(HideExperienceStatus.None) + } } private fun showConsentPopup() { diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt index 704c7fcb..0f0d6dbe 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt @@ -207,7 +207,6 @@ class KetchWebView @JvmOverloads constructor( if (view is KetchWebView && url == view.currentUrl && !view.isPageLoaded) { view.isPageLoaded = true - view.listener?.onPageLoaded() } Log.d(TAG, "onPageFinished: $url") } @@ -450,7 +449,11 @@ class KetchWebView @JvmOverloads constructor( fun changeDialog(display: ContentDisplay) fun onClose(status: HideExperienceStatus) fun onWillShowExperience(experienceType: WillShowExperienceType) - fun onPageLoaded() + /** + * @deprecated This method is deprecated and will be removed in a future release + */ + @Deprecated("This method is deprecated and will be removed in a future release") + fun onTapOutside() } internal enum class ExperienceType { From 025f6516603b059d0530e8da97eaad202110af2d Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Wed, 5 Mar 2025 18:54:46 -0700 Subject: [PATCH 22/30] seems to be working properly again --- .../src/main/java/com/ketch/android/Ketch.kt | 693 +++++++++++++----- .../ketch/android/ui/KetchDialogFragment.kt | 372 +++++++++- .../java/com/ketch/android/ui/KetchWebView.kt | 103 ++- 3 files changed, 935 insertions(+), 233 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 4f56a64f..63aa832e 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -2,7 +2,9 @@ package com.ketch.android import android.content.Context import android.os.Handler +import android.os.SystemClock import android.util.Log +import android.view.ViewGroup import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle @@ -15,6 +17,7 @@ import com.ketch.android.data.WillShowExperienceType import com.ketch.android.ui.KetchDialogFragment import com.ketch.android.ui.KetchWebView import java.lang.ref.WeakReference +import java.util.concurrent.atomic.AtomicBoolean /** * Main Ketch SDK class @@ -37,6 +40,10 @@ class Ketch private constructor( // WebView instance and state flags private var currentWebView: KetchWebView? = null private var isActive = false + + // Debounce mechanism to prevent rapid clicks + private var lastClickTime = 0L + private val CLICK_DEBOUNCE_TIME = 1000L // 1 second between allowed clicks /** * Retrieve a String value from the preferences. @@ -140,6 +147,72 @@ class Ketch private constructor( synchronousPreferences: Boolean = false, bottomPadding: Int = 0, ): Boolean { + // Debounce rapid clicks + val currentTime = SystemClock.elapsedRealtime() + if (currentTime - lastClickTime < CLICK_DEBOUNCE_TIME) { + Log.d(TAG, "Ignoring rapid click, debouncing for ${CLICK_DEBOUNCE_TIME}ms") + return false + } + lastClickTime = currentTime + + // Force a complete reset of all state to ensure we start clean + isActive = false + + // Check for any lingering WebViews and destroy them + synchronized(instanceLock) { + cleanupWebView() + currentWebView = null + } + + // Force cleanup any existing fragments before creating a new one - more aggressive cleanup + fragmentManager.get()?.let { fm -> + if (!fm.isDestroyed) { + // First try our normal cleanup + removeAllKetchDialogFragments(fm) + + // Then force an explicit check for any fragments that might remain + try { + val remainingFragments = fm.fragments.filterIsInstance() + if (remainingFragments.isNotEmpty()) { + Log.w(TAG, "Found ${remainingFragments.size} remaining fragments before creating dialog, forcing removal") + + // Emergency direct cleanup, fragment by fragment + remainingFragments.forEach { fragment -> + try { + // Reset touch listeners + fragment.webView?.setOnTouchListener(null) + + // Clear WebView + fragment.webView?.destroy() + fragment.webView = null + + // Force removal with individual transaction + val emergencyTransaction = fm.beginTransaction() + emergencyTransaction.remove(fragment) + emergencyTransaction.commitNowAllowingStateLoss() + fm.executePendingTransactions() + } catch (e: Exception) { + Log.e(TAG, "Error in emergency fragment cleanup: ${e.message}", e) + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error checking for remaining fragments: ${e.message}", e) + } + + // Force global reset + KetchDialogFragment.resetShowingState() + } + } + + // Wait a small amount of time to ensure cleanup completes + try { + Thread.sleep(50) + } catch (e: InterruptedException) { + // Ignore + } + + // Now create the WebView val webView = createWebView(shouldRetry, synchronousPreferences) return if (webView != null) { webView.load( @@ -177,6 +250,21 @@ class Ketch private constructor( synchronousPreferences: Boolean = false, bottomPadding: Int = 0, ): Boolean { + // Debounce rapid clicks + val currentTime = SystemClock.elapsedRealtime() + if (currentTime - lastClickTime < CLICK_DEBOUNCE_TIME) { + Log.d(TAG, "Ignoring rapid click, debouncing for ${CLICK_DEBOUNCE_TIME}ms") + return false + } + lastClickTime = currentTime + + // Force cleanup any existing fragments before creating a new one + fragmentManager.get()?.let { fm -> + if (!fm.isDestroyed) { + KetchDialogFragment.forceCleanupAllInstances(fm) + } + } + val webView = createWebView(shouldRetry, synchronousPreferences) return if (webView != null) { webView.load( @@ -265,209 +353,214 @@ class Ketch private constructor( } private fun createWebView(shouldRetry: Boolean = false, synchronousPreferences: Boolean = false): KetchWebView? { - if (isActive) { - return null - } - - val existingFragment = findDialogFragment() - if (existingFragment != null) { - if (!isActive) { - cleanupDialogFragment(forceRemove = true) - } - return null - } - - isActive = true - - try { - cleanupWebView() - - val ctx = context.get() ?: run { - isActive = false + synchronized(instanceLock) { + if (isActive) { + Log.w(TAG, "WebView creation attempted while another is active") return null } - val webView = KetchWebView(ctx) - if (shouldRetry) { - Log.d(TAG, "WebView created with retry enabled") + val existingFragment = findDialogFragment() + if (existingFragment != null) { + Log.d(TAG, "Found existing dialog fragment, cleaning up before creating new WebView") + cleanupDialogFragment(forceRemove = true) + // Return null to prevent creating a new WebView while cleanup is in progress + return null } - currentWebView = webView - - if (logLevel === LogLevel.DEBUG) { - webView.setDebugMode() - } - - webView.listener = object : KetchWebView.WebViewListener { + isActive = true - private var config: KetchConfig? = null - private var showConsent: Boolean = false + try { + // Clean up any existing WebView first + cleanupWebView() - override fun showConsent() { - if (config == null) { - showConsent = true - return - } - showConsentPopup() + val ctx = context.get() ?: run { + Log.e(TAG, "Context is null, cannot create WebView") + isActive = false + return null + } + + val webView = KetchWebView(ctx) + if (shouldRetry) { + Log.d(TAG, "WebView created with retry enabled") } + + currentWebView = webView - override fun showPreferences() { - if (!isActivityActive()) { - Log.d(TAG, "Not showing as activity is not active") - isActive = false - return - } + if (logLevel === LogLevel.DEBUG) { + webView.setDebugMode() + } - if (findDialogFragment() != null) { - Log.d(TAG, "Not showing as dialog already exists") - if (!isActive) { - isActive = true + webView.listener = object : KetchWebView.WebViewListener { + + private var config: KetchConfig? = null + private var showConsent: Boolean = false + + override fun showConsent() { + if (config == null) { + showConsent = true + return } - return - } - - val dialog = KetchDialogFragment.newInstance()?.apply { - isCancelable = !getDisposableContentInteractions() + showConsentPopup() } - fragmentManager.get()?.let { manager -> - try { - dialog?.show(manager, webView) - this@Ketch.listener?.onShow() - } catch (e: Exception) { - Log.e(TAG, "Error showing dialog: ${e.message}", e) + override fun showPreferences() { + if (!isActivityActive()) { + Log.d(TAG, "Not showing as activity is not active") isActive = false + return } - } ?: run { - isActive = false - } - - showConsent = false - } - override fun onUSPrivacyUpdated(values: Map) { - getPreferences().saveValues(values, "USPrivacy", synchronousPreferences) - this@Ketch.listener?.onUSPrivacyUpdated(values) - } + if (findDialogFragment() != null) { + Log.d(TAG, "Not showing as dialog already exists") + if (!isActive) { + isActive = true + } + return + } - override fun onTCFUpdated(values: Map) { - getPreferences().saveValues(values, "TCF", synchronousPreferences) - this@Ketch.listener?.onTCFUpdated(values) - } + val dialog = KetchDialogFragment.newInstance()?.apply { + isCancelable = !getDisposableContentInteractions() + } - override fun onGPPUpdated(values: Map) { - getPreferences().saveValues(values, "GPP", synchronousPreferences) - this@Ketch.listener?.onGPPUpdated(values) - } + fragmentManager.get()?.let { manager -> + try { + dialog?.show(manager, webView) + this@Ketch.listener?.onShow() + } catch (e: Exception) { + Log.e(TAG, "Error showing dialog: ${e.message}", e) + isActive = false + } + } ?: run { + isActive = false + } + + showConsent = false + } - override fun onConfigUpdated(config: KetchConfig?) { - this.config = config - this@Ketch.listener?.onConfigUpdated(config) + override fun onUSPrivacyUpdated(values: Map) { + getPreferences().saveValues(values, "USPrivacy", synchronousPreferences) + this@Ketch.listener?.onUSPrivacyUpdated(values) + } - if (showConsent) { - showConsentPopup() + override fun onTCFUpdated(values: Map) { + getPreferences().saveValues(values, "TCF", synchronousPreferences) + this@Ketch.listener?.onTCFUpdated(values) } - } - override fun onEnvironmentUpdated(environment: String?) { - this@Ketch.listener?.onEnvironmentUpdated(environment) - } + override fun onGPPUpdated(values: Map) { + getPreferences().saveValues(values, "GPP", synchronousPreferences) + this@Ketch.listener?.onGPPUpdated(values) + } - override fun onRegionInfoUpdated(regionInfo: String?) { - this@Ketch.listener?.onRegionInfoUpdated(regionInfo) - } + override fun onConfigUpdated(config: KetchConfig?) { + this.config = config + this@Ketch.listener?.onConfigUpdated(config) - override fun onJurisdictionUpdated(jurisdiction: String?) { - this@Ketch.listener?.onJurisdictionUpdated(jurisdiction) - } + if (showConsent) { + showConsentPopup() + } + } - override fun onIdentitiesUpdated(identities: String?) { - this@Ketch.listener?.onIdentitiesUpdated(identities) - } + override fun onEnvironmentUpdated(environment: String?) { + this@Ketch.listener?.onEnvironmentUpdated(environment) + } - override fun onConsentUpdated(consent: Consent) { - this@Ketch.listener?.onConsentUpdated(consent) - } + override fun onRegionInfoUpdated(regionInfo: String?) { + this@Ketch.listener?.onRegionInfoUpdated(regionInfo) + } - override fun onError(errMsg: String?) { - this@Ketch.listener?.onError(errMsg) - } + override fun onJurisdictionUpdated(jurisdiction: String?) { + this@Ketch.listener?.onJurisdictionUpdated(jurisdiction) + } - override fun changeDialog(display: ContentDisplay) { - findDialogFragment()?.let { - (it as? KetchDialogFragment)?.apply { - isCancelable = getDisposableContentInteractions() - } + override fun onIdentitiesUpdated(identities: String?) { + this@Ketch.listener?.onIdentitiesUpdated(identities) } - } - override fun onClose(status: HideExperienceStatus) { - cleanupDialogFragment { _ -> - this@Ketch.listener?.onDismiss(status) - isActive = false + override fun onConsentUpdated(consent: Consent) { + this@Ketch.listener?.onConsentUpdated(consent) } - } - override fun onWillShowExperience(experienceType: WillShowExperienceType) { - this@Ketch.listener?.onWillShowExperience(experienceType) - } - - /** - * @deprecated This method is deprecated and will be removed in a future release - */ - @Deprecated("This method is deprecated and will be removed in a future release") - override fun onTapOutside() { - // Dismiss dialog fragment - findDialogFragment()?.let { - (it as? KetchDialogFragment)?.dismissAllowingStateLoss() - - // Execute onDismiss event listener - this@Ketch.listener?.onDismiss(HideExperienceStatus.None) + override fun onError(errMsg: String?) { + this@Ketch.listener?.onError(errMsg) } - } - private fun showConsentPopup() { - if (!isActivityActive()) { - Log.d(TAG, "Not showing as activity is not active") - isActive = false - return + override fun changeDialog(display: ContentDisplay) { + findDialogFragment()?.let { + (it as? KetchDialogFragment)?.apply { + isCancelable = getDisposableContentInteractions() + } + } } - if (findDialogFragment() != null) { - Log.d(TAG, "Not showing as dialog already exists") - if (!isActive) { - isActive = true + override fun onClose(status: HideExperienceStatus) { + cleanupDialogFragment { _ -> + this@Ketch.listener?.onDismiss(status) + isActive = false } - return } - val dialog = KetchDialogFragment.newInstance()?.apply { - isCancelable = !getDisposableContentInteractions() + override fun onWillShowExperience(experienceType: WillShowExperienceType) { + this@Ketch.listener?.onWillShowExperience(experienceType) } - fragmentManager.get()?.let { manager -> - try { - dialog?.show(manager, webView) - this@Ketch.listener?.onShow() - } catch (e: Exception) { - Log.e(TAG, "Error showing dialog: ${e.message}", e) + /** + * @deprecated This method is deprecated and will be removed in a future release + */ + @Deprecated("This method is deprecated and will be removed in a future release") + override fun onTapOutside() { + // Dismiss dialog fragment + findDialogFragment()?.let { + (it as? KetchDialogFragment)?.dismissAllowingStateLoss() + + // Execute onDismiss event listener + this@Ketch.listener?.onDismiss(HideExperienceStatus.None) + } + } + + private fun showConsentPopup() { + if (!isActivityActive()) { + Log.d(TAG, "Not showing as activity is not active") isActive = false + return } - } ?: run { - isActive = false + + if (findDialogFragment() != null) { + Log.d(TAG, "Not showing as dialog already exists") + if (!isActive) { + isActive = true + } + return + } + + val dialog = KetchDialogFragment.newInstance()?.apply { + isCancelable = !getDisposableContentInteractions() + } + + fragmentManager.get()?.let { manager -> + try { + dialog?.show(manager, webView) + this@Ketch.listener?.onShow() + } catch (e: Exception) { + Log.e(TAG, "Error showing dialog: ${e.message}", e) + isActive = false + } + } ?: run { + isActive = false + } + + showConsent = false } - showConsent = false + private fun getDisposableContentInteractions(): Boolean = + config?.theme?.modal?.container?.backdrop?.disableContentInteractions ?: false } - - private fun getDisposableContentInteractions(): Boolean = - config?.theme?.modal?.container?.backdrop?.disableContentInteractions ?: false + return webView + } catch (e: Exception) { + Log.e(TAG, "Error creating WebView: ${e.message}", e) + isActive = false + return null } - return webView - } catch (e: Exception) { - Log.e(TAG, "Error creating WebView: ${e.message}", e) - isActive = false - return null } } @@ -589,6 +682,8 @@ class Ketch private constructor( companion object { val TAG = Ketch::class.java.simpleName + private val instanceLock = Any() + fun create( context: Context, fragmentManager: FragmentManager, @@ -613,12 +708,37 @@ class Ketch private constructor( /** * Centralized helper to clean up WebView resources */ - private fun cleanupWebView() = runCatching { - currentWebView?.destroy() - }.onFailure { - Log.e(TAG, "Error during WebView cleanup: ${it.message}", it) - }.also { - currentWebView = null + private fun cleanupWebView() = synchronized(instanceLock) { + runCatching { + Log.d(TAG, "Cleaning up WebView") + currentWebView?.let { webView -> + try { + // CRITICAL: Reset touch listener first to ensure it doesn't block touches + webView.setOnTouchListener(null) + + // Destroy the WebView + webView.destroy() + + // Clear the reference + currentWebView = null + + Log.d(TAG, "WebView cleanup completed successfully") + } catch (e: Exception) { + Log.e(TAG, "Error during WebView cleanup: ${e.message}", e) + // Even if there's an error, ensure we reset the touch listener + try { + webView.setOnTouchListener(null) + } catch (e2: Exception) { + Log.e(TAG, "Error resetting touch listener: ${e2.message}", e2) + } + } + } + }.onFailure { + Log.e(TAG, "Exception during WebView cleanup: ${it.message}", it) + // Even if there's a failure, try to reset the touch listener + currentWebView?.setOnTouchListener(null) + currentWebView = null + } } /** @@ -628,39 +748,240 @@ class Ketch private constructor( * @param onComplete Optional callback to execute after cleanup */ private fun cleanupDialogFragment(forceRemove: Boolean = false, onComplete: ((HideExperienceStatus?) -> Unit) = {}) { + synchronized(instanceLock) { + try { + Log.d(TAG, "cleanupDialogFragment: Beginning cleanup, forceRemove=$forceRemove") + + // First, reset the WebView touch listener to ensure it doesn't block touches + currentWebView?.setOnTouchListener(null) + + fragmentManager.get()?.let { fm -> + if (!fm.isDestroyed) { + // ALWAYS use force remove to ensure complete cleanup after our fixes + // This helps prevent multiple lingering fragments + removeAllKetchDialogFragments(fm) + + // Clean up WebView after fragments are removed + cleanupWebView() + + // Reset active state with a small delay to ensure fragment dismissal completes + Handler(android.os.Looper.getMainLooper()).postDelayed({ + isActive = false + + // Force reset the showing state in KetchDialogFragment + KetchDialogFragment.resetShowingState() + + // Double-check for any remaining fragments and force remove them + try { + removeAllKetchDialogFragments(fm) + } catch (e: Exception) { + Log.e(TAG, "Error in final fragment cleanup: ${e.message}", e) + } + + onComplete.invoke(null) + }, 500) // Increased delay to ensure fragments are fully dismissed + } else { + // FragmentManager is destroyed, just reset state + cleanupWebView() + isActive = false + KetchDialogFragment.resetShowingState() + onComplete.invoke(null) + } + } ?: run { + // No FragmentManager, just reset state + cleanupWebView() + isActive = false + KetchDialogFragment.resetShowingState() + onComplete.invoke(null) + } + } catch (e: Exception) { + Log.e(TAG, "Error during fragment cleanup: ${e.message}", e) + cleanupWebView() + isActive = false + KetchDialogFragment.resetShowingState() + onComplete.invoke(null) + } + } + } + + /** + * Helper method to forcefully remove all KetchDialogFragment instances + * This is more thorough than using dismiss() which might leave fragments in memory + */ + private fun removeAllKetchDialogFragments(fm: FragmentManager) { try { - val fragment = findDialogFragment() - if (fragment != null) { - // Dismiss the fragment if it's a DialogFragment - when (fragment) { - is KetchDialogFragment -> fragment.dismissAllowingStateLoss() - is DialogFragment -> fragment.dismissAllowingStateLoss() + val fragments = fm.fragments.filterIsInstance() + if (fragments.isNotEmpty()) { + Log.d(TAG, "Force removing ${fragments.size} KetchDialogFragment instances with transaction") + + // First reset touch listeners, detach from parents, and destroy WebViews + fragments.forEach { fragment -> + try { + // Reset dialog window parameters first + fragment.dialog?.window?.let { window -> + try { + // Clear any flags that might interfere with touch events + window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) + + // Reset window parameters to default + val attrs = window.attributes + attrs.flags = attrs.flags or android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL + window.attributes = attrs + + // Reset any touch interceptors + val decorView = window.decorView + decorView.setOnTouchListener(null) + } catch (e: Exception) { + Log.e(TAG, "Error resetting window parameters: ${e.message}", e) + } + } + + // Get the WebView from the fragment + fragment.webView?.let { wv -> + // Reset touch listener first + wv.setOnTouchListener(null) + + // Disable hardware acceleration + try { + wv.setLayerType(android.view.View.LAYER_TYPE_NONE, null) + } catch (e: Exception) { + Log.e(TAG, "Error disabling WebView hardware acceleration: ${e.message}", e) + } + + // Detach WebView from any parent views + (wv.parent as? ViewGroup)?.removeView(wv) + + // Clear all content + wv.loadUrl("about:blank") + + // Destroy WebView + wv.destroy() + } + + // Clear the fragment's WebView reference + fragment.webView = null + } catch (e: Exception) { + Log.e(TAG, "Error cleaning up fragment WebView: ${e.message}", e) + } } - if (forceRemove) { - fragmentManager.get()?.let { fm -> + // Then remove the fragments with a transaction and execute it immediately + val transaction = fm.beginTransaction() + fragments.forEach { fragment -> + try { + // First try dismissing the dialog to properly remove the window try { - fm.beginTransaction() - .remove(fragment) - .commitNowAllowingStateLoss() - - onComplete.invoke(null) + fragment.dismissAllowingStateLoss() } catch (e: Exception) { - Log.e(TAG, "Error forcefully removing fragment: ${e.message}", e) - onComplete.invoke(null) + Log.e(TAG, "Error dismissing fragment: ${e.message}", e) } - } ?: onComplete.invoke(null) - } else { - Handler(android.os.Looper.getMainLooper()).postDelayed({ - onComplete.invoke(null) - }, 100) + + // Then remove the fragment from the manager + transaction.remove(fragment) + } catch (e: Exception) { + Log.e(TAG, "Error removing fragment in transaction: ${e.message}", e) + } } - } else { - onComplete.invoke(null) + transaction.commitNowAllowingStateLoss() + + // Make sure the transaction is processed immediately + try { + fm.executePendingTransactions() + } catch (e: Exception) { + Log.e(TAG, "Error executing pending transactions: ${e.message}", e) + } + + // Reset the showing state + KetchDialogFragment.resetShowingState() + + // Make sure we clean up again after a delay + Handler(android.os.Looper.getMainLooper()).postDelayed({ + try { + val remainingFragments = fm.fragments.filterIsInstance() + if (remainingFragments.isNotEmpty()) { + Log.w(TAG, "Still found ${remainingFragments.size} fragments, removing again with IMMEDIATE execution") + + // More aggressive cleanup for remaining fragments + remainingFragments.forEach { fragment -> + try { + // Reset window parameters first + fragment.dialog?.window?.let { window -> + try { + window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) + val decorView = window.decorView + decorView.setOnTouchListener(null) + } catch (e: Exception) { + Log.e(TAG, "Error in delayed window parameter reset: ${e.message}", e) + } + } + + // Clear any remaining WebView + fragment.webView?.let { wv -> + wv.setOnTouchListener(null) + wv.setLayerType(android.view.View.LAYER_TYPE_NONE, null) + (wv.parent as? ViewGroup)?.removeView(wv) + wv.loadUrl("about:blank") + wv.destroy() + } + fragment.webView = null + } catch (e: Exception) { + Log.e(TAG, "Error in delayed WebView cleanup: ${e.message}", e) + } + } + + val finalTransaction = fm.beginTransaction() + remainingFragments.forEach { fragment -> + finalTransaction.remove(fragment) + } + finalTransaction.commitNowAllowingStateLoss() + + // Force immediate execution + fm.executePendingTransactions() + } + + // Clear current WebView reference to be safe + currentWebView?.let { wv -> + wv.setOnTouchListener(null) + wv.stopLoading() + } + cleanupWebView() + + // Force another check after a longer delay + Handler(android.os.Looper.getMainLooper()).postDelayed({ + val finalFragments = fm.fragments.filterIsInstance() + if (finalFragments.isNotEmpty()) { + Log.e(TAG, "CRITICAL: Still found ${finalFragments.size} fragments after multiple cleanup attempts") + + // Last-ditch attempt with detached transactions + try { + val lastTransaction = fm.beginTransaction() + finalFragments.forEach { fragment -> + // Detach first, then remove + lastTransaction.detach(fragment) + lastTransaction.remove(fragment) + } + lastTransaction.commitNowAllowingStateLoss() + fm.executePendingTransactions() + } catch (e: Exception) { + Log.e(TAG, "Error in final fragment cleanup attempt: ${e.message}", e) + } + + // Force showing state reset + KetchDialogFragment.resetShowingState() + } + + // Suggest garbage collection + System.gc() + + }, 500) // Final check after 500ms + + } catch (e: Exception) { + Log.e(TAG, "Error in delayed fragment cleanup: ${e.message}", e) + } + }, 300) } } catch (e: Exception) { - Log.e(TAG, "Error during fragment cleanup: ${e.message}", e) - onComplete.invoke(null) + Log.e(TAG, "Error removing KetchDialogFragments: ${e.message}", e) } } } diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt index 24b48c99..ccd6c850 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt @@ -7,6 +7,7 @@ import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.os.Handler import android.os.Looper +import android.os.SystemClock import android.util.Log import android.view.Gravity import android.view.LayoutInflater @@ -19,12 +20,23 @@ import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager import com.ketch.android.R import com.ketch.android.databinding.KetchDialogLayoutBinding +import java.util.concurrent.atomic.AtomicBoolean internal class KetchDialogFragment() : DialogFragment() { private lateinit var binding: KetchDialogLayoutBinding - private var webView: KetchWebView? = null + // Change to internal access to allow direct manipulation from Ketch.kt + internal var webView: KetchWebView? = null + + // Keep track of whether this instance is being destroyed + private var isBeingDestroyed = false + + // Add a safety touch blocker for the main view + private val transparentTouchListener = View.OnTouchListener { _, _ -> + // Return false to NOT block touches + false + } // Define the position property with a default value private val position: DialogPosition = DialogPosition.CENTER @@ -79,17 +91,70 @@ internal class KetchDialogFragment() : DialogFragment() { override fun onDestroyView() { try { Log.d(TAG, "onDestroyView: Beginning WebView cleanup") + + // Mark as being destroyed to prevent reuse + isBeingDestroyed = true + + // Set the transparent touch listener on the root view + binding.root.setOnTouchListener(transparentTouchListener) + + // Make sure the dialog window is not capturing touches + dialog?.window?.let { window -> + try { + // Clear any flags that might interfere with touch events + window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) + + // Reset any touch interceptors + val decorView = window.decorView + decorView.setOnTouchListener(null) + } catch (e: Exception) { + Log.e(TAG, "Error resetting window touch properties: ${e.message}", e) + } + } + webView?.let { wv -> - // Disable interaction during cleanup - wv.setOnTouchListener { _, _ -> true } + // IMPORTANT: Set touch listener to null instead of blocking touches + wv.setOnTouchListener(null) + + // Stop any ongoing loads + wv.stopLoading() + + // Disable hardware acceleration + try { + wv.setLayerType(View.LAYER_TYPE_NONE, null) + } catch (e: Exception) { + Log.e(TAG, "Error disabling hardware acceleration: ${e.message}", e) + } + + // Clear content + try { + wv.loadUrl("about:blank") + } catch (e: Exception) { + Log.e(TAG, "Error loading blank page: ${e.message}", e) + } // Remove from view hierarchy - (wv.parent as? ViewGroup)?.removeView(wv) + try { + (wv.parent as? ViewGroup)?.removeView(wv) + } catch (e: Exception) { + Log.e(TAG, "Error removing WebView from parent: ${e.message}", e) + } + binding.root.removeView(wv) + + // Destroy the WebView + wv.destroy() + + // Clear the reference + webView = null } - webView = null } catch (e: Exception) { Log.e(TAG, "Error cleaning up WebView: ${e.message}", e) + // Even if we fail, try to ensure WebView is not blocking touches + webView?.setOnTouchListener(null) + } finally { + // Always reset the showing state when view is destroyed + isCurrentlyShowing.set(false) } super.onDestroyView() @@ -125,25 +190,67 @@ internal class KetchDialogFragment() : DialogFragment() { // Make sure the window has a transparent background val attributes = window.attributes attributes.dimAmount = 0f + + // Ensure we don't interfere with app touches after close + attributes.flags = attributes.flags or android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL + window.attributes = attributes } } fun show(manager: FragmentManager, webView: KetchWebView) { + if (manager.isDestroyed) { + Log.e(TAG, "Cannot show dialog: FragmentManager is destroyed") + isCurrentlyShowing.set(false) + return + } + if (!isAdded) { try { - // Check for existing fragments - manager.findFragmentByTag(TAG)?.let { existingFragment -> - manager.beginTransaction() - .remove(existingFragment) - .commitNowAllowingStateLoss() + // Use synchronized block to prevent race conditions + synchronized(LOCK) { + if (!isCurrentlyShowing.get()) { + isCurrentlyShowing.set(true) + + // Check for existing fragments and remove them + val existingFragments = manager.fragments.filterIsInstance() + if (existingFragments.isNotEmpty()) { + try { + // Remove all existing fragments + val transaction = manager.beginTransaction() + existingFragments.forEach { fragment -> + try { + if (fragment is DialogFragment) { + fragment.dismissAllowingStateLoss() + } + transaction.remove(fragment) + } catch (e: Exception) { + Log.e(TAG, "Error removing existing fragment: ${e.message}", e) + } + } + transaction.commitNowAllowingStateLoss() + + // Small delay to ensure fragments are fully removed + Handler(Looper.getMainLooper()).postDelayed({ + this.webView = webView + super.show(manager, TAG) + }, 200) + } catch (e: Exception) { + Log.e(TAG, "Error removing existing fragments: ${e.message}", e) + isCurrentlyShowing.set(false) + } + } else { + // No existing fragment found, show directly + this.webView = webView + super.show(manager, TAG) + } + } else { + Log.w(TAG, "Dialog already showing, ignoring request") + } } - - this.webView = webView - super.show(manager, TAG) } catch (e: Exception) { Log.e(TAG, "Error showing dialog: ${e.message}", e) - isCurrentlyShowing = false // Reset flag if we failed to show + isCurrentlyShowing.set(false) } } } @@ -160,7 +267,23 @@ internal class KetchDialogFragment() : DialogFragment() { override fun onDestroy() { super.onDestroy() - isCurrentlyShowing = false + isCurrentlyShowing.set(false) + + // Ensure WebView is properly destroyed + webView?.let { wv -> + try { + // Disable interaction during cleanup + wv.setOnTouchListener(null) + + // Destroy the WebView + wv.destroy() + + // Clear the reference + webView = null + } catch (e: Exception) { + Log.e(TAG, "Error destroying WebView in onDestroy: ${e.message}", e) + } + } } // Helper methods @@ -183,27 +306,236 @@ internal class KetchDialogFragment() : DialogFragment() { private fun prepareForDismissal() { try { - // Detach WebView from touch events during dismissal - webView?.setOnTouchListener { _, _ -> true } + Log.d(TAG, "prepareForDismissal: Beginning cleanup") + + // Mark as being destroyed to prevent reuse + isBeingDestroyed = true + + // Set the transparent touch listener on the root view + binding.root.setOnTouchListener(transparentTouchListener) + + // CRITICAL: Reset touch listener to null first + webView?.setOnTouchListener(null) + + // Stop any ongoing loads + webView?.stopLoading() + + // Clear content + try { + webView?.loadUrl("about:blank") + } catch (e: Exception) { + Log.e(TAG, "Error loading blank page: ${e.message}", e) + } + + // Ensure we reset the showing flag + isCurrentlyShowing.set(false) + + // Remove WebView from parent + try { + (webView?.parent as? ViewGroup)?.removeView(webView) + } catch (e: Exception) { + Log.e(TAG, "Error removing WebView from parent: ${e.message}", e) + } + + // Destroy the WebView + webView?.destroy() + webView = null + + // Force a reset of the showing state + resetShowingState() } catch (e: Exception) { Log.e(TAG, "Error preparing for dismissal: ${e.message}", e) + // Even if there's an error, ensure we reset the state + isCurrentlyShowing.set(false) + } finally { + // Final safety check to ensure touch events aren't blocked + try { + webView?.setOnTouchListener(null) + } catch (e: Exception) { + Log.e(TAG, "Final touch reset failed: ${e.message}", e) + } } } companion object { internal val TAG = KetchDialogFragment::class.java.simpleName - @Volatile private var isCurrentlyShowing = false + private val isCurrentlyShowing = AtomicBoolean(false) + private val LOCK = Any() + + // Add a debounce mechanism + private var lastShowTime = 0L + private const val SHOW_DEBOUNCE_TIME = 1000L // 1 second between allowed shows @Synchronized fun newInstance(): KetchDialogFragment? { + // Debounce rapid creation attempts + val currentTime = SystemClock.elapsedRealtime() + if (currentTime - lastShowTime < SHOW_DEBOUNCE_TIME) { + Log.d(TAG, "Ignoring rapid fragment creation, debouncing for ${SHOW_DEBOUNCE_TIME}ms") + return null + } + lastShowTime = currentTime + // Only allow ONE instance at a time - return if (!isCurrentlyShowing) { - isCurrentlyShowing = true + return if (!isCurrentlyShowing.get()) { + isCurrentlyShowing.set(true) KetchDialogFragment() } else { Log.w(TAG, "DialogFragment already showing, ignoring request") null } } + + /** + * Force reset the showing state - should only be used in emergency cleanup situations + */ + @Synchronized + fun resetShowingState() { + isCurrentlyShowing.set(false) + } + + /** + * Force cleanup all KetchDialogFragment instances from a FragmentManager + */ + @Synchronized + fun forceCleanupAllInstances(fragmentManager: FragmentManager) { + if (fragmentManager.isDestroyed) { + Log.e(TAG, "Cannot cleanup: FragmentManager is destroyed") + isCurrentlyShowing.set(false) + return + } + + try { + val existingFragments = fragmentManager.fragments.filterIsInstance() + if (existingFragments.isNotEmpty()) { + Log.d(TAG, "Force cleaning up ${existingFragments.size} dialog fragments") + + // First reset all touch listeners and clean up WebViews + existingFragments.forEach { fragment -> + try { + // Mark as being destroyed + fragment.isBeingDestroyed = true + + // Ensure root view doesn't block touches + if (::binding.isInitialized) { + fragment.binding.root.setOnTouchListener(fragment.transparentTouchListener) + } + + // Ensure WebView is properly destroyed + fragment.webView?.let { wv -> + try { + // Reset touch listener first to prevent blocking touches + wv.setOnTouchListener(null) + + // Stop any loading + wv.stopLoading() + + // Clear content + wv.loadUrl("about:blank") + + // Remove from parent + (wv.parent as? ViewGroup)?.removeView(wv) + + // Destroy the WebView + wv.destroy() + } catch (e: Exception) { + Log.e(TAG, "Error destroying WebView during cleanup: ${e.message}", e) + } + } + + // Clear WebView reference + fragment.webView = null + } catch (e: Exception) { + Log.e(TAG, "Error cleaning up fragment WebView: ${e.message}", e) + } + } + + // Directly remove them in a transaction without dismissing first + // This is more thorough than dismissAllowingStateLoss() + val transaction = fragmentManager.beginTransaction() + existingFragments.forEach { fragment -> + try { + // Detach first to ensure view is removed from UI + transaction.detach(fragment) + transaction.remove(fragment) + } catch (e: Exception) { + Log.e(TAG, "Error removing fragment: ${e.message}", e) + } + } + transaction.commitNowAllowingStateLoss() + + // Force immediate execution + try { + fragmentManager.executePendingTransactions() + } catch (e: Exception) { + Log.e(TAG, "Error executing pending transactions: ${e.message}", e) + } + + // Reset the showing state + isCurrentlyShowing.set(false) + + // Add a small delay to ensure the UI thread has time to process the changes + Handler(Looper.getMainLooper()).postDelayed({ + // Double-check that all fragments are gone + val remainingFragments = fragmentManager.fragments.filterIsInstance() + if (remainingFragments.isNotEmpty()) { + Log.w(TAG, "Found ${remainingFragments.size} remaining fragments after cleanup, forcing removal") + try { + // One more attempt with detach-then-remove approach + val finalTransaction = fragmentManager.beginTransaction() + remainingFragments.forEach { fragment -> + // Set touch listeners to null to be extra safe + fragment.webView?.setOnTouchListener(null) + if (::binding.isInitialized) { + fragment.binding.root.setOnTouchListener(fragment.transparentTouchListener) + } + + // Detach first, then remove + finalTransaction.detach(fragment) + finalTransaction.remove(fragment) + } + finalTransaction.commitNowAllowingStateLoss() + fragmentManager.executePendingTransactions() + + // One final check for any stubborn fragments + Handler(Looper.getMainLooper()).postDelayed({ + val stubbornFragments = fragmentManager.fragments.filterIsInstance() + if (stubbornFragments.isNotEmpty()) { + Log.e(TAG, "CRITICAL: Still found fragments after multiple cleanup attempts!") + + // Last-ditch attempt with individual transactions + stubbornFragments.forEach { fragment -> + try { + val emergencyTransaction = fragmentManager.beginTransaction() + emergencyTransaction.detach(fragment) + emergencyTransaction.remove(fragment) + emergencyTransaction.commitNowAllowingStateLoss() + fragmentManager.executePendingTransactions() + } catch (e: Exception) { + Log.e(TAG, "Error in emergency fragment removal: ${e.message}", e) + } + } + } + + // Suggest garbage collection + System.gc() + }, 200) + } catch (e: Exception) { + Log.e(TAG, "Error in final fragment cleanup: ${e.message}", e) + } + } + + // Ensure the showing state is reset + isCurrentlyShowing.set(false) + }, 500) // Increased delay for thorough cleanup + } else { + // No fragments to clean up, just reset the state + isCurrentlyShowing.set(false) + } + } catch (e: Exception) { + Log.e(TAG, "Error during force cleanup: ${e.message}", e) + isCurrentlyShowing.set(false) + } + } } } \ No newline at end of file diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt index 0f0d6dbe..ed72b51b 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt @@ -55,6 +55,12 @@ class KetchWebView @JvmOverloads constructor( // Ensure the WebView background is transparent setBackgroundColor(android.graphics.Color.TRANSPARENT) + + // Configure hardware acceleration properly + setLayerType(LAYER_TYPE_HARDWARE, null) + + // Ensure that the WebView renders properly + setWillNotDraw(false) // Add JavaScript interface for communication with WebView addJavascriptInterface( @@ -98,61 +104,104 @@ class KetchWebView @JvmOverloads constructor( // Properly clean up WebView resources to prevent memory leaks and renderer crashes override fun destroy() { try { - Log.d(TAG, "Beginning WebView cleanup") + Log.d(TAG, "Beginning WebView destroy") + + // CRITICAL: Reset touch listener FIRST to ensure touches pass through + // This must be the first operation to guarantee it happens even if other steps fail + setOnTouchListener(null) + + // Disable hardware acceleration which can cause blocking issues + try { + setLayerType(LAYER_TYPE_NONE, null) + } catch (e: Exception) { + Log.e(TAG, "Error disabling hardware acceleration: ${e.message}") + } // Prevent further page loads stopLoading() // Add a blank/empty handler for JS errors during cleanup - evaluateJavascript("window.onerror = function(message, url, line, column, error) { return true; };", null) + try { + evaluateJavascript("window.onerror = function(message, url, line, column, error) { return true; };", null) + } catch (e: Exception) { + Log.e(TAG, "Error setting JS error handler: ${e.message}") + } // Remove JavaScript interface first to prevent any further callbacks - removeJavascriptInterface("androidListener") + try { + removeJavascriptInterface("androidListener") + } catch (e: Exception) { + Log.e(TAG, "Error removing JS interface: ${e.message}") + } // Set listener to null to prevent callbacks during cleanup listener = null // Cancel all coroutines next - localContentWebViewClient.cancelCoroutines() + try { + localContentWebViewClient.cancelCoroutines() + } catch (e: Exception) { + Log.e(TAG, "Error cancelling coroutines: ${e.message}") + } // Disable JavaScript to prevent further execution - settings.javaScriptEnabled = false + try { + settings.javaScriptEnabled = false + } catch (e: Exception) { + Log.e(TAG, "Error disabling JavaScript: ${e.message}") + } // Stop any ongoing loads or processing - onPause() - - // Small pause to allow WebView internals to stabilize try { - Thread.sleep(50) - } catch (e: InterruptedException) { - Log.e(TAG, "Sleep interrupted during WebView cleanup", e) + onPause() + } catch (e: Exception) { + Log.e(TAG, "Error pausing WebView: ${e.message}") } // Clear WebView state - clearHistory() - clearCache(true) - clearFormData() - clearSslPreferences() + try { + clearHistory() + clearCache(true) + clearFormData() + clearSslPreferences() + } catch (e: Exception) { + Log.e(TAG, "Error clearing WebView state: ${e.message}") + } // Remove all views - removeAllViews() + try { + removeAllViews() + } catch (e: Exception) { + Log.e(TAG, "Error removing views: ${e.message}") + } // Set a low global layout limit to reduce memory pressure - // This is a common practice for WebView cleanup to ensure the view doesn't - // maintain large layout allocations while waiting for destruction - setLayoutParams( - ViewGroup.LayoutParams(1, 1) - ) + try { + setLayoutParams( + ViewGroup.LayoutParams(1, 1) + ) + } catch (e: Exception) { + Log.e(TAG, "Error setting layout params: ${e.message}") + } // Finally call the parent WebView's destroy method - super.destroy() - - // Suggest garbage collection to reclaim memory - Runtime.getRuntime().gc() + try { + super.destroy() + } catch (e: Exception) { + Log.e(TAG, "Error in super.destroy(): ${e.message}") + } - Log.d(TAG, "WebView cleanup completed successfully") + Log.d(TAG, "WebView destroy completed successfully") } catch (e: Exception) { - Log.e(TAG, "Error during WebView cleanup: ${e.message}", e) + Log.e(TAG, "Error during WebView destroy: ${e.message}", e) + } finally { + // CRITICAL: Reset touch listener AGAIN in finally block to ensure it happens + // This is our last line of defense to prevent touch blocking + try { + setOnTouchListener(null) + } catch (e: Exception) { + Log.e(TAG, "Final attempt to reset touch listener failed: ${e.message}", e) + } } } From 2cde78ddce850b87c71ec7c2161e2724b8111b81 Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Wed, 5 Mar 2025 20:22:15 -0700 Subject: [PATCH 23/30] update --- .../src/main/java/com/ketch/android/Ketch.kt | 780 ++++-------------- .../main/java/com/ketch/android/data/Index.kt | 4 +- .../ketch/android/ui/KetchDialogFragment.kt | 488 +---------- .../java/com/ketch/android/ui/KetchWebView.kt | 212 ++--- 4 files changed, 262 insertions(+), 1222 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 63aa832e..a34046bd 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -1,11 +1,7 @@ package com.ketch.android import android.content.Context -import android.os.Handler -import android.os.SystemClock import android.util.Log -import android.view.ViewGroup -import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -17,7 +13,6 @@ import com.ketch.android.data.WillShowExperienceType import com.ketch.android.ui.KetchDialogFragment import com.ketch.android.ui.KetchWebView import java.lang.ref.WeakReference -import java.util.concurrent.atomic.AtomicBoolean /** * Main Ketch SDK class @@ -36,40 +31,38 @@ class Ketch private constructor( private var language: String? = null private var jurisdiction: String? = null private var region: String? = null - - // WebView instance and state flags - private var currentWebView: KetchWebView? = null - private var isActive = false - - // Debounce mechanism to prevent rapid clicks - private var lastClickTime = 0L - private val CLICK_DEBOUNCE_TIME = 1000L // 1 second between allowed clicks /** * Retrieve a String value from the preferences. * * @param key The name of the preference to retrieve. + * * @return Returns the preference value if it exists */ fun getSavedString(key: String) = getPreferences().getSavedValue(key) /** * Retrieve IABTCF_TCString value from the preferences. + * * @return Returns the preference value if it exists */ fun getTCFTCString() = getPreferences().getSavedValue(KetchSharedPreferences.IAB_TCF_TC_STRING) /** * Retrieve IABUSPrivacy_String value from the preferences. + * * @return Returns the preference value if it exists */ - fun getUSPrivacyString() = getPreferences().getSavedValue(KetchSharedPreferences.IAB_US_PRIVACY_STRING) + fun getUSPrivacyString() = + getPreferences().getSavedValue(KetchSharedPreferences.IAB_US_PRIVACY_STRING) /** * Retrieve IABGPP_HDR_GppString value from the preferences. + * * @return Returns the preference value if it exists */ - fun getGPPHDRGppString() = getPreferences().getSavedValue(KetchSharedPreferences.IAB_GPP_HDR_GPP_STRING) + fun getGPPHDRGppString() = + getPreferences().getSavedValue(KetchSharedPreferences.IAB_GPP_HDR_GPP_STRING) /** * Loads a web page and shows a popup if necessary @@ -147,72 +140,6 @@ class Ketch private constructor( synchronousPreferences: Boolean = false, bottomPadding: Int = 0, ): Boolean { - // Debounce rapid clicks - val currentTime = SystemClock.elapsedRealtime() - if (currentTime - lastClickTime < CLICK_DEBOUNCE_TIME) { - Log.d(TAG, "Ignoring rapid click, debouncing for ${CLICK_DEBOUNCE_TIME}ms") - return false - } - lastClickTime = currentTime - - // Force a complete reset of all state to ensure we start clean - isActive = false - - // Check for any lingering WebViews and destroy them - synchronized(instanceLock) { - cleanupWebView() - currentWebView = null - } - - // Force cleanup any existing fragments before creating a new one - more aggressive cleanup - fragmentManager.get()?.let { fm -> - if (!fm.isDestroyed) { - // First try our normal cleanup - removeAllKetchDialogFragments(fm) - - // Then force an explicit check for any fragments that might remain - try { - val remainingFragments = fm.fragments.filterIsInstance() - if (remainingFragments.isNotEmpty()) { - Log.w(TAG, "Found ${remainingFragments.size} remaining fragments before creating dialog, forcing removal") - - // Emergency direct cleanup, fragment by fragment - remainingFragments.forEach { fragment -> - try { - // Reset touch listeners - fragment.webView?.setOnTouchListener(null) - - // Clear WebView - fragment.webView?.destroy() - fragment.webView = null - - // Force removal with individual transaction - val emergencyTransaction = fm.beginTransaction() - emergencyTransaction.remove(fragment) - emergencyTransaction.commitNowAllowingStateLoss() - fm.executePendingTransactions() - } catch (e: Exception) { - Log.e(TAG, "Error in emergency fragment cleanup: ${e.message}", e) - } - } - } - } catch (e: Exception) { - Log.e(TAG, "Error checking for remaining fragments: ${e.message}", e) - } - - // Force global reset - KetchDialogFragment.resetShowingState() - } - } - - // Wait a small amount of time to ensure cleanup completes - try { - Thread.sleep(50) - } catch (e: InterruptedException) { - // Ignore - } - - // Now create the WebView val webView = createWebView(shouldRetry, synchronousPreferences) return if (webView != null) { webView.load( @@ -250,21 +177,6 @@ class Ketch private constructor( synchronousPreferences: Boolean = false, bottomPadding: Int = 0, ): Boolean { - // Debounce rapid clicks - val currentTime = SystemClock.elapsedRealtime() - if (currentTime - lastClickTime < CLICK_DEBOUNCE_TIME) { - Log.d(TAG, "Ignoring rapid click, debouncing for ${CLICK_DEBOUNCE_TIME}ms") - return false - } - lastClickTime = currentTime - - // Force cleanup any existing fragments before creating a new one - fragmentManager.get()?.let { fm -> - if (!fm.isDestroyed) { - KetchDialogFragment.forceCleanupAllInstances(fm) - } - } - val webView = createWebView(shouldRetry, synchronousPreferences) return if (webView != null) { webView.load( @@ -292,55 +204,56 @@ class Ketch private constructor( * Dismiss the dialog */ fun dismissDialog() { - cleanupDialogFragment { status -> - this@Ketch.listener?.onDismiss(status ?: HideExperienceStatus.None) + findDialogFragment()?.let { + (it as? KetchDialogFragment)?.dismissAllowingStateLoss() + this@Ketch.listener?.onDismiss(HideExperienceStatus.None) } } /** * Set identities + * * @param identities: Map */ - fun setIdentities(identities: Map) { this.identities = identities } + fun setIdentities(identities: Map) { + this.identities = identities + } /** * Set the language + * * @param language: a language name (EN, FR, etc.) */ - fun setLanguage(language: String?) { this.language = language } + fun setLanguage(language: String?) { + this.language = language + } /** * Set the jurisdiction + * * @param jurisdiction: the jurisdiction value */ - fun setJurisdiction(jurisdiction: String?) { this.jurisdiction = jurisdiction } + fun setJurisdiction(jurisdiction: String?) { + this.jurisdiction = jurisdiction + } /** * Set Region + * * @param region: the region name */ - fun setRegion(region: String?) { this.region = region } + fun setRegion(region: String?) { + this.region = region + } init { + getPreferences() - fragmentManager.get()?.let { fm -> - try { - val initialExistingDialog = fm.findFragmentByTag(KetchDialogFragment.TAG) - if (initialExistingDialog != null) { - cleanupDialogFragment(forceRemove = true) { _ -> - this@Ketch.listener?.onDismiss(HideExperienceStatus.None) - } - } else { - // No existing dialog to clean up - null - } - } catch (e: Exception) { - Log.e(TAG, "Error cleaning up existing dialogs: ${e.message}", e) - } + findDialogFragment()?.let { dialog -> + (dialog as KetchDialogFragment).dismissAllowingStateLoss() + this@Ketch.listener?.onDismiss(HideExperienceStatus.None) } - - isActive = false } // Get the singleton KetchSharedPreferences object @@ -353,247 +266,174 @@ class Ketch private constructor( } private fun createWebView(shouldRetry: Boolean = false, synchronousPreferences: Boolean = false): KetchWebView? { - synchronized(instanceLock) { - if (isActive) { - Log.w(TAG, "WebView creation attempted while another is active") - return null - } - - val existingFragment = findDialogFragment() - if (existingFragment != null) { - Log.d(TAG, "Found existing dialog fragment, cleaning up before creating new WebView") - cleanupDialogFragment(forceRemove = true) - // Return null to prevent creating a new WebView while cleanup is in progress - return null + + val webView = context.get()?.let { KetchWebView(it, shouldRetry) } ?: return null + + // Enable debug mode + if (logLevel === LogLevel.DEBUG) { + webView.setDebugMode() + } + + webView.listener = object : KetchWebView.WebViewListener { + + private var config: KetchConfig? = null + private var showConsent: Boolean = false + + override fun showConsent() { + if (config == null) { + showConsent = true + return + } + showConsentPopup() } - - isActive = true - - try { - // Clean up any existing WebView first - cleanupWebView() - - val ctx = context.get() ?: run { - Log.e(TAG, "Context is null, cannot create WebView") - isActive = false - return null + + override fun showPreferences() { + if (!isActivityActive()) { + Log.d(TAG, "Not showing as activity is not active") + return } - - val webView = KetchWebView(ctx) - if (shouldRetry) { - Log.d(TAG, "WebView created with retry enabled") + + if (findDialogFragment() != null) { + Log.d(TAG, "Not showing as dialog already exists") + return } - - currentWebView = webView - if (logLevel === LogLevel.DEBUG) { - webView.setDebugMode() + val dialog = KetchDialogFragment.newInstance() + + fragmentManager.get()?.let { + dialog.show(it, webView) + this@Ketch.listener?.onShow() } + } - webView.listener = object : KetchWebView.WebViewListener { - - private var config: KetchConfig? = null - private var showConsent: Boolean = false - - override fun showConsent() { - if (config == null) { - showConsent = true - return - } - showConsentPopup() - } + override fun onUSPrivacyUpdated(values: Map) { + getPreferences().saveValues(values, "USPrivacy", synchronousPreferences) + this@Ketch.listener?.onUSPrivacyUpdated(values) + } - override fun showPreferences() { - if (!isActivityActive()) { - Log.d(TAG, "Not showing as activity is not active") - isActive = false - return - } - - if (findDialogFragment() != null) { - Log.d(TAG, "Not showing as dialog already exists") - if (!isActive) { - isActive = true - } - return - } - - val dialog = KetchDialogFragment.newInstance()?.apply { - isCancelable = !getDisposableContentInteractions() - } - - fragmentManager.get()?.let { manager -> - try { - dialog?.show(manager, webView) - this@Ketch.listener?.onShow() - } catch (e: Exception) { - Log.e(TAG, "Error showing dialog: ${e.message}", e) - isActive = false - } - } ?: run { - isActive = false - } - - showConsent = false - } + override fun onTCFUpdated(values: Map) { + getPreferences().saveValues(values, "TCF", synchronousPreferences) + this@Ketch.listener?.onTCFUpdated(values) + } - override fun onUSPrivacyUpdated(values: Map) { - getPreferences().saveValues(values, "USPrivacy", synchronousPreferences) - this@Ketch.listener?.onUSPrivacyUpdated(values) - } + override fun onGPPUpdated(values: Map) { + getPreferences().saveValues(values, "GPP", synchronousPreferences) + this@Ketch.listener?.onGPPUpdated(values) + } - override fun onTCFUpdated(values: Map) { - getPreferences().saveValues(values, "TCF", synchronousPreferences) - this@Ketch.listener?.onTCFUpdated(values) - } + override fun onConfigUpdated(config: KetchConfig?) { + // Set internal config field + this.config = config - override fun onGPPUpdated(values: Map) { - getPreferences().saveValues(values, "GPP", synchronousPreferences) - this@Ketch.listener?.onGPPUpdated(values) - } + // Call config update listener + this@Ketch.listener?.onConfigUpdated(config) - override fun onConfigUpdated(config: KetchConfig?) { - this.config = config - this@Ketch.listener?.onConfigUpdated(config) + if (!showConsent) { + return + } + showConsentPopup() + } - if (showConsent) { - showConsentPopup() - } - } + override fun onEnvironmentUpdated(environment: String?) { + this@Ketch.listener?.onEnvironmentUpdated(environment) + } - override fun onEnvironmentUpdated(environment: String?) { - this@Ketch.listener?.onEnvironmentUpdated(environment) - } + override fun onRegionInfoUpdated(regionInfo: String?) { + this@Ketch.listener?.onRegionInfoUpdated(regionInfo) + } - override fun onRegionInfoUpdated(regionInfo: String?) { - this@Ketch.listener?.onRegionInfoUpdated(regionInfo) - } + override fun onJurisdictionUpdated(jurisdiction: String?) { + this@Ketch.listener?.onJurisdictionUpdated(jurisdiction) + } - override fun onJurisdictionUpdated(jurisdiction: String?) { - this@Ketch.listener?.onJurisdictionUpdated(jurisdiction) - } + override fun onIdentitiesUpdated(identities: String?) { + this@Ketch.listener?.onIdentitiesUpdated(identities) + } - override fun onIdentitiesUpdated(identities: String?) { - this@Ketch.listener?.onIdentitiesUpdated(identities) - } + override fun onConsentUpdated(consent: Consent) { + this@Ketch.listener?.onConsentUpdated(consent) + } - override fun onConsentUpdated(consent: Consent) { - this@Ketch.listener?.onConsentUpdated(consent) - } + override fun onError(errMsg: String?) { + this@Ketch.listener?.onError(errMsg) + } - override fun onError(errMsg: String?) { - this@Ketch.listener?.onError(errMsg) + override fun changeDialog(display: ContentDisplay) { + findDialogFragment()?.let { + (it as? KetchDialogFragment)?.apply { + isCancelable = getDisposableContentInteractions(display) } + } + } - override fun changeDialog(display: ContentDisplay) { - findDialogFragment()?.let { - (it as? KetchDialogFragment)?.apply { - isCancelable = getDisposableContentInteractions() - } - } - } + override fun onClose(status: HideExperienceStatus) { + // Dismiss dialog fragment + findDialogFragment()?.let { + (it as? KetchDialogFragment)?.dismissAllowingStateLoss() + } - override fun onClose(status: HideExperienceStatus) { - cleanupDialogFragment { _ -> - this@Ketch.listener?.onDismiss(status) - isActive = false - } - } + // Execute onDismiss event listener + this@Ketch.listener?.onDismiss(status) + } - override fun onWillShowExperience(experienceType: WillShowExperienceType) { - this@Ketch.listener?.onWillShowExperience(experienceType) - } - - /** - * @deprecated This method is deprecated and will be removed in a future release - */ - @Deprecated("This method is deprecated and will be removed in a future release") - override fun onTapOutside() { - // Dismiss dialog fragment - findDialogFragment()?.let { - (it as? KetchDialogFragment)?.dismissAllowingStateLoss() - - // Execute onDismiss event listener - this@Ketch.listener?.onDismiss(HideExperienceStatus.None) - } - } + override fun onWillShowExperience(experienceType: WillShowExperienceType) { + // Execute onWillShowExperience listener + this@Ketch.listener?.onWillShowExperience(experienceType) + } - private fun showConsentPopup() { - if (!isActivityActive()) { - Log.d(TAG, "Not showing as activity is not active") - isActive = false - return - } - - if (findDialogFragment() != null) { - Log.d(TAG, "Not showing as dialog already exists") - if (!isActive) { - isActive = true - } - return - } - - val dialog = KetchDialogFragment.newInstance()?.apply { - isCancelable = !getDisposableContentInteractions() - } - - fragmentManager.get()?.let { manager -> - try { - dialog?.show(manager, webView) - this@Ketch.listener?.onShow() - } catch (e: Exception) { - Log.e(TAG, "Error showing dialog: ${e.message}", e) - isActive = false - } - } ?: run { - isActive = false - } - - showConsent = false - } - - private fun getDisposableContentInteractions(): Boolean = - config?.theme?.modal?.container?.backdrop?.disableContentInteractions ?: false + override fun onTapOutside() { + // Dismiss dialog fragment + findDialogFragment()?.let { + (it as? KetchDialogFragment)?.dismissAllowingStateLoss() + + // Execute onDismiss event listener + this@Ketch.listener?.onDismiss(HideExperienceStatus.None) } - return webView - } catch (e: Exception) { - Log.e(TAG, "Error creating WebView: ${e.message}", e) - isActive = false - return null } - } - } - - /** - * Helper method to show a dialog with the WebView - */ - private fun showDialogWithWebView( - webView: KetchWebView - ) { - // Since we can't access the config property from the WebViewListener, - // we'll use a default value for disableContentInteractions - val disableContentInteractions = false - - val dialog = KetchDialogFragment.newInstance()?.apply { - isCancelable = !disableContentInteractions - } - - fragmentManager.get()?.let { manager -> - try { - dialog?.show(manager, webView) - this@Ketch.listener?.onShow() - } catch (e: Exception) { - Log.e(TAG, "Error showing dialog: ${e.message}", e) - isActive = false + + private fun showConsentPopup() { + if (!isActivityActive()) { + Log.d(TAG, "Not showing as activity is not active") + return + } + + if (findDialogFragment() != null) { + Log.d(TAG, "Not showing as dialog already exists") + return + } + + val dialog = KetchDialogFragment.newInstance().apply { + val disableContentInteractions = getDisposableContentInteractions( + config?.experiences?.consent?.display ?: ContentDisplay.Banner + ) + isCancelable = !disableContentInteractions + } + fragmentManager.get()?.let { + dialog.show(it, webView) + this@Ketch.listener?.onShow() + } + showConsent = false } - } ?: run { - isActive = false + + private fun getDisposableContentInteractions(display: ContentDisplay): Boolean = + config?.let { + if (display == ContentDisplay.Modal) { + it.theme?.modal?.container?.backdrop?.disableContentInteractions == true + } else if (display == ContentDisplay.Banner) { + it.theme?.modal?.container?.backdrop?.disableContentInteractions == true + } else false + } ?: false } + return webView } - private fun findDialogFragment() = fragmentManager.get()?.findFragmentByTag(KetchDialogFragment.TAG) + private fun findDialogFragment() = + fragmentManager.get()?.findFragmentByTag(KetchDialogFragment.TAG) - private fun isActivityActive() = (context.get() as? LifecycleOwner)?.lifecycle?.currentState?.isAtLeast(Lifecycle.State.STARTED) ?: false + private fun isActivityActive(): Boolean { + return (context.get() as? LifecycleOwner)?.lifecycle?.currentState?.isAtLeast(Lifecycle.State.STARTED) + ?: false + } enum class PreferencesTab { OVERVIEW, @@ -682,8 +522,6 @@ class Ketch private constructor( companion object { val TAG = Ketch::class.java.simpleName - private val instanceLock = Any() - fun create( context: Context, fragmentManager: FragmentManager, @@ -704,284 +542,4 @@ class Ketch private constructor( logLevel ) } - - /** - * Centralized helper to clean up WebView resources - */ - private fun cleanupWebView() = synchronized(instanceLock) { - runCatching { - Log.d(TAG, "Cleaning up WebView") - currentWebView?.let { webView -> - try { - // CRITICAL: Reset touch listener first to ensure it doesn't block touches - webView.setOnTouchListener(null) - - // Destroy the WebView - webView.destroy() - - // Clear the reference - currentWebView = null - - Log.d(TAG, "WebView cleanup completed successfully") - } catch (e: Exception) { - Log.e(TAG, "Error during WebView cleanup: ${e.message}", e) - // Even if there's an error, ensure we reset the touch listener - try { - webView.setOnTouchListener(null) - } catch (e2: Exception) { - Log.e(TAG, "Error resetting touch listener: ${e2.message}", e2) - } - } - } - }.onFailure { - Log.e(TAG, "Exception during WebView cleanup: ${it.message}", it) - // Even if there's a failure, try to reset the touch listener - currentWebView?.setOnTouchListener(null) - currentWebView = null - } - } - - /** - * Centralized helper to clean up dialog fragments - * - * @param forceRemove Whether to forcefully remove the fragment from the FragmentManager - * @param onComplete Optional callback to execute after cleanup - */ - private fun cleanupDialogFragment(forceRemove: Boolean = false, onComplete: ((HideExperienceStatus?) -> Unit) = {}) { - synchronized(instanceLock) { - try { - Log.d(TAG, "cleanupDialogFragment: Beginning cleanup, forceRemove=$forceRemove") - - // First, reset the WebView touch listener to ensure it doesn't block touches - currentWebView?.setOnTouchListener(null) - - fragmentManager.get()?.let { fm -> - if (!fm.isDestroyed) { - // ALWAYS use force remove to ensure complete cleanup after our fixes - // This helps prevent multiple lingering fragments - removeAllKetchDialogFragments(fm) - - // Clean up WebView after fragments are removed - cleanupWebView() - - // Reset active state with a small delay to ensure fragment dismissal completes - Handler(android.os.Looper.getMainLooper()).postDelayed({ - isActive = false - - // Force reset the showing state in KetchDialogFragment - KetchDialogFragment.resetShowingState() - - // Double-check for any remaining fragments and force remove them - try { - removeAllKetchDialogFragments(fm) - } catch (e: Exception) { - Log.e(TAG, "Error in final fragment cleanup: ${e.message}", e) - } - - onComplete.invoke(null) - }, 500) // Increased delay to ensure fragments are fully dismissed - } else { - // FragmentManager is destroyed, just reset state - cleanupWebView() - isActive = false - KetchDialogFragment.resetShowingState() - onComplete.invoke(null) - } - } ?: run { - // No FragmentManager, just reset state - cleanupWebView() - isActive = false - KetchDialogFragment.resetShowingState() - onComplete.invoke(null) - } - } catch (e: Exception) { - Log.e(TAG, "Error during fragment cleanup: ${e.message}", e) - cleanupWebView() - isActive = false - KetchDialogFragment.resetShowingState() - onComplete.invoke(null) - } - } - } - - /** - * Helper method to forcefully remove all KetchDialogFragment instances - * This is more thorough than using dismiss() which might leave fragments in memory - */ - private fun removeAllKetchDialogFragments(fm: FragmentManager) { - try { - val fragments = fm.fragments.filterIsInstance() - if (fragments.isNotEmpty()) { - Log.d(TAG, "Force removing ${fragments.size} KetchDialogFragment instances with transaction") - - // First reset touch listeners, detach from parents, and destroy WebViews - fragments.forEach { fragment -> - try { - // Reset dialog window parameters first - fragment.dialog?.window?.let { window -> - try { - // Clear any flags that might interfere with touch events - window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) - - // Reset window parameters to default - val attrs = window.attributes - attrs.flags = attrs.flags or android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL - window.attributes = attrs - - // Reset any touch interceptors - val decorView = window.decorView - decorView.setOnTouchListener(null) - } catch (e: Exception) { - Log.e(TAG, "Error resetting window parameters: ${e.message}", e) - } - } - - // Get the WebView from the fragment - fragment.webView?.let { wv -> - // Reset touch listener first - wv.setOnTouchListener(null) - - // Disable hardware acceleration - try { - wv.setLayerType(android.view.View.LAYER_TYPE_NONE, null) - } catch (e: Exception) { - Log.e(TAG, "Error disabling WebView hardware acceleration: ${e.message}", e) - } - - // Detach WebView from any parent views - (wv.parent as? ViewGroup)?.removeView(wv) - - // Clear all content - wv.loadUrl("about:blank") - - // Destroy WebView - wv.destroy() - } - - // Clear the fragment's WebView reference - fragment.webView = null - } catch (e: Exception) { - Log.e(TAG, "Error cleaning up fragment WebView: ${e.message}", e) - } - } - - // Then remove the fragments with a transaction and execute it immediately - val transaction = fm.beginTransaction() - fragments.forEach { fragment -> - try { - // First try dismissing the dialog to properly remove the window - try { - fragment.dismissAllowingStateLoss() - } catch (e: Exception) { - Log.e(TAG, "Error dismissing fragment: ${e.message}", e) - } - - // Then remove the fragment from the manager - transaction.remove(fragment) - } catch (e: Exception) { - Log.e(TAG, "Error removing fragment in transaction: ${e.message}", e) - } - } - transaction.commitNowAllowingStateLoss() - - // Make sure the transaction is processed immediately - try { - fm.executePendingTransactions() - } catch (e: Exception) { - Log.e(TAG, "Error executing pending transactions: ${e.message}", e) - } - - // Reset the showing state - KetchDialogFragment.resetShowingState() - - // Make sure we clean up again after a delay - Handler(android.os.Looper.getMainLooper()).postDelayed({ - try { - val remainingFragments = fm.fragments.filterIsInstance() - if (remainingFragments.isNotEmpty()) { - Log.w(TAG, "Still found ${remainingFragments.size} fragments, removing again with IMMEDIATE execution") - - // More aggressive cleanup for remaining fragments - remainingFragments.forEach { fragment -> - try { - // Reset window parameters first - fragment.dialog?.window?.let { window -> - try { - window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) - val decorView = window.decorView - decorView.setOnTouchListener(null) - } catch (e: Exception) { - Log.e(TAG, "Error in delayed window parameter reset: ${e.message}", e) - } - } - - // Clear any remaining WebView - fragment.webView?.let { wv -> - wv.setOnTouchListener(null) - wv.setLayerType(android.view.View.LAYER_TYPE_NONE, null) - (wv.parent as? ViewGroup)?.removeView(wv) - wv.loadUrl("about:blank") - wv.destroy() - } - fragment.webView = null - } catch (e: Exception) { - Log.e(TAG, "Error in delayed WebView cleanup: ${e.message}", e) - } - } - - val finalTransaction = fm.beginTransaction() - remainingFragments.forEach { fragment -> - finalTransaction.remove(fragment) - } - finalTransaction.commitNowAllowingStateLoss() - - // Force immediate execution - fm.executePendingTransactions() - } - - // Clear current WebView reference to be safe - currentWebView?.let { wv -> - wv.setOnTouchListener(null) - wv.stopLoading() - } - cleanupWebView() - - // Force another check after a longer delay - Handler(android.os.Looper.getMainLooper()).postDelayed({ - val finalFragments = fm.fragments.filterIsInstance() - if (finalFragments.isNotEmpty()) { - Log.e(TAG, "CRITICAL: Still found ${finalFragments.size} fragments after multiple cleanup attempts") - - // Last-ditch attempt with detached transactions - try { - val lastTransaction = fm.beginTransaction() - finalFragments.forEach { fragment -> - // Detach first, then remove - lastTransaction.detach(fragment) - lastTransaction.remove(fragment) - } - lastTransaction.commitNowAllowingStateLoss() - fm.executePendingTransactions() - } catch (e: Exception) { - Log.e(TAG, "Error in final fragment cleanup attempt: ${e.message}", e) - } - - // Force showing state reset - KetchDialogFragment.resetShowingState() - } - - // Suggest garbage collection - System.gc() - - }, 500) // Final check after 500ms - - } catch (e: Exception) { - Log.e(TAG, "Error in delayed fragment cleanup: ${e.message}", e) - } - }, 300) - } - } catch (e: Exception) { - Log.e(TAG, "Error removing KetchDialogFragments: ${e.message}", e) - } - } } diff --git a/ketchsdk/src/main/java/com/ketch/android/data/Index.kt b/ketchsdk/src/main/java/com/ketch/android/data/Index.kt index ab3edb6b..3369f5a3 100644 --- a/ketchsdk/src/main/java/com/ketch/android/data/Index.kt +++ b/ketchsdk/src/main/java/com/ketch/android/data/Index.kt @@ -1,7 +1,7 @@ package com.ketch.android.data /* -* https://global.ketchcdn.com/web/v3/config/ketch_samples/android/boot.js?ketch_log=DEBUG +* https://global.ketchcdn.com/web/v3//config/ketch_samples/android/boot.js?ketch_log=DEBUG * &ketch_lang=en&ketch_jurisdiction=default&ketch_region=US * &ketch_show=preferences&ketch_preferences_tabs=overviewTab,rightsTab,consentsTab,subscriptionsTab */ @@ -159,7 +159,7 @@ fun getIndexHtml( " // Trigger taps outside the dialog\n" + " document.body.addEventListener('touchstart', function (e) {\n" + " if (e.target === document.body) {\n" + - " emitEvent('hideExperience', ['close']);\n" + + " emitEvent('tapOutside', [getDialogSize()]);\n" + " }\n" + " });\n" + " initKetchTag({" + diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt index ccd6c850..3f969252 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt @@ -5,10 +5,6 @@ import android.content.res.Configuration import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.os.SystemClock -import android.util.Log import android.view.Gravity import android.view.LayoutInflater import android.view.View @@ -20,143 +16,47 @@ import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager import com.ketch.android.R import com.ketch.android.databinding.KetchDialogLayoutBinding -import java.util.concurrent.atomic.AtomicBoolean internal class KetchDialogFragment() : DialogFragment() { private lateinit var binding: KetchDialogLayoutBinding - // Change to internal access to allow direct manipulation from Ketch.kt - internal var webView: KetchWebView? = null - - // Keep track of whether this instance is being destroyed - private var isBeingDestroyed = false - - // Add a safety touch blocker for the main view - private val transparentTouchListener = View.OnTouchListener { _, _ -> - // Return false to NOT block touches - false - } - - // Define the position property with a default value - private val position: DialogPosition = DialogPosition.CENTER - - // Add DialogPosition enum - enum class DialogPosition(val gravity: Int) { - TOP(Gravity.TOP), - CENTER(Gravity.CENTER), - BOTTOM(Gravity.BOTTOM) - } + private var webView: KetchWebView? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - binding = KetchDialogLayoutBinding.bind( - inflater.inflate(R.layout.ketch_dialog_layout, container) - ) - - // Ensure the root view is transparent - binding.root.setBackgroundColor(Color.TRANSPARENT) - + binding = + KetchDialogLayoutBinding.bind(inflater.inflate(R.layout.ketch_dialog_layout, container)) webView?.let { web -> - // Remove from any previous parent (web.parent as? ViewGroup)?.removeView(web) - - // Add to our layout with appropriate properties binding.root.addView( web, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT ) - - // Ensure WebView is interactive and transparent - web.isClickable = true - web.isFocusable = true - web.isFocusableInTouchMode = true - web.setBackgroundColor(Color.TRANSPARENT) } - return binding.root } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - return super.onCreateDialog(savedInstanceState).apply { + val dialog = super.onCreateDialog(savedInstanceState).apply { requestWindowFeature(Window.FEATURE_NO_TITLE) - setCanceledOnTouchOutside(isCancelable) } + return dialog } override fun onDestroyView() { - try { - Log.d(TAG, "onDestroyView: Beginning WebView cleanup") - - // Mark as being destroyed to prevent reuse - isBeingDestroyed = true - - // Set the transparent touch listener on the root view - binding.root.setOnTouchListener(transparentTouchListener) - - // Make sure the dialog window is not capturing touches - dialog?.window?.let { window -> - try { - // Clear any flags that might interfere with touch events - window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) - - // Reset any touch interceptors - val decorView = window.decorView - decorView.setOnTouchListener(null) - } catch (e: Exception) { - Log.e(TAG, "Error resetting window touch properties: ${e.message}", e) - } - } - - webView?.let { wv -> - // IMPORTANT: Set touch listener to null instead of blocking touches - wv.setOnTouchListener(null) - - // Stop any ongoing loads - wv.stopLoading() - - // Disable hardware acceleration - try { - wv.setLayerType(View.LAYER_TYPE_NONE, null) - } catch (e: Exception) { - Log.e(TAG, "Error disabling hardware acceleration: ${e.message}", e) - } - - // Clear content - try { - wv.loadUrl("about:blank") - } catch (e: Exception) { - Log.e(TAG, "Error loading blank page: ${e.message}", e) - } - - // Remove from view hierarchy - try { - (wv.parent as? ViewGroup)?.removeView(wv) - } catch (e: Exception) { - Log.e(TAG, "Error removing WebView from parent: ${e.message}", e) - } - - binding.root.removeView(wv) - - // Destroy the WebView - wv.destroy() - - // Clear the reference - webView = null - } - } catch (e: Exception) { - Log.e(TAG, "Error cleaning up WebView: ${e.message}", e) - // Even if we fail, try to ensure WebView is not blocking touches - webView?.setOnTouchListener(null) - } finally { - // Always reset the showing state when view is destroyed - isCurrentlyShowing.set(false) - } - + + binding.root.removeView(webView) + + // Cancel any coroutines in KetchWebView and fully tear down webview to prevent memory leaks + webView?.kill() + + // Set webview reference to null to prevent memory leaks + webView = null super.onDestroyView() } @@ -165,132 +65,26 @@ internal class KetchDialogFragment() : DialogFragment() { dialog?.window?.also { window -> window.clearFlags(FLAG_DIM_BEHIND) window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) - } - updateDialogSize() - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - updateDialogSize() - } + val displayMetrics = requireActivity().resources.displayMetrics - override fun onStart() { - super.onStart() - dialog?.window?.let { window -> - window.setLayout( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - window.setGravity(position.gravity) - - // Ensure complete transparency - window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) - window.clearFlags(FLAG_DIM_BEHIND) - - // Make sure the window has a transparent background - val attributes = window.attributes - attributes.dimAmount = 0f - - // Ensure we don't interfere with app touches after close - attributes.flags = attributes.flags or android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL - - window.attributes = attributes - } - } + val width = displayMetrics.widthPixels + val height = displayMetrics.heightPixels - fun show(manager: FragmentManager, webView: KetchWebView) { - if (manager.isDestroyed) { - Log.e(TAG, "Cannot show dialog: FragmentManager is destroyed") - isCurrentlyShowing.set(false) - return - } - - if (!isAdded) { - try { - // Use synchronized block to prevent race conditions - synchronized(LOCK) { - if (!isCurrentlyShowing.get()) { - isCurrentlyShowing.set(true) - - // Check for existing fragments and remove them - val existingFragments = manager.fragments.filterIsInstance() - if (existingFragments.isNotEmpty()) { - try { - // Remove all existing fragments - val transaction = manager.beginTransaction() - existingFragments.forEach { fragment -> - try { - if (fragment is DialogFragment) { - fragment.dismissAllowingStateLoss() - } - transaction.remove(fragment) - } catch (e: Exception) { - Log.e(TAG, "Error removing existing fragment: ${e.message}", e) - } - } - transaction.commitNowAllowingStateLoss() - - // Small delay to ensure fragments are fully removed - Handler(Looper.getMainLooper()).postDelayed({ - this.webView = webView - super.show(manager, TAG) - }, 200) - } catch (e: Exception) { - Log.e(TAG, "Error removing existing fragments: ${e.message}", e) - isCurrentlyShowing.set(false) - } - } else { - // No existing fragment found, show directly - this.webView = webView - super.show(manager, TAG) - } - } else { - Log.w(TAG, "Dialog already showing, ignoring request") - } - } - } catch (e: Exception) { - Log.e(TAG, "Error showing dialog: ${e.message}", e) - isCurrentlyShowing.set(false) + val params = window.attributes.apply { + this.width = width + this.height = height + gravity = Gravity.CENTER } - } - } - override fun dismiss() { - prepareForDismissal() - super.dismiss() - } - - override fun dismissAllowingStateLoss() { - prepareForDismissal() - super.dismissAllowingStateLoss() - } - - override fun onDestroy() { - super.onDestroy() - isCurrentlyShowing.set(false) - - // Ensure WebView is properly destroyed - webView?.let { wv -> - try { - // Disable interaction during cleanup - wv.setOnTouchListener(null) - - // Destroy the WebView - wv.destroy() - - // Clear the reference - webView = null - } catch (e: Exception) { - Log.e(TAG, "Error destroying WebView in onDestroy: ${e.message}", e) - } + window.attributes = params } } - // Helper methods - - private fun updateDialogSize() { + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) dialog?.window?.also { window -> val displayMetrics = requireActivity().resources.displayMetrics + val width = displayMetrics.widthPixels val height = displayMetrics.heightPixels @@ -304,238 +98,16 @@ internal class KetchDialogFragment() : DialogFragment() { } } - private fun prepareForDismissal() { - try { - Log.d(TAG, "prepareForDismissal: Beginning cleanup") - - // Mark as being destroyed to prevent reuse - isBeingDestroyed = true - - // Set the transparent touch listener on the root view - binding.root.setOnTouchListener(transparentTouchListener) - - // CRITICAL: Reset touch listener to null first - webView?.setOnTouchListener(null) - - // Stop any ongoing loads - webView?.stopLoading() - - // Clear content - try { - webView?.loadUrl("about:blank") - } catch (e: Exception) { - Log.e(TAG, "Error loading blank page: ${e.message}", e) - } - - // Ensure we reset the showing flag - isCurrentlyShowing.set(false) - - // Remove WebView from parent - try { - (webView?.parent as? ViewGroup)?.removeView(webView) - } catch (e: Exception) { - Log.e(TAG, "Error removing WebView from parent: ${e.message}", e) - } - - // Destroy the WebView - webView?.destroy() - webView = null - - // Force a reset of the showing state - resetShowingState() - } catch (e: Exception) { - Log.e(TAG, "Error preparing for dismissal: ${e.message}", e) - // Even if there's an error, ensure we reset the state - isCurrentlyShowing.set(false) - } finally { - // Final safety check to ensure touch events aren't blocked - try { - webView?.setOnTouchListener(null) - } catch (e: Exception) { - Log.e(TAG, "Final touch reset failed: ${e.message}", e) - } - } + fun show(manager: FragmentManager, webView: KetchWebView) { + this.webView = webView + super.show(manager, TAG) } companion object { internal val TAG = KetchDialogFragment::class.java.simpleName - private val isCurrentlyShowing = AtomicBoolean(false) - private val LOCK = Any() - - // Add a debounce mechanism - private var lastShowTime = 0L - private const val SHOW_DEBOUNCE_TIME = 1000L // 1 second between allowed shows - - @Synchronized - fun newInstance(): KetchDialogFragment? { - // Debounce rapid creation attempts - val currentTime = SystemClock.elapsedRealtime() - if (currentTime - lastShowTime < SHOW_DEBOUNCE_TIME) { - Log.d(TAG, "Ignoring rapid fragment creation, debouncing for ${SHOW_DEBOUNCE_TIME}ms") - return null - } - lastShowTime = currentTime - - // Only allow ONE instance at a time - return if (!isCurrentlyShowing.get()) { - isCurrentlyShowing.set(true) - KetchDialogFragment() - } else { - Log.w(TAG, "DialogFragment already showing, ignoring request") - null - } - } - - /** - * Force reset the showing state - should only be used in emergency cleanup situations - */ - @Synchronized - fun resetShowingState() { - isCurrentlyShowing.set(false) - } - - /** - * Force cleanup all KetchDialogFragment instances from a FragmentManager - */ - @Synchronized - fun forceCleanupAllInstances(fragmentManager: FragmentManager) { - if (fragmentManager.isDestroyed) { - Log.e(TAG, "Cannot cleanup: FragmentManager is destroyed") - isCurrentlyShowing.set(false) - return - } - - try { - val existingFragments = fragmentManager.fragments.filterIsInstance() - if (existingFragments.isNotEmpty()) { - Log.d(TAG, "Force cleaning up ${existingFragments.size} dialog fragments") - - // First reset all touch listeners and clean up WebViews - existingFragments.forEach { fragment -> - try { - // Mark as being destroyed - fragment.isBeingDestroyed = true - - // Ensure root view doesn't block touches - if (::binding.isInitialized) { - fragment.binding.root.setOnTouchListener(fragment.transparentTouchListener) - } - - // Ensure WebView is properly destroyed - fragment.webView?.let { wv -> - try { - // Reset touch listener first to prevent blocking touches - wv.setOnTouchListener(null) - - // Stop any loading - wv.stopLoading() - - // Clear content - wv.loadUrl("about:blank") - - // Remove from parent - (wv.parent as? ViewGroup)?.removeView(wv) - - // Destroy the WebView - wv.destroy() - } catch (e: Exception) { - Log.e(TAG, "Error destroying WebView during cleanup: ${e.message}", e) - } - } - - // Clear WebView reference - fragment.webView = null - } catch (e: Exception) { - Log.e(TAG, "Error cleaning up fragment WebView: ${e.message}", e) - } - } - - // Directly remove them in a transaction without dismissing first - // This is more thorough than dismissAllowingStateLoss() - val transaction = fragmentManager.beginTransaction() - existingFragments.forEach { fragment -> - try { - // Detach first to ensure view is removed from UI - transaction.detach(fragment) - transaction.remove(fragment) - } catch (e: Exception) { - Log.e(TAG, "Error removing fragment: ${e.message}", e) - } - } - transaction.commitNowAllowingStateLoss() - - // Force immediate execution - try { - fragmentManager.executePendingTransactions() - } catch (e: Exception) { - Log.e(TAG, "Error executing pending transactions: ${e.message}", e) - } - - // Reset the showing state - isCurrentlyShowing.set(false) - - // Add a small delay to ensure the UI thread has time to process the changes - Handler(Looper.getMainLooper()).postDelayed({ - // Double-check that all fragments are gone - val remainingFragments = fragmentManager.fragments.filterIsInstance() - if (remainingFragments.isNotEmpty()) { - Log.w(TAG, "Found ${remainingFragments.size} remaining fragments after cleanup, forcing removal") - try { - // One more attempt with detach-then-remove approach - val finalTransaction = fragmentManager.beginTransaction() - remainingFragments.forEach { fragment -> - // Set touch listeners to null to be extra safe - fragment.webView?.setOnTouchListener(null) - if (::binding.isInitialized) { - fragment.binding.root.setOnTouchListener(fragment.transparentTouchListener) - } - - // Detach first, then remove - finalTransaction.detach(fragment) - finalTransaction.remove(fragment) - } - finalTransaction.commitNowAllowingStateLoss() - fragmentManager.executePendingTransactions() - - // One final check for any stubborn fragments - Handler(Looper.getMainLooper()).postDelayed({ - val stubbornFragments = fragmentManager.fragments.filterIsInstance() - if (stubbornFragments.isNotEmpty()) { - Log.e(TAG, "CRITICAL: Still found fragments after multiple cleanup attempts!") - - // Last-ditch attempt with individual transactions - stubbornFragments.forEach { fragment -> - try { - val emergencyTransaction = fragmentManager.beginTransaction() - emergencyTransaction.detach(fragment) - emergencyTransaction.remove(fragment) - emergencyTransaction.commitNowAllowingStateLoss() - fragmentManager.executePendingTransactions() - } catch (e: Exception) { - Log.e(TAG, "Error in emergency fragment removal: ${e.message}", e) - } - } - } - - // Suggest garbage collection - System.gc() - }, 200) - } catch (e: Exception) { - Log.e(TAG, "Error in final fragment cleanup: ${e.message}", e) - } - } - - // Ensure the showing state is reset - isCurrentlyShowing.set(false) - }, 500) // Increased delay for thorough cleanup - } else { - // No fragments to clean up, just reset the state - isCurrentlyShowing.set(false) - } - } catch (e: Exception) { - Log.e(TAG, "Error during force cleanup: ${e.message}", e) - isCurrentlyShowing.set(false) - } + + fun newInstance(): KetchDialogFragment { + return KetchDialogFragment() } } } \ No newline at end of file diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt index ed72b51b..bad32aa6 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt @@ -6,9 +6,7 @@ import android.content.Intent import android.graphics.Bitmap import android.os.Handler import android.os.Looper -import android.util.AttributeSet import android.util.Log -import android.view.ViewGroup import android.webkit.ConsoleMessage import android.webkit.JavascriptInterface import android.webkit.WebChromeClient @@ -38,31 +36,22 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicBoolean -class KetchWebView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : WebView(context, attrs, defStyleAttr) { +const val INITIAL_RELOAD_DELAY = 4000L + +@SuppressLint("SetJavaScriptEnabled", "ViewConstructor") +class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(context) { var listener: WebViewListener? = null - private val localContentWebViewClient = LocalContentWebViewClient() - internal var isPageLoaded = false - internal var currentUrl: String? = null + private val localContentWebViewClient = LocalContentWebViewClient(shouldRetry) init { webViewClient = localContentWebViewClient - setupWebView() - - // Ensure the WebView background is transparent - setBackgroundColor(android.graphics.Color.TRANSPARENT) - - // Configure hardware acceleration properly - setLayerType(LAYER_TYPE_HARDWARE, null) - - // Ensure that the WebView renders properly - setWillNotDraw(false) - - // Add JavaScript interface for communication with WebView + settings.javaScriptEnabled = true + setBackgroundColor(context.getColor(android.R.color.transparent)) + + // Explicitly set to false to address android webview security concern + setWebContentsDebuggingEnabled(false) + addJavascriptInterface( PreferenceCenterJavascriptInterface(this), "androidListener" @@ -82,132 +71,31 @@ class KetchWebView @JvmOverloads constructor( } } - private fun setupWebView() { - settings.apply { - javaScriptEnabled = true - domStorageEnabled = true - setGeolocationEnabled(false) - mediaPlaybackRequiresUserGesture = false - } - } - - override fun loadUrl(url: String) { - currentUrl = url - isPageLoaded = false - super.loadUrl(url) - } - fun setDebugMode() { setWebContentsDebuggingEnabled(true) } - // Properly clean up WebView resources to prevent memory leaks and renderer crashes - override fun destroy() { - try { - Log.d(TAG, "Beginning WebView destroy") - - // CRITICAL: Reset touch listener FIRST to ensure touches pass through - // This must be the first operation to guarantee it happens even if other steps fail - setOnTouchListener(null) - - // Disable hardware acceleration which can cause blocking issues - try { - setLayerType(LAYER_TYPE_NONE, null) - } catch (e: Exception) { - Log.e(TAG, "Error disabling hardware acceleration: ${e.message}") - } - - // Prevent further page loads - stopLoading() - - // Add a blank/empty handler for JS errors during cleanup - try { - evaluateJavascript("window.onerror = function(message, url, line, column, error) { return true; };", null) - } catch (e: Exception) { - Log.e(TAG, "Error setting JS error handler: ${e.message}") - } - - // Remove JavaScript interface first to prevent any further callbacks - try { - removeJavascriptInterface("androidListener") - } catch (e: Exception) { - Log.e(TAG, "Error removing JS interface: ${e.message}") - } - - // Set listener to null to prevent callbacks during cleanup - listener = null - - // Cancel all coroutines next - try { - localContentWebViewClient.cancelCoroutines() - } catch (e: Exception) { - Log.e(TAG, "Error cancelling coroutines: ${e.message}") - } - - // Disable JavaScript to prevent further execution - try { - settings.javaScriptEnabled = false - } catch (e: Exception) { - Log.e(TAG, "Error disabling JavaScript: ${e.message}") - } - - // Stop any ongoing loads or processing - try { - onPause() - } catch (e: Exception) { - Log.e(TAG, "Error pausing WebView: ${e.message}") - } - - // Clear WebView state - try { - clearHistory() - clearCache(true) - clearFormData() - clearSslPreferences() - } catch (e: Exception) { - Log.e(TAG, "Error clearing WebView state: ${e.message}") - } - - // Remove all views - try { - removeAllViews() - } catch (e: Exception) { - Log.e(TAG, "Error removing views: ${e.message}") - } - - // Set a low global layout limit to reduce memory pressure - try { - setLayoutParams( - ViewGroup.LayoutParams(1, 1) - ) - } catch (e: Exception) { - Log.e(TAG, "Error setting layout params: ${e.message}") - } - - // Finally call the parent WebView's destroy method - try { - super.destroy() - } catch (e: Exception) { - Log.e(TAG, "Error in super.destroy(): ${e.message}") - } - - Log.d(TAG, "WebView destroy completed successfully") - } catch (e: Exception) { - Log.e(TAG, "Error during WebView destroy: ${e.message}", e) - } finally { - // CRITICAL: Reset touch listener AGAIN in finally block to ensure it happens - // This is our last line of defense to prevent touch blocking - try { - setOnTouchListener(null) - } catch (e: Exception) { - Log.e(TAG, "Final attempt to reset touch listener failed: ${e.message}", e) - } - } + // Cancel any coroutines in KetchWebView and fully tear down webview to prevent memory leaks + fun kill() { + localContentWebViewClient.cancelCoroutines() + stopLoading() + clearHistory() + clearCache(true) + loadUrl("about:blank") + removeAllViews() + destroy() } - class LocalContentWebViewClient : WebViewClientCompat() { - private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) - private val isRetrying = AtomicBoolean(false) + class LocalContentWebViewClient(private var shouldRetry: Boolean = false) : WebViewClientCompat() { + + // Flag indicating if the webview has finished loading + // We use atomic boolean here because we are using it within a coroutine + private var isLoaded = AtomicBoolean(false) + + // Reload delay, increases exponentially in onPageStarted + private var reloadDelay = INITIAL_RELOAD_DELAY + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { val intent = Intent(Intent.ACTION_VIEW, request.url) @@ -246,23 +134,41 @@ class KetchWebView @JvmOverloads constructor( super.onPageStarted(view, url, favicon) Log.d(TAG, "onPageStarted: $url") - if (view is KetchWebView && url == view.currentUrl) { - view.isPageLoaded = false + // Reset loaded flag + isLoaded.set(false) + + // Launch retry if flag set + if (shouldRetry) { + scope.launch(Dispatchers.Main) { + delay(reloadDelay) + + // If not yet loaded stop current webview, reload, and increase future delay + if (!isLoaded.get()) { + Log.d(TAG, "Reloading webview after $reloadDelay ms") + view?.stopLoading() + view?.reload() + reloadDelay *= 2 // Exponentially increase reload delay + } + } } } override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) - if (view is KetchWebView && url == view.currentUrl && !view.isPageLoaded) { - view.isPageLoaded = true + // Set loaded flag + isLoaded.set(true) + + // Only reset reload delay when second onPageFinished callback has fired + if (url === "data:text/html;charset=utf-8;base64,") { + reloadDelay = INITIAL_RELOAD_DELAY } Log.d(TAG, "onPageFinished: $url") } // Cancel all coroutines fun cancelCoroutines() { - coroutineScope.cancel() + scope.cancel() Log.d(TAG, "webViewClient coroutines cancelled") } } @@ -412,6 +318,14 @@ class KetchWebView @JvmOverloads constructor( } } + @JavascriptInterface + fun tapOutside(dialogSize: String?) { + Log.d(TAG, "tapOutside: $dialogSize") + runOnMainThread { + ketchWebView.listener?.onTapOutside() + } + } + @JavascriptInterface fun geoip(ip: String?) { } @@ -498,10 +412,6 @@ class KetchWebView @JvmOverloads constructor( fun changeDialog(display: ContentDisplay) fun onClose(status: HideExperienceStatus) fun onWillShowExperience(experienceType: WillShowExperienceType) - /** - * @deprecated This method is deprecated and will be removed in a future release - */ - @Deprecated("This method is deprecated and will be removed in a future release") fun onTapOutside() } From d39eb3f4592514f820c37cd751d75db01ea997b0 Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Tue, 11 Mar 2025 23:30:14 -0600 Subject: [PATCH 24/30] updates --- gradle.properties | 2 +- ketchsdk/build.gradle | 3 +- .../src/main/java/com/ketch/android/Ketch.kt | 428 ++++++++++++------ .../ketch/android/ui/KetchDialogFragment.kt | 209 ++++++++- 4 files changed, 488 insertions(+), 154 deletions(-) diff --git a/gradle.properties b/gradle.properties index 8d471725..e34ca5a9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,7 +17,7 @@ # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects org.gradle.daemon=true org.gradle.parallel=true -org.gradle.jvmargs=-Xms1024m -Xmx6144m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xms1024m -Xmx6144m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED kotlin.incremental.usePreciseJavaTracking=true android.useAndroidX=true android.enableJetifier=true diff --git a/ketchsdk/build.gradle b/ketchsdk/build.gradle index 236f2962..c2bb18aa 100644 --- a/ketchsdk/build.gradle +++ b/ketchsdk/build.gradle @@ -1,6 +1,7 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-parcelize' -apply plugin: 'kotlin-kapt' +// Temporarily disabling KAPT to fix the Java module system issue +// apply plugin: 'kotlin-kapt' apply plugin: 'org.jetbrains.kotlin.android' apply plugin: 'maven-publish' apply plugin: 'org.jetbrains.dokka' diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index a34046bd..0236a237 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -2,6 +2,7 @@ package com.ketch.android import android.content.Context import android.util.Log +import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -31,6 +32,16 @@ class Ketch private constructor( private var language: String? = null private var jurisdiction: String? = null private var region: String? = null + + // Add a flag to track if we're already showing an experience to prevent multiple overlapping experiences + @Volatile + private var isShowingExperience = false + + // Keep a reference to the active fragment for better cleanup + private var activeDialogFragment: WeakReference? = null + + // Lock object for synchronization + private val lock = Any() /** * Retrieve a String value from the preferences. @@ -74,6 +85,12 @@ class Ketch private constructor( synchronousPreferences: Boolean = false, bottomPadding: Int = 0, ): Boolean { + // Check if we're already showing an experience + if (isShowingExperience) { + Log.d(TAG, "Not loading as an experience is already being shown") + return false + } + val webView = createWebView(shouldRetry, synchronousPreferences) return if (webView != null) { webView.load( @@ -107,6 +124,12 @@ class Ketch private constructor( synchronousPreferences: Boolean = false, bottomPadding: Int = 0, ): Boolean { + // Check if we're already showing an experience + if (isShowingExperience) { + Log.d(TAG, "Not showing consent as an experience is already being shown") + return false + } + val webView = createWebView(shouldRetry, synchronousPreferences) return if (webView != null) { webView.load( @@ -140,6 +163,12 @@ class Ketch private constructor( synchronousPreferences: Boolean = false, bottomPadding: Int = 0, ): Boolean { + // Check if we're already showing an experience + if (isShowingExperience) { + Log.d(TAG, "Not showing preferences as an experience is already being shown") + return false + } + val webView = createWebView(shouldRetry, synchronousPreferences) return if (webView != null) { webView.load( @@ -177,6 +206,12 @@ class Ketch private constructor( synchronousPreferences: Boolean = false, bottomPadding: Int = 0, ): Boolean { + // Check if we're already showing an experience + if (isShowingExperience) { + Log.d(TAG, "Not showing preferences tab as an experience is already being shown") + return false + } + val webView = createWebView(shouldRetry, synchronousPreferences) return if (webView != null) { webView.load( @@ -204,9 +239,20 @@ class Ketch private constructor( * Dismiss the dialog */ fun dismissDialog() { - findDialogFragment()?.let { - (it as? KetchDialogFragment)?.dismissAllowingStateLoss() - this@Ketch.listener?.onDismiss(HideExperienceStatus.None) + synchronized(lock) { + val fragment = findDialogFragment() + if (fragment != null) { + try { + (fragment as? KetchDialogFragment)?.dismissAllowingStateLoss() + } catch (e: Exception) { + Log.e(TAG, "Error dismissing dialog: ${e.message}") + } finally { + // Reset showing flag and reference regardless + isShowingExperience = false + activeDialogFragment = null + this@Ketch.listener?.onDismiss(HideExperienceStatus.None) + } + } } } @@ -247,12 +293,19 @@ class Ketch private constructor( } init { - getPreferences() - findDialogFragment()?.let { dialog -> - (dialog as KetchDialogFragment).dismissAllowingStateLoss() - this@Ketch.listener?.onDismiss(HideExperienceStatus.None) + // Ensure any existing dialog fragments are properly cleaned up + synchronized(lock) { + val existingFragment = fragmentManager.get()?.findFragmentByTag(KetchDialogFragment.TAG) + if (existingFragment != null) { + try { + (existingFragment as? KetchDialogFragment)?.dismissAllowingStateLoss() + this@Ketch.listener?.onDismiss(HideExperienceStatus.None) + } catch (e: Exception) { + Log.e(TAG, "Error dismissing existing dialog in init: ${e.message}") + } + } } } @@ -266,169 +319,282 @@ class Ketch private constructor( } private fun createWebView(shouldRetry: Boolean = false, synchronousPreferences: Boolean = false): KetchWebView? { + synchronized(lock) { + // First check if a fragment is already showing - if so, don't create a new WebView + if (isShowingExperience || findDialogFragment() != null) { + Log.d(TAG, "Not creating WebView as experience is already being shown") + return null + } - val webView = context.get()?.let { KetchWebView(it, shouldRetry) } ?: return null + val webView = context.get()?.let { KetchWebView(it, shouldRetry) } ?: return null - // Enable debug mode - if (logLevel === LogLevel.DEBUG) { - webView.setDebugMode() - } + // Enable debug mode + if (logLevel === LogLevel.DEBUG) { + webView.setDebugMode() + } - webView.listener = object : KetchWebView.WebViewListener { + webView.listener = object : KetchWebView.WebViewListener { - private var config: KetchConfig? = null - private var showConsent: Boolean = false + private var config: KetchConfig? = null + private var showConsent: Boolean = false - override fun showConsent() { - if (config == null) { - showConsent = true - return + override fun showConsent() { + if (config == null) { + showConsent = true + return + } + showConsentPopup() } - showConsentPopup() - } - override fun showPreferences() { - if (!isActivityActive()) { - Log.d(TAG, "Not showing as activity is not active") - return + override fun showPreferences() { + // Synchronize on our lock object to prevent race conditions + synchronized(lock) { + if (!isActivityActive()) { + Log.d(TAG, "Not showing as activity is not active") + return + } + + // Ensure we aren't already showing a dialog + if (isShowingExperience || findDialogFragment() != null) { + Log.d(TAG, "Not showing as dialog already exists") + return + } + + // Set flag to indicate we're showing an experience + isShowingExperience = true + + try { + val dialog = KetchDialogFragment.newInstance() + + // Store reference to the dialog fragment + activeDialogFragment = WeakReference(dialog) + + fragmentManager.get()?.let { fm -> + if (!fm.isDestroyed) { + // Pass dismissal callback to properly reset state + dialog.show(fm, webView) { + // This callback will be invoked when the fragment is fully dismissed + isShowingExperience = false + activeDialogFragment = null + this@Ketch.listener?.onDismiss(HideExperienceStatus.None) + } + this@Ketch.listener?.onShow() + } else { + isShowingExperience = false + activeDialogFragment = null + Log.e(TAG, "FragmentManager is destroyed, cannot show dialog") + this@Ketch.listener?.onError("FragmentManager is destroyed, cannot show dialog") + } + } ?: run { + isShowingExperience = false + activeDialogFragment = null + Log.e(TAG, "FragmentManager is null, cannot show dialog") + this@Ketch.listener?.onError("FragmentManager is null, cannot show dialog") + } + } catch (e: Exception) { + isShowingExperience = false + activeDialogFragment = null + Log.e(TAG, "Error showing dialog: ${e.message}") + this@Ketch.listener?.onError("Error showing dialog: ${e.message}") + } + } } - if (findDialogFragment() != null) { - Log.d(TAG, "Not showing as dialog already exists") - return + override fun onUSPrivacyUpdated(values: Map) { + getPreferences().saveValues(values, "USPrivacy", synchronousPreferences) + this@Ketch.listener?.onUSPrivacyUpdated(values) } - val dialog = KetchDialogFragment.newInstance() - - fragmentManager.get()?.let { - dialog.show(it, webView) - this@Ketch.listener?.onShow() + override fun onTCFUpdated(values: Map) { + getPreferences().saveValues(values, "TCF", synchronousPreferences) + this@Ketch.listener?.onTCFUpdated(values) } - } - - override fun onUSPrivacyUpdated(values: Map) { - getPreferences().saveValues(values, "USPrivacy", synchronousPreferences) - this@Ketch.listener?.onUSPrivacyUpdated(values) - } - override fun onTCFUpdated(values: Map) { - getPreferences().saveValues(values, "TCF", synchronousPreferences) - this@Ketch.listener?.onTCFUpdated(values) - } - - override fun onGPPUpdated(values: Map) { - getPreferences().saveValues(values, "GPP", synchronousPreferences) - this@Ketch.listener?.onGPPUpdated(values) - } + override fun onGPPUpdated(values: Map) { + getPreferences().saveValues(values, "GPP", synchronousPreferences) + this@Ketch.listener?.onGPPUpdated(values) + } - override fun onConfigUpdated(config: KetchConfig?) { - // Set internal config field - this.config = config + override fun onConfigUpdated(config: KetchConfig?) { + // Set internal config field + this.config = config - // Call config update listener - this@Ketch.listener?.onConfigUpdated(config) + // Call config update listener + this@Ketch.listener?.onConfigUpdated(config) - if (!showConsent) { - return + if (!showConsent) { + return + } + showConsentPopup() } - showConsentPopup() - } - override fun onEnvironmentUpdated(environment: String?) { - this@Ketch.listener?.onEnvironmentUpdated(environment) - } + override fun onEnvironmentUpdated(environment: String?) { + this@Ketch.listener?.onEnvironmentUpdated(environment) + } - override fun onRegionInfoUpdated(regionInfo: String?) { - this@Ketch.listener?.onRegionInfoUpdated(regionInfo) - } + override fun onRegionInfoUpdated(regionInfo: String?) { + this@Ketch.listener?.onRegionInfoUpdated(regionInfo) + } - override fun onJurisdictionUpdated(jurisdiction: String?) { - this@Ketch.listener?.onJurisdictionUpdated(jurisdiction) - } + override fun onJurisdictionUpdated(jurisdiction: String?) { + this@Ketch.listener?.onJurisdictionUpdated(jurisdiction) + } - override fun onIdentitiesUpdated(identities: String?) { - this@Ketch.listener?.onIdentitiesUpdated(identities) - } + override fun onIdentitiesUpdated(identities: String?) { + this@Ketch.listener?.onIdentitiesUpdated(identities) + } - override fun onConsentUpdated(consent: Consent) { - this@Ketch.listener?.onConsentUpdated(consent) - } + override fun onConsentUpdated(consent: Consent) { + this@Ketch.listener?.onConsentUpdated(consent) + } - override fun onError(errMsg: String?) { - this@Ketch.listener?.onError(errMsg) - } + override fun onError(errMsg: String?) { + this@Ketch.listener?.onError(errMsg) + } - override fun changeDialog(display: ContentDisplay) { - findDialogFragment()?.let { - (it as? KetchDialogFragment)?.apply { - isCancelable = getDisposableContentInteractions(display) + override fun changeDialog(display: ContentDisplay) { + findDialogFragment()?.let { + (it as? KetchDialogFragment)?.apply { + isCancelable = getDisposableContentInteractions(display) + } } } - } - override fun onClose(status: HideExperienceStatus) { - // Dismiss dialog fragment - findDialogFragment()?.let { - (it as? KetchDialogFragment)?.dismissAllowingStateLoss() + override fun onClose(status: HideExperienceStatus) { + // Dismiss dialog fragment + synchronized(lock) { + val fragment = findDialogFragment() + if (fragment != null) { + try { + // Pass the status to the dismissal callback + (fragment as? KetchDialogFragment)?.dismissAllowingStateLoss() + // Note: The callback in the fragment will handle resetting isShowingExperience and activeDialogFragment + this@Ketch.listener?.onDismiss(status) + } catch (e: Exception) { + Log.e(TAG, "Error dismissing dialog: ${e.message}") + // Ensure state is reset even if dismissal fails + isShowingExperience = false + activeDialogFragment = null + this@Ketch.listener?.onDismiss(status) + } + } else { + // Even if fragment isn't found, reset state + isShowingExperience = false + activeDialogFragment = null + this@Ketch.listener?.onDismiss(status) + } + } } - // Execute onDismiss event listener - this@Ketch.listener?.onDismiss(status) - } - - override fun onWillShowExperience(experienceType: WillShowExperienceType) { - // Execute onWillShowExperience listener - this@Ketch.listener?.onWillShowExperience(experienceType) - } - - override fun onTapOutside() { - // Dismiss dialog fragment - findDialogFragment()?.let { - (it as? KetchDialogFragment)?.dismissAllowingStateLoss() - - // Execute onDismiss event listener - this@Ketch.listener?.onDismiss(HideExperienceStatus.None) + override fun onWillShowExperience(experienceType: WillShowExperienceType) { + // Execute onWillShowExperience listener + this@Ketch.listener?.onWillShowExperience(experienceType) } - } - private fun showConsentPopup() { - if (!isActivityActive()) { - Log.d(TAG, "Not showing as activity is not active") - return + override fun onTapOutside() { + // Dismiss dialog fragment + synchronized(lock) { + val fragment = findDialogFragment() + if (fragment != null) { + try { + (fragment as? KetchDialogFragment)?.dismissAllowingStateLoss() + // Note: The callback in the fragment will handle resetting isShowingExperience and activeDialogFragment + this@Ketch.listener?.onDismiss(HideExperienceStatus.None) + } catch (e: Exception) { + Log.e(TAG, "Error dismissing dialog on tap outside: ${e.message}") + // Ensure state is reset even if dismissal fails + isShowingExperience = false + activeDialogFragment = null + this@Ketch.listener?.onDismiss(HideExperienceStatus.None) + } + } + } } - if (findDialogFragment() != null) { - Log.d(TAG, "Not showing as dialog already exists") - return + private fun showConsentPopup() { + // Synchronize on our lock object to prevent race conditions + synchronized(lock) { + if (!isActivityActive()) { + Log.d(TAG, "Not showing as activity is not active") + return + } + + // Ensure we aren't already showing a dialog + if (isShowingExperience || findDialogFragment() != null) { + Log.d(TAG, "Not showing as dialog already exists") + return + } + + // Set flag to indicate we're showing an experience + isShowingExperience = true + + try { + val dialog = KetchDialogFragment.newInstance().apply { + val disableContentInteractions = getDisposableContentInteractions( + config?.experiences?.consent?.display ?: ContentDisplay.Banner + ) + isCancelable = !disableContentInteractions + } + + // Store reference to the dialog fragment + activeDialogFragment = WeakReference(dialog) + + fragmentManager.get()?.let { fm -> + if (!fm.isDestroyed) { + // Pass dismissal callback to properly reset state + dialog.show(fm, webView) { + // This callback will be invoked when the fragment is fully dismissed + isShowingExperience = false + activeDialogFragment = null + this@Ketch.listener?.onDismiss(HideExperienceStatus.None) + } + this@Ketch.listener?.onShow() + } else { + isShowingExperience = false + activeDialogFragment = null + Log.e(TAG, "FragmentManager is destroyed, cannot show dialog") + this@Ketch.listener?.onError("FragmentManager is destroyed, cannot show dialog") + } + } ?: run { + isShowingExperience = false + activeDialogFragment = null + Log.e(TAG, "FragmentManager is null, cannot show dialog") + this@Ketch.listener?.onError("FragmentManager is null, cannot show dialog") + } + } catch (e: Exception) { + isShowingExperience = false + activeDialogFragment = null + Log.e(TAG, "Error showing dialog: ${e.message}") + this@Ketch.listener?.onError("Error showing dialog: ${e.message}") + } + + showConsent = false + } } - val dialog = KetchDialogFragment.newInstance().apply { - val disableContentInteractions = getDisposableContentInteractions( - config?.experiences?.consent?.display ?: ContentDisplay.Banner - ) - isCancelable = !disableContentInteractions - } - fragmentManager.get()?.let { - dialog.show(it, webView) - this@Ketch.listener?.onShow() - } - showConsent = false + private fun getDisposableContentInteractions(display: ContentDisplay): Boolean = + config?.let { + if (display == ContentDisplay.Modal) { + it.theme?.modal?.container?.backdrop?.disableContentInteractions == true + } else if (display == ContentDisplay.Banner) { + it.theme?.modal?.container?.backdrop?.disableContentInteractions == true + } else false + } ?: false } - - private fun getDisposableContentInteractions(display: ContentDisplay): Boolean = - config?.let { - if (display == ContentDisplay.Modal) { - it.theme?.modal?.container?.backdrop?.disableContentInteractions == true - } else if (display == ContentDisplay.Banner) { - it.theme?.modal?.container?.backdrop?.disableContentInteractions == true - } else false - } ?: false + return webView } - return webView } - private fun findDialogFragment() = - fragmentManager.get()?.findFragmentByTag(KetchDialogFragment.TAG) + private fun findDialogFragment(): Fragment? { + // First check our active reference, which is faster than searching + val activeFragment = activeDialogFragment?.get() + if (activeFragment != null && activeFragment.isAdded && !activeFragment.isDetached) { + return activeFragment + } + + // Fall back to searching by tag + return fragmentManager.get()?.findFragmentByTag(KetchDialogFragment.TAG) + } private fun isActivityActive(): Boolean { return (context.get() as? LifecycleOwner)?.lifecycle?.currentState?.isAtLeast(Lifecycle.State.STARTED) diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt index 3f969252..9fa904a5 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt @@ -1,10 +1,14 @@ package com.ketch.android.ui import android.app.Dialog +import android.content.Context import android.content.res.Configuration import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log import android.view.Gravity import android.view.LayoutInflater import android.view.View @@ -16,27 +20,47 @@ import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager import com.ketch.android.R import com.ketch.android.databinding.KetchDialogLayoutBinding +import java.util.concurrent.atomic.AtomicBoolean -internal class KetchDialogFragment() : DialogFragment() { +internal class KetchDialogFragment : DialogFragment() { - private lateinit var binding: KetchDialogLayoutBinding + private var _binding: KetchDialogLayoutBinding? = null + private val binding get() = _binding!! private var webView: KetchWebView? = null + + // Flag to track if we've already cleaned up resources + private val hasCleanedUp = AtomicBoolean(false) + // Flag to track if the fragment has been added to the manager + private val isShowing = AtomicBoolean(false) + // Callback to notify the parent when this fragment is dismissed + private var onDismissCallback: (() -> Unit)? = null + // Main thread handler for delayed operations + private val mainHandler = Handler(Looper.getMainLooper()) + + override fun onAttach(context: Context) { + super.onAttach(context) + // Set showing flag when fragment is attached + isShowing.set(true) + } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - binding = - KetchDialogLayoutBinding.bind(inflater.inflate(R.layout.ketch_dialog_layout, container)) + _binding = KetchDialogLayoutBinding.bind(inflater.inflate(R.layout.ketch_dialog_layout, container)) webView?.let { web -> - (web.parent as? ViewGroup)?.removeView(web) - binding.root.addView( - web, - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT - ) + try { + (web.parent as? ViewGroup)?.removeView(web) + binding.root.addView( + web, + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + } catch (e: Exception) { + Log.e(TAG, "Error adding WebView to view hierarchy: ${e.message}") + } } return binding.root } @@ -49,16 +73,103 @@ internal class KetchDialogFragment() : DialogFragment() { } override fun onDestroyView() { - - binding.root.removeView(webView) - - // Cancel any coroutines in KetchWebView and fully tear down webview to prevent memory leaks - webView?.kill() - - // Set webview reference to null to prevent memory leaks - webView = null + cleanupResources() + _binding = null super.onDestroyView() } + + override fun onDetach() { + super.onDetach() + isShowing.set(false) + // Notify parent this fragment is fully detached + notifyDismissComplete() + } + + override fun onDestroy() { + cleanupResources() + super.onDestroy() + } + + override fun dismiss() { + if (isShowing.get()) { + cleanupResources() + try { + super.dismiss() + } catch (e: Exception) { + Log.e(TAG, "Error during dismiss: ${e.message}") + try { + dismissAllowingStateLoss() + } catch (e2: Exception) { + Log.e(TAG, "Error during fallback dismissAllowingStateLoss: ${e2.message}") + forceRemoveFragment() + } + } + } + } + + override fun dismissAllowingStateLoss() { + if (isShowing.get()) { + cleanupResources() + try { + super.dismissAllowingStateLoss() + } catch (e: Exception) { + Log.e(TAG, "Error during dismissAllowingStateLoss: ${e.message}") + forceRemoveFragment() + } + } + } + + // Force remove this fragment from fragment manager as a last resort + private fun forceRemoveFragment() { + try { + val fragmentManager = parentFragmentManager + if (!fragmentManager.isDestroyed) { + val transaction = fragmentManager.beginTransaction() + transaction.remove(this) + transaction.commitAllowingStateLoss() + + // Schedule a delayed callback to ensure cleanup happens + mainHandler.postDelayed({ + isShowing.set(false) + notifyDismissComplete() + }, 500) + } else { + isShowing.set(false) + notifyDismissComplete() + } + } catch (e: Exception) { + Log.e(TAG, "Error during force remove: ${e.message}") + isShowing.set(false) + notifyDismissComplete() + } + } + + // Notify parent this fragment is dismissed + private fun notifyDismissComplete() { + onDismissCallback?.invoke() + // Clear callback to prevent memory leaks + onDismissCallback = null + } + + private fun cleanupResources() { + // Only clean up once to avoid double resource cleanup + if (hasCleanedUp.getAndSet(true)) return + + try { + // If binding is initialized, remove webview from root + _binding?.let { binding -> + binding.root.removeView(webView) + } + + // Cancel any coroutines in KetchWebView and fully tear down webview to prevent memory leaks + webView?.kill() + + // Set webview reference to null to prevent memory leaks + webView = null + } catch (e: Exception) { + Log.e(TAG, "Error cleaning up resources: ${e.message}") + } + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -98,9 +209,65 @@ internal class KetchDialogFragment() : DialogFragment() { } } - fun show(manager: FragmentManager, webView: KetchWebView) { - this.webView = webView - super.show(manager, TAG) + fun show(manager: FragmentManager, webView: KetchWebView, onDismiss: () -> Unit) { + if (isShowing.getAndSet(true)) { + // Already showing, don't add again + return + } + + try { + this.webView = webView + this.onDismissCallback = onDismiss + + // Check if fragment manager is valid and not destroyed + if (manager.isDestroyed) { + Log.e(TAG, "FragmentManager is destroyed, cannot show dialog") + isShowing.set(false) + notifyDismissComplete() + return + } + + // Check for any existing fragment with the same tag and remove it + val existingFragment = manager.findFragmentByTag(TAG) + if (existingFragment != null) { + try { + Log.d(TAG, "Found existing fragment, removing it first") + val transaction = manager.beginTransaction() + transaction.remove(existingFragment) + transaction.commitAllowingStateLoss() + manager.executePendingTransactions() + } catch (e: Exception) { + Log.e(TAG, "Error removing existing fragment: ${e.message}") + } + } + + // Now show this fragment + val transaction = manager.beginTransaction() + transaction.add(this, TAG) + transaction.commitAllowingStateLoss() + + // Execute transaction immediately + try { + manager.executePendingTransactions() + } catch (e: Exception) { + Log.e(TAG, "Error executing pending transactions: ${e.message}") + + // Schedule a check to verify if the fragment was actually added + mainHandler.postDelayed({ + if (!isAdded && isShowing.get()) { + Log.d(TAG, "Fragment wasn't properly added, notifying dismissal") + isShowing.set(false) + notifyDismissComplete() + } + }, 500) + } + + } catch (e: Exception) { + Log.e(TAG, "Error showing dialog: ${e.message}") + isShowing.set(false) + notifyDismissComplete() + cleanupResources() + } } companion object { From 0c4f4d253e2261839e34f058a75e9d4147759a0e Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Tue, 11 Mar 2025 23:48:59 -0600 Subject: [PATCH 25/30] still works --- .../src/main/java/com/ketch/android/Ketch.kt | 58 +++--------- .../ketch/android/ui/KetchDialogFragment.kt | 89 +++---------------- 2 files changed, 22 insertions(+), 125 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 0236a237..53438d19 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -347,53 +347,38 @@ class Ketch private constructor( } override fun showPreferences() { - // Synchronize on our lock object to prevent race conditions + // Simple synchronization to prevent race conditions synchronized(lock) { - if (!isActivityActive()) { - Log.d(TAG, "Not showing as activity is not active") - return - } - - // Ensure we aren't already showing a dialog + // Don't show if we're already showing an experience if (isShowingExperience || findDialogFragment() != null) { - Log.d(TAG, "Not showing as dialog already exists") + Log.d(TAG, "Not showing as already showing an experience") return } - + // Set flag to indicate we're showing an experience isShowingExperience = true try { val dialog = KetchDialogFragment.newInstance() - - // Store reference to the dialog fragment - activeDialogFragment = WeakReference(dialog) - fragmentManager.get()?.let { fm -> if (!fm.isDestroyed) { - // Pass dismissal callback to properly reset state dialog.show(fm, webView) { - // This callback will be invoked when the fragment is fully dismissed + // Reset state on dismissal isShowingExperience = false - activeDialogFragment = null - this@Ketch.listener?.onDismiss(HideExperienceStatus.None) } this@Ketch.listener?.onShow() } else { isShowingExperience = false - activeDialogFragment = null Log.e(TAG, "FragmentManager is destroyed, cannot show dialog") this@Ketch.listener?.onError("FragmentManager is destroyed, cannot show dialog") } } ?: run { isShowingExperience = false - activeDialogFragment = null Log.e(TAG, "FragmentManager is null, cannot show dialog") this@Ketch.listener?.onError("FragmentManager is null, cannot show dialog") } } catch (e: Exception) { isShowingExperience = false - activeDialogFragment = null Log.e(TAG, "Error showing dialog: ${e.message}") this@Ketch.listener?.onError("Error showing dialog: ${e.message}") } @@ -461,26 +446,22 @@ class Ketch private constructor( } override fun onClose(status: HideExperienceStatus) { - // Dismiss dialog fragment + // Dismiss dialog fragment safely synchronized(lock) { val fragment = findDialogFragment() if (fragment != null) { try { - // Pass the status to the dismissal callback (fragment as? KetchDialogFragment)?.dismissAllowingStateLoss() - // Note: The callback in the fragment will handle resetting isShowingExperience and activeDialogFragment this@Ketch.listener?.onDismiss(status) } catch (e: Exception) { Log.e(TAG, "Error dismissing dialog: ${e.message}") // Ensure state is reset even if dismissal fails isShowingExperience = false - activeDialogFragment = null this@Ketch.listener?.onDismiss(status) } } else { // Even if fragment isn't found, reset state isShowingExperience = false - activeDialogFragment = null this@Ketch.listener?.onDismiss(status) } } @@ -492,19 +473,17 @@ class Ketch private constructor( } override fun onTapOutside() { - // Dismiss dialog fragment + // Dismiss dialog fragment safely synchronized(lock) { val fragment = findDialogFragment() if (fragment != null) { try { (fragment as? KetchDialogFragment)?.dismissAllowingStateLoss() - // Note: The callback in the fragment will handle resetting isShowingExperience and activeDialogFragment this@Ketch.listener?.onDismiss(HideExperienceStatus.None) } catch (e: Exception) { Log.e(TAG, "Error dismissing dialog on tap outside: ${e.message}") // Ensure state is reset even if dismissal fails isShowingExperience = false - activeDialogFragment = null this@Ketch.listener?.onDismiss(HideExperienceStatus.None) } } @@ -512,20 +491,12 @@ class Ketch private constructor( } private fun showConsentPopup() { - // Synchronize on our lock object to prevent race conditions synchronized(lock) { - if (!isActivityActive()) { - Log.d(TAG, "Not showing as activity is not active") - return - } - - // Ensure we aren't already showing a dialog if (isShowingExperience || findDialogFragment() != null) { - Log.d(TAG, "Not showing as dialog already exists") + Log.d(TAG, "Not showing as already showing an experience") return } - - // Set flag to indicate we're showing an experience + isShowingExperience = true try { @@ -536,34 +507,25 @@ class Ketch private constructor( isCancelable = !disableContentInteractions } - // Store reference to the dialog fragment - activeDialogFragment = WeakReference(dialog) - fragmentManager.get()?.let { fm -> if (!fm.isDestroyed) { - // Pass dismissal callback to properly reset state dialog.show(fm, webView) { - // This callback will be invoked when the fragment is fully dismissed + // Reset state on dismissal isShowingExperience = false - activeDialogFragment = null - this@Ketch.listener?.onDismiss(HideExperienceStatus.None) } this@Ketch.listener?.onShow() } else { isShowingExperience = false - activeDialogFragment = null Log.e(TAG, "FragmentManager is destroyed, cannot show dialog") this@Ketch.listener?.onError("FragmentManager is destroyed, cannot show dialog") } } ?: run { isShowingExperience = false - activeDialogFragment = null Log.e(TAG, "FragmentManager is null, cannot show dialog") this@Ketch.listener?.onError("FragmentManager is null, cannot show dialog") } } catch (e: Exception) { isShowingExperience = false - activeDialogFragment = null Log.e(TAG, "Error showing dialog: ${e.message}") this@Ketch.listener?.onError("Error showing dialog: ${e.message}") } diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt index 9fa904a5..c984de9c 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt @@ -6,8 +6,6 @@ import android.content.res.Configuration import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.os.Bundle -import android.os.Handler -import android.os.Looper import android.util.Log import android.view.Gravity import android.view.LayoutInflater @@ -31,18 +29,8 @@ internal class KetchDialogFragment : DialogFragment() { // Flag to track if we've already cleaned up resources private val hasCleanedUp = AtomicBoolean(false) - // Flag to track if the fragment has been added to the manager - private val isShowing = AtomicBoolean(false) // Callback to notify the parent when this fragment is dismissed private var onDismissCallback: (() -> Unit)? = null - // Main thread handler for delayed operations - private val mainHandler = Handler(Looper.getMainLooper()) - - override fun onAttach(context: Context) { - super.onAttach(context) - // Set showing flag when fragment is attached - isShowing.set(true) - } override fun onCreateView( inflater: LayoutInflater, @@ -80,7 +68,6 @@ internal class KetchDialogFragment : DialogFragment() { override fun onDetach() { super.onDetach() - isShowing.set(false) // Notify parent this fragment is fully detached notifyDismissComplete() } @@ -91,55 +78,26 @@ internal class KetchDialogFragment : DialogFragment() { } override fun dismiss() { - if (isShowing.get()) { - cleanupResources() + cleanupResources() + try { + super.dismiss() + } catch (e: Exception) { + Log.e(TAG, "Error during dismiss: ${e.message}") try { - super.dismiss() - } catch (e: Exception) { - Log.e(TAG, "Error during dismiss: ${e.message}") - try { - dismissAllowingStateLoss() - } catch (e2: Exception) { - Log.e(TAG, "Error during fallback dismissAllowingStateLoss: ${e2.message}") - forceRemoveFragment() - } + dismissAllowingStateLoss() + } catch (e2: Exception) { + Log.e(TAG, "Error during fallback dismissAllowingStateLoss: ${e2.message}") + notifyDismissComplete() } } } override fun dismissAllowingStateLoss() { - if (isShowing.get()) { - cleanupResources() - try { - super.dismissAllowingStateLoss() - } catch (e: Exception) { - Log.e(TAG, "Error during dismissAllowingStateLoss: ${e.message}") - forceRemoveFragment() - } - } - } - - // Force remove this fragment from fragment manager as a last resort - private fun forceRemoveFragment() { + cleanupResources() try { - val fragmentManager = parentFragmentManager - if (!fragmentManager.isDestroyed) { - val transaction = fragmentManager.beginTransaction() - transaction.remove(this) - transaction.commitAllowingStateLoss() - - // Schedule a delayed callback to ensure cleanup happens - mainHandler.postDelayed({ - isShowing.set(false) - notifyDismissComplete() - }, 500) - } else { - isShowing.set(false) - notifyDismissComplete() - } + super.dismissAllowingStateLoss() } catch (e: Exception) { - Log.e(TAG, "Error during force remove: ${e.message}") - isShowing.set(false) + Log.e(TAG, "Error during dismissAllowingStateLoss: ${e.message}") notifyDismissComplete() } } @@ -210,23 +168,10 @@ internal class KetchDialogFragment : DialogFragment() { } fun show(manager: FragmentManager, webView: KetchWebView, onDismiss: () -> Unit) { - if (isShowing.getAndSet(true)) { - // Already showing, don't add again - return - } - try { this.webView = webView this.onDismissCallback = onDismiss - // Check if fragment manager is valid and not destroyed - if (manager.isDestroyed) { - Log.e(TAG, "FragmentManager is destroyed, cannot show dialog") - isShowing.set(false) - notifyDismissComplete() - return - } - // Check for any existing fragment with the same tag and remove it val existingFragment = manager.findFragmentByTag(TAG) if (existingFragment != null) { @@ -251,20 +196,10 @@ internal class KetchDialogFragment : DialogFragment() { manager.executePendingTransactions() } catch (e: Exception) { Log.e(TAG, "Error executing pending transactions: ${e.message}") - - // Schedule a check to verify if the fragment was actually added - mainHandler.postDelayed({ - if (!isAdded && isShowing.get()) { - Log.d(TAG, "Fragment wasn't properly added, notifying dismissal") - isShowing.set(false) - notifyDismissComplete() - } - }, 500) } } catch (e: Exception) { Log.e(TAG, "Error showing dialog: ${e.message}") - isShowing.set(false) notifyDismissComplete() cleanupResources() } From 7966cfa762d9f00bdfe44b05bd5dc8466a64376c Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Tue, 11 Mar 2025 23:54:49 -0600 Subject: [PATCH 26/30] final cleanup --- .../src/main/java/com/ketch/android/Ketch.kt | 7 +- .../ketch/android/ui/KetchDialogFragment.kt | 68 ++++--------------- window_dump.xml | 1 + 3 files changed, 19 insertions(+), 57 deletions(-) create mode 100644 window_dump.xml diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 53438d19..935fb12b 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -347,11 +347,10 @@ class Ketch private constructor( } override fun showPreferences() { - // Simple synchronization to prevent race conditions + // Quick early return if already showing a dialog synchronized(lock) { - // Don't show if we're already showing an experience if (isShowingExperience || findDialogFragment() != null) { - Log.d(TAG, "Not showing as already showing an experience") + Log.d(TAG, "Not showing as dialog already exists") return } @@ -363,7 +362,7 @@ class Ketch private constructor( fragmentManager.get()?.let { fm -> if (!fm.isDestroyed) { dialog.show(fm, webView) { - // Reset state on dismissal + // Reset flag when dialog is dismissed isShowingExperience = false } this@Ketch.listener?.onShow() diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt index c984de9c..f0db8200 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt @@ -18,7 +18,6 @@ import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager import com.ketch.android.R import com.ketch.android.databinding.KetchDialogLayoutBinding -import java.util.concurrent.atomic.AtomicBoolean internal class KetchDialogFragment : DialogFragment() { @@ -26,10 +25,6 @@ internal class KetchDialogFragment : DialogFragment() { private val binding get() = _binding!! private var webView: KetchWebView? = null - - // Flag to track if we've already cleaned up resources - private val hasCleanedUp = AtomicBoolean(false) - // Callback to notify the parent when this fragment is dismissed private var onDismissCallback: (() -> Unit)? = null override fun onCreateView( @@ -61,7 +56,15 @@ internal class KetchDialogFragment : DialogFragment() { } override fun onDestroyView() { - cleanupResources() + // Clean up resources + try { + _binding?.root?.removeView(webView) + webView?.kill() + webView = null + } catch (e: Exception) { + Log.e(TAG, "Error cleaning up resources: ${e.message}") + } + _binding = null super.onDestroyView() } @@ -69,16 +72,11 @@ internal class KetchDialogFragment : DialogFragment() { override fun onDetach() { super.onDetach() // Notify parent this fragment is fully detached - notifyDismissComplete() - } - - override fun onDestroy() { - cleanupResources() - super.onDestroy() + onDismissCallback?.invoke() + onDismissCallback = null } override fun dismiss() { - cleanupResources() try { super.dismiss() } catch (e: Exception) { @@ -87,47 +85,11 @@ internal class KetchDialogFragment : DialogFragment() { dismissAllowingStateLoss() } catch (e2: Exception) { Log.e(TAG, "Error during fallback dismissAllowingStateLoss: ${e2.message}") - notifyDismissComplete() + onDismissCallback?.invoke() + onDismissCallback = null } } } - - override fun dismissAllowingStateLoss() { - cleanupResources() - try { - super.dismissAllowingStateLoss() - } catch (e: Exception) { - Log.e(TAG, "Error during dismissAllowingStateLoss: ${e.message}") - notifyDismissComplete() - } - } - - // Notify parent this fragment is dismissed - private fun notifyDismissComplete() { - onDismissCallback?.invoke() - // Clear callback to prevent memory leaks - onDismissCallback = null - } - - private fun cleanupResources() { - // Only clean up once to avoid double resource cleanup - if (hasCleanedUp.getAndSet(true)) return - - try { - // If binding is initialized, remove webview from root - _binding?.let { binding -> - binding.root.removeView(webView) - } - - // Cancel any coroutines in KetchWebView and fully tear down webview to prevent memory leaks - webView?.kill() - - // Set webview reference to null to prevent memory leaks - webView = null - } catch (e: Exception) { - Log.e(TAG, "Error cleaning up resources: ${e.message}") - } - } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -200,8 +162,8 @@ internal class KetchDialogFragment : DialogFragment() { } catch (e: Exception) { Log.e(TAG, "Error showing dialog: ${e.message}") - notifyDismissComplete() - cleanupResources() + onDismissCallback?.invoke() + onDismissCallback = null } } diff --git a/window_dump.xml b/window_dump.xml new file mode 100644 index 00000000..7ccc5be4 --- /dev/null +++ b/window_dump.xml @@ -0,0 +1 @@ + \ No newline at end of file From df28ba163c19341e3da2db9ca3a6d406f6036de5 Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Wed, 12 Mar 2025 00:11:13 -0600 Subject: [PATCH 27/30] updates --- window_dump.xml | 1 - 1 file changed, 1 deletion(-) delete mode 100644 window_dump.xml diff --git a/window_dump.xml b/window_dump.xml deleted file mode 100644 index 7ccc5be4..00000000 --- a/window_dump.xml +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file From cd83a22871984b31eb8f0ba11bc161524a0843b6 Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Wed, 12 Mar 2025 00:18:35 -0600 Subject: [PATCH 28/30] cleanup --- ketchsdk/src/main/java/com/ketch/android/Ketch.kt | 12 +----------- .../java/com/ketch/android/ui/KetchDialogFragment.kt | 3 --- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 935fb12b..26b58fce 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -33,7 +33,7 @@ class Ketch private constructor( private var jurisdiction: String? = null private var region: String? = null - // Add a flag to track if we're already showing an experience to prevent multiple overlapping experiences + // Flag to track if we're already showing an experience to prevent multiple overlapping experiences @Volatile private var isShowingExperience = false @@ -85,7 +85,6 @@ class Ketch private constructor( synchronousPreferences: Boolean = false, bottomPadding: Int = 0, ): Boolean { - // Check if we're already showing an experience if (isShowingExperience) { Log.d(TAG, "Not loading as an experience is already being shown") return false @@ -124,7 +123,6 @@ class Ketch private constructor( synchronousPreferences: Boolean = false, bottomPadding: Int = 0, ): Boolean { - // Check if we're already showing an experience if (isShowingExperience) { Log.d(TAG, "Not showing consent as an experience is already being shown") return false @@ -163,7 +161,6 @@ class Ketch private constructor( synchronousPreferences: Boolean = false, bottomPadding: Int = 0, ): Boolean { - // Check if we're already showing an experience if (isShowingExperience) { Log.d(TAG, "Not showing preferences as an experience is already being shown") return false @@ -206,7 +203,6 @@ class Ketch private constructor( synchronousPreferences: Boolean = false, bottomPadding: Int = 0, ): Boolean { - // Check if we're already showing an experience if (isShowingExperience) { Log.d(TAG, "Not showing preferences tab as an experience is already being shown") return false @@ -320,7 +316,6 @@ class Ketch private constructor( private fun createWebView(shouldRetry: Boolean = false, synchronousPreferences: Boolean = false): KetchWebView? { synchronized(lock) { - // First check if a fragment is already showing - if so, don't create a new WebView if (isShowingExperience || findDialogFragment() != null) { Log.d(TAG, "Not creating WebView as experience is already being shown") return null @@ -347,7 +342,6 @@ class Ketch private constructor( } override fun showPreferences() { - // Quick early return if already showing a dialog synchronized(lock) { if (isShowingExperience || findDialogFragment() != null) { Log.d(TAG, "Not showing as dialog already exists") @@ -400,10 +394,7 @@ class Ketch private constructor( } override fun onConfigUpdated(config: KetchConfig?) { - // Set internal config field this.config = config - - // Call config update listener this@Ketch.listener?.onConfigUpdated(config) if (!showConsent) { @@ -467,7 +458,6 @@ class Ketch private constructor( } override fun onWillShowExperience(experienceType: WillShowExperienceType) { - // Execute onWillShowExperience listener this@Ketch.listener?.onWillShowExperience(experienceType) } diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt index f0db8200..ee9f8f2d 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchDialogFragment.kt @@ -56,7 +56,6 @@ internal class KetchDialogFragment : DialogFragment() { } override fun onDestroyView() { - // Clean up resources try { _binding?.root?.removeView(webView) webView?.kill() @@ -71,7 +70,6 @@ internal class KetchDialogFragment : DialogFragment() { override fun onDetach() { super.onDetach() - // Notify parent this fragment is fully detached onDismissCallback?.invoke() onDismissCallback = null } @@ -148,7 +146,6 @@ internal class KetchDialogFragment : DialogFragment() { } } - // Now show this fragment val transaction = manager.beginTransaction() transaction.add(this, TAG) transaction.commitAllowingStateLoss() From 27b7d091adde799cac9398cf85d03ff44ee082fb Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Wed, 12 Mar 2025 00:33:31 -0600 Subject: [PATCH 29/30] cleanup --- ketchsdk/build.gradle | 2 -- ketchsdk/src/main/java/com/ketch/android/Ketch.kt | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/ketchsdk/build.gradle b/ketchsdk/build.gradle index c2bb18aa..bb09ebe9 100644 --- a/ketchsdk/build.gradle +++ b/ketchsdk/build.gradle @@ -1,7 +1,5 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-parcelize' -// Temporarily disabling KAPT to fix the Java module system issue -// apply plugin: 'kotlin-kapt' apply plugin: 'org.jetbrains.kotlin.android' apply plugin: 'maven-publish' apply plugin: 'org.jetbrains.dokka' diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 26b58fce..3c51d127 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -33,11 +33,11 @@ class Ketch private constructor( private var jurisdiction: String? = null private var region: String? = null - // Flag to track if we're already showing an experience to prevent multiple overlapping experiences + // Flag to prevent multiple overlapping experiences @Volatile private var isShowingExperience = false - // Keep a reference to the active fragment for better cleanup + // Reference to the active fragment to do cleanup private var activeDialogFragment: WeakReference? = null // Lock object for synchronization @@ -243,7 +243,6 @@ class Ketch private constructor( } catch (e: Exception) { Log.e(TAG, "Error dismissing dialog: ${e.message}") } finally { - // Reset showing flag and reference regardless isShowingExperience = false activeDialogFragment = null this@Ketch.listener?.onDismiss(HideExperienceStatus.None) @@ -395,6 +394,7 @@ class Ketch private constructor( override fun onConfigUpdated(config: KetchConfig?) { this.config = config + this@Ketch.listener?.onConfigUpdated(config) if (!showConsent) { From 8ff30475d97b04e6ccecffaa62cdff6a5c0fd672 Mon Sep 17 00:00:00 2001 From: Joseph Chereshnovsky Date: Wed, 12 Mar 2025 00:36:28 -0600 Subject: [PATCH 30/30] updates --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9944a49f..e4e588a3 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ If you're developing or modifying the SDK and want to test your changes with the ```gradle // Include the Ketch SDK from the local repository -includeBuild('../../ketch-android') { +includeBuild('../../../../ketch-android') { dependencySubstitution { substitute module('com.github.ketch-com:ketch-android') using project(':ketchsdk') }