jsamp/0000755000175000017500000000000012730750345011474 5ustar sladensladenjsamp/.gitignore0000664000175000017500000000007712730747754013504 0ustar sladensladentarget velocity.log /.settings /.classpath /.project .DS_Store jsamp/README.md0000664000175000017500000000102212730747754012762 0ustar sladensladenJSAMP ===== JSAMP is a java library and toolkit for using SAMP, the Simple Application Messaging Protocol. JSAMP comprises a client library, a command-line toolkit, and a hub implementation. Find documentation for this package at http://www.star.bristol.ac.uk/~mbt/jsamp/. For more information on SAMP, see: * The SAMP standard, http://www.ivoa.net/documents/SAMP/ * SAMP info at IVOA, http://www.ivoa.net/samp/ JSAMP is currently developed by [Mark Taylor](http://www.star.bris.ac.uk/~mbt/) (m.b.taylor@bristol.ac.uk) jsamp/pom.xml0000664000175000017500000001735112730747754013034 0ustar sladensladen 4.0.0 org.astrogrid jsamp 1.3.5 JSAMP http://www.star.bristol.ac.uk/~mbt/jsamp/ scm:git:https://github.com/mbtaylor/jsamp.git scm:git:git@github.com:mbtaylor/jsamp.git Various components used for developing and deploying Simple Applications Messaging Protocol-compliant applications and middleware. 2008 mbt Mark Taylor m.b.taylor@bristol.ac.uk http://www.star.bristol.ac.uk/~mbt/ Bristol University ${project.build.directory}/docs yyyy/MM/dd HH:mm:ss UTF-8 1.3.5 www.star.bristol.ac.uk file:///homeb/mbt/public_html/jsamp/ src/java src/test/java src/resources true maven-surefire-plugin 2.16 -ea false false org/astrogrid/samp/**/*Test.java maven-compiler-plugin 3.1 1.4 1.4 maven-jar-plugin 2.4 true org.astrogrid.samp.JSamp all-permissions JSAMP ${maven.build.timestamp} maven-antrun-plugin pre-site Generating command usage strings @{command} run ant ant 1.6.5 ant-contrib ant-contrib 1.0b2 maven-deploy-plugin 2.8.1 maven-javadoc-plugin 2.9.1 http://docs.oracle.com/javase/6/docs/api/ http://ws.apache.org/xmlrpc/xmlrpc2/apidocs/ http://docs.oracle.com/javase/6/docs/api/ ${project.build.sourceDirectory}/../docs/packagelists/j2se http://ws.apache.org/xmlrpc/xmlrpc2/apidocs/ ${project.build.sourceDirectory}/../docs/packagelists/xmlrpc 1.4 maven-project-info-reports-plugin 2.7 https://github.com/mbtaylor/jsamp xmlrpc xmlrpc 1.2-b1 junit junit 3.8 test jsamp/src/0000775000175000017500000000000012730747754012277 5ustar sladensladenjsamp/src/resources/0000775000175000017500000000000012730747754014311 5ustar sladensladenjsamp/src/resources/org/0000775000175000017500000000000012730747754015100 5ustar sladensladenjsamp/src/resources/org/astrogrid/0000775000175000017500000000000012730747754017076 5ustar sladensladenjsamp/src/resources/org/astrogrid/samp/0000775000175000017500000000000012730747754020036 5ustar sladensladenjsamp/src/resources/org/astrogrid/samp/samp.version0000664000175000017500000000003212730747754022400 0ustar sladensladenSAMP REC 1.3 (2012-04-11) jsamp/src/resources/org/astrogrid/samp/web/0000775000175000017500000000000012730747754020613 5ustar sladensladenjsamp/src/resources/org/astrogrid/samp/web/clientaccesspolicy.xml0000664000175000017500000000077112730747754025222 0ustar sladensladen jsamp/src/resources/org/astrogrid/samp/web/crossdomain.xml0000664000175000017500000000114012730747754023652 0ustar sladensladen jsamp/src/resources/org/astrogrid/samp/jsamp.version0000664000175000017500000000001712730747754022555 0ustar sladensladen${pom.version} jsamp/src/resources/org/astrogrid/samp/images/0000775000175000017500000000000012730747754021303 5ustar sladensladenjsamp/src/resources/org/astrogrid/samp/images/eye.gif0000664000175000017500000000214112730747754022552 0ustar sladensladenGIF89a7BCHLͬQPUSNT88:ݕZX]LMQޚbbd]^bȳҚˈRRR=BF>Ze'-9¯===RW[Ξ  ѝVVVOV\.JU I_jBBB\ag "SSSBEJȩAYeOam󐔗DDDQRWŠ  ZZZ_^cvSc r А; @auc Jv5*0 pz?'%Y,۷o?9seݾĂay/l[K=!VVlͯpX\Ҳ  0=*FKaG @ӏ; $@!!FTK_|}[[B ? \[mα-fK.- rr]||,@v ;!|%rO/s~ @v= I!|| JJJD;A6 dN@.( {lLnM7p|33ܻw`v  yY=33T,`xuvvC_v-VUUwt @2dN `fega:x;?(1ôib2*b @vq:?(}8XSa&/ 6( *ڡV,~/+JyXl92Pv377' h0Т@vsoq8%Cqc1J^'999###"q@~怛o_|)$Åx%Ǐg3bJb0TaAvK|h Nt 3|} %\E:. 6@pgTͤx9oG3p+ep}9/Qpg5A?Av+ >@  x rWu1Tr@6[ɠ̠e"( l'rmG}am P!: U?C1!@;01;S1`ZM!$U@?7ĂnOsCiX]MLET|d@ NL\k0|UĠKiwx>ȝ j3 X@ytճU(#Jr뛟?_^陷`˷ _xh93ʂڜ[_<c`bbb-O|~X X#aZ B,FDLL߄[Kcd7~~}ǫ]V39h rcavBw;;Q;@tA]- t10IENDB`jsamp/src/resources/org/astrogrid/samp/images/phone2.gif0000664000175000017500000000043512730747754023167 0ustar sladensladenGIF89a EEEYYY222... ```w ***ggg! ,'di@ g[BP@(<`t1tœ<͒ @R %6TZ!,.$8 #!# Ir$ HE# Mqypdx\egcC//cB8Bcccccccccccccccccccccccccccccccccccccccccccccccccc0"5!1AQa"2q#BRb3Sr&!1A"Q2aRq ?Rĩb!*`ĂȖ`K pF$T%h.q 1(@Af(&\APcx]&YmqbWY.z S EKZ/l<@.GVO*vǬj*ز@̦'T$J>"E H"0m'#T /K3+, | Ƥ"XM0 -D)`bWx#mZtPpYssH s1W{#-#b9$y#7AL)RA*I'L1,rg4d0u38"w42]'(ǯѕ'ie# P6KNnӎ!Ջ1&# !P٢dcTLVHӱg*I9ѫs bG.63uLLh_#iux b;`O[L17ؽN]CndvADvPtr֙|._hǏe]d'83\' .ItsY 2IɐdT2Zp;ɞV2ňUQE[+8F *I$&VwNһΣ9V+<]jҷI'"[M3֕^fmzNmN$|՞n$İ'xI\w,0<6O~'H.G0ױ3$:g*v3Mv:E +N:e+UqJdq 8[GOtҍة8ƭ/"t*:3ɗU/stίZzXgr8+Z )w!Һ]=OXՓa.l˼i\ f@Q4qDzOq%E(.A6Q/:u&>!S,pKg:L}Y+3nz Fe4 Zҝk5Eπ%ACxXmAX;xhľfs9$b #Qqz,pjJ;g(X?s*!r@_'^hjW%TJRwK T-Hʜ0fq%MAm`3h<"r%{v1`tzpUyNv7Z<%L;,a /!tɔ6@L:ދR78ԏۨ;D? e| '4sN#.1#vRad#`dq.7@g(dc;5jNNfc_z"oМ%rZ֠y9m[88> ,dF&dgŌ9+8ںGiS )oh?R1Վgҿ3j!y=3R,mQͬwt u186).-pvx=3Ȉc\h7(" *;0w=o1'].(]+GG괵; ̻Q{uJvVzz'G5*5u*(6;wb[xjlb2:!b5ۤSNzr8+ccI aF zP]6Q}<26'IqL$%DE3(N8JDj>\ygg_[c/ۉ8M]{<:Mg b1'UG ̮!(GgSnm8c# NP/Ia`>RpA!2_J7PuKDH890sUvjg30r!F2i7m: }5N:s|J/Uv-縊`<`G6: ;Cv!kfJS' Kv孙+ӚyS4/3e ` ɷRPׂ|^߉Ȓ~d`+==6[*rLk=yӚ-nxxiM3c;{$zZBLxw|I^2QP0N0I'I$q̮.LN0 KF("3'LVY,4+eCayBOէ9$Hl$fi 'rKӨfBqٽk q\K9<]3VeNu-")6@ũq{-/8lr#/R(/mlz G"s#S8 ՞[qyz'USOoytz(0Gl' (^ԩ eg0 5uyGJ ;F.<ޝ̬FH0u \]cӷk9^EqiuMZtXc3&wVz+]q]I7~0[$!wQco9C5lv9#|G,L𲹷M忓hoj/Vγ.9#y Mtfc62̔±NMp}q*T35LR cۈd'*{GR@ζi90:d]*PчJ۳S 6[P 7&f`}Q;5_Pm繀Acut۶gFFv/$#EeP@c1IBj60_FE~LBFflYf6s|7FmÃ-n]`^PzTn~gAX8$8뺏0"*|H'W(H#q2'hCE$ O-)3'6Z髳$,~ + +.^oRs"Hqz:~A=b7t]Jm"9i4㞿JE< ?=o역Ify喢͈jmV~hG,x|QݵkD;l;yk\}xOL|ECT宄.zrKIJ'`Ac-Ɩa}"u,ZI'*2*N70e gRtj8oJb2D ~fK meS1^pdҶquLt g;gzoRcگxgRvE(J9BIɆF)ϖ±DQ$Ǔ&$xjE/p:'YD&`I$*EΠĿAK LdQ 3a}D٥e8?dOiܨ.O<}&;nB5vv3ѦRϘM-4՞|h=@Ǒ:]SX/45bN1bUedScFBjj+zZ5#Ԣ@n6\K9$2'sU{ݤbŎwl6:ؚŸ=*㏅eu jzHh D=-5bМXUW |6a⬺*K}s v@/D72ψF#AE;!@1[583vmaQ:,r;sI4Qiu V40U [9fSl )hsbH˜S*<̹*zM l$&Ocx!d"ˆ&ѕ3$M 1gNN]@ vc+OAjJ5v@j듣Qt=s=簞z>r.@!,6 ,1(P3E+71N0ࢶ5 92$5(.S)4f$4ih RQwOEP4ԸQVZt7Қ i79G.L=:Mc,Z2ӆuVGQv8#x nvPG)$LښJsX(g8uu# NmՕ>GqL.9%h4"8``q '=c1L[4K2DFMgq30{ Ve0>s8(12 RFKIZLQka$HɜLvSbce-k8I!ԚՍmu&I&WedɒI1I-PZ $+5Vutzj)#;$+n>ĴP&I$g.n #$.}V3xP$lD E I#"a큘.$eMWdV!A[$P0 [d=K?gc"i$5vjsamp/src/resources/org/astrogrid/samp/images/ears.png0000664000175000017500000000370212730747754022745 0ustar sladensladenPNG  IHDR bKGD pHYs  tIME /hOIDATHǭ]]U}>;3LN!ƈQDy4Dc<y%*$U[hL|N̽{޹s?Ϲ2BiN_Y{RW-fTԨv~ꮟߩRʢ:xlæPJqU6 t2,,eđ+G (])k}K2Y01"^R0 !6ѨWV|=Ovߞ݄=TS)[bߥ@sl=xq|ߧi&A#i47Zi/>4#[uv#FBy=n hzP^vAҤEs9|jnz\+WU$"?ΐ6qAF8lJf6 ݂DADhXҤ R,D ?ܸ[(%D)ʞ;~|ۦ-2ApieݸCh UHg@j4.aN#r]ւ4)64#DLt5;GjtIzLY6'O- 0 :R`q% Zq^kz-I :J׈S(C$ҵ1 m2 zDѭ%ˍY}ΦKd%4hwouН?KggjheDD8fY~AmD v itJ)0$D!S2zaѣG7 @7,%!@hyZAK Ûx=={2 vBC/X,o355Eg}|yi %2uaHYξj,.t: EŨ8 D&J)]VX8nm_=(jddby#͡Cx't* C[0- u?+ϩ´#Q߾k<;?Z9^=E]0ԯ𬚝W^GOJ߸<q!k8CIENDB`jsamp/src/resources/org/astrogrid/samp/images/disconnected-32.png0000664000175000017500000000336612730747754024705 0ustar sladensladenPNG  IHDR szzgAMAOX2tEXtSoftwareAdobe ImageReadyqe<IDATxb?rzzÇEab";ut8T%K@| _ YmLL̍'O>,..~ @XHq( ;7'Xϯ;^3AHHH282|a"J ֆ` | @803L6 ^Ba p10DT?s=#*Ϡ)" 2@Bҗ_8>°SA@ 05%:b> 6&8re `nn#$r[1]6^?tIFAP 0 5B@A R<~ d'@6 ׿} 1l!1'}P;\~t9|PV#;Ԏ03^ZYׁL#|bpuue())a>CB"gs6.8@ W6F@e;ٵVQ3Ë_@6Y X{+*tuP @e;H m|WǏ,@$@ubm8d"`\Qp /_~Z] @-gZ~$ ',.Y`hdx+(;a r`{@u!ff"z<<, \\, ̰Z ?~[7ot9 #-7Z~hjvNqB>&e0aYIENDB`jsamp/src/resources/org/astrogrid/samp/images/disconnected-24.gif0000664000175000017500000000045512730747754024663 0ustar sladensladenGIF89a"""2W$W%W&999Y/$Y0&3`MH`NIUUUgggfkkk! ,'d)6h#A鞖%l j|s 5!Ǥ!2i{H)4f$Gu9mrT*p@>v %S%1oq% wy7d?W B?,B;5I*EB+}=GIFGg( /,g$!;jsamp/src/resources/org/astrogrid/samp/images/tx3.gif0000664000175000017500000000022612730747754022510 0ustar sladensladenGIF89aw! ,go>BAFh-';? bW8>")N. rCrR#E: –O]e_"4Z,7g5ޟ9:r27HW;jsamp/src/resources/org/astrogrid/samp/images/connected-24.gif0000664000175000017500000000062312730747754024160 0ustar sladensladenGIF89a!"""- 67 IJ999"M'k"*o$+p$+p%G]A,p%H^AUUU1t)2t*2t+5w-6w.gggkkkf~ajdjdng! ?,pH,-4"˦HʱBK()CCi#t]_M ~voF! xMZLuLJGmPInSǤg^W\[`\^ `? Ex EA;jsamp/src/resources/org/astrogrid/samp/images/hub.png0000664000175000017500000000177012730747754022574 0ustar sladensladenPNG  IHDRw=bKGDtIME)IIDATHǝKlTu?0R@ZI } Zqe4i4)IMĨą14%n( 1ViSj\T!mA/ڡ;ef:`Ji$'N z`- :`?i xX};yn?"hMbh˛<)P> yv:k1.ǜ0>tMpx0N^`3p71~B"pf໕}qX4=4$NՐ\ ||--ȒM"9Nd&u?McnQ~(g!epmLݝ=,O%љvY{-*FE[F*/?G.$Zi` " tr`h~޿%!k7Fw=AxwVo RSVejYQh9l{ ¥+LB8>{_M91) xuk{>=`$Xl /J`Eca[Ԣ`+&-g}~PR,KBLB{NғuL$z*noة*|pCi- Lsuy>T n$J<XB|,^ӎ6@9Z0NDSwܐ_;Fw?3K8FJ5Eڏ3:7d454~B)C$V`S#S0p P952Qh*!IENDB`jsamp/src/resources/org/astrogrid/samp/images/bridge.png0000664000175000017500000000330312730747754023244 0ustar sladensladenPNG  IHDR szzbKGDtIME&*EgeIDATXíklTs{01x R>((jF _P$A%BCӨVRDI RԸ-F28lcv];]k Vݝ9?猠ud׾@J(_sP<ጏgUN\*af\" oW63d.xqG-8Zi|zZ(DKJLS ՞Yhf`Nč6 C @ RZ鱔J&S QJƶ,շGwe1r/~FnzH%ryw-z3Y'ڞ}XF-K~qNKOaj! v2| Hj2k+Y!p#@or1{ۢ_3( ٽd;r\ fW2{ E5 1s7w}-|T?k+=F~BJ 4svx h"7d@Fc!q/>o=bcc)LF)kb;' gWhsU O2;ċ/L2 hlpd4vO/ymyN盄eGK$)jsq6UO@[1.@7zѫX;pɑA=r^~V=)d4fl"@x“}]Ru<;Xzp vQ6@y^$B j] @iENJ n*& "sa/`D` Pk sk#"32k)VM E'*dr;@!TLP6X!- -8=@~d d43sr+|OYbp@Fg<8Tɱ +XV5) JUDL6+14:{}{}Kich€_P9'{{IWMZ;숩~n%S>|4 Xn2]S8b>VpQ~0^@OLVU^AYD^_id;gN:x6 h4) 7E$LsYUKڥe_U[PUHM}ÅK f)$+T;ػbںF "L0 dMV={oY嗎Ǜ*ZjRI}"jm̅B j ]@RI&gćI ٲځB ok==IOa&yZ360\3NAYoZPyOoҴ: y͊Èj<N6 "FC5Xu:k|8~>,G#@ou~SMZ!իFҪaм?}*&kgm0 tGs[mD3Z.;ՑH&cdV'Su>L&2鱣"grLIENDB`jsamp/src/site/0000775000175000017500000000000012730747754013243 5ustar sladensladenjsamp/src/site/resources/0000775000175000017500000000000012730747754015255 5ustar sladensladenjsamp/src/site/resources/bridge.pdf0000664000175000017500000311034512730747754017213 0ustar sladensladen%PDF-1.4 5 0 obj << /S /GoTo /D [6 0 R /Fit ] >> endobj 9 0 obj << /Length 748 /Filter /FlateDecode >> stream xڥUQO1 ~_$.$Nx"1ƨxchBs.wU Usl y"k㼺FʏW+_~:(Ø)]IZ f8e{-shRD ,c ͊B,zu ?/#ѷ%nԑ Y1gFP@bBir*;{nbU`&^vfOLǨjASF=DQ V)Y ݣUM˽jRIdS}UneKV}@:(%Ź!H촉)D}0 rT[6_7@d\qfa10kVٍ$sa*Փb{=Y Žmk:DfM$g hg)q95qvsu[)oQPN泻;LcqbWMit"ZO3tRTS@C=N}Qe>a4&>FG;Q@Ձ@0ղϱ@?hy&dQT6Dwy]Zj~k8ީjh^LF}0&1jz]6ֈ[mPaF_ ;c諮Gdt ^ %'/)E+Q],k\b\^W YIm;m+<15vy}aWw  endstream endobj 6 0 obj << /Type /Page /Contents 9 0 R /Resources 8 0 R /MediaBox [0 0 792 612] /Parent 22 0 R /Annots [ 21 0 R ] >> endobj 7 0 obj << /Type /XObject /Subtype /Image /Width 763 /Height 329 /BitsPerComponent 8 /ColorSpace /DeviceRGB /Length 415115 /Filter /FlateDecode >> stream xtUo?;;w4؃9'VV9GKrI`0i`ܒdl+u]}UWWWwTrˁ{)U+)e;5|Qw"QuIfdJŬG 'tu]^/CvFJ?RtQjn"]+dmEeCnש@`PƋuw rt>{(E$C >'\Ӆ4*r AJC| A`}Ø7l NX7`b PV$* JzYI2]?s# F}b܇Ǎ,`}Zn?O!xې gvss}Bn0_h}Xh-?٪ȭhWaALA[—kۑޘ&Ɂـ<ho\ϩyLv~Xr rٿHg@3=~~]7"t>B;l DI[ 9 T0Z,ix&.f{`NE@ krPua鱈sSxhUFY 5XhxV1>a0|6#+<>v XVZ̓!Ax@$t!=ck1FG9?4`Vf.eޘ%%B!#'\G;#y%bGC&xVc1@00cZf'Ѕ]1A@C9(,mZ0k`^.NYVݏpn1 cRtԌ<0F@j #7?Ng 0U%4:xC.x)k_-~u/yacϠagccx|{/7@`%o+pN̲1-Y*'1OYG 0|-=zmQw !@~0xCiU8 xyؔgCh Fh>.cOY9V̾-bˍezXČ/E :v/U_5M݊>G}eN]R.S58x6c&a# :j>a+eK\<|]krCJ^U yx8֕y!W}]k8(>%fC1"ws< G xxE|m +X`Qx`On8&bS" 㗤s68[t:G@C֌,h(F|(zG' o0)E X?y'}܎box@p;0ڕ\60( κoα"cp)!ck:. f=, < ۰I+>P N{ly[8D+t2|Izi d>dV@w B :b T "ғ ̀  mJ$VtAtzSۨ1$EW e=kkΨỵ"%&&iuȷ7m `a@_?_CE Bw"|I.!yOp2sJ0< J52j :JHiZoˎ,306!،ŘNbߓ P|Z53,;C+ xxg6>{<#P a^4BJdLځ&|-yOx{{2axH -[4Jښx[! 1 y <F['ҷKӀGz>Gx8Zɬ'm"6zڟǨTM8Y̧ s9HLK羋14W0vċbLl;.a,aE#s)f6>aAx@±$0h,*Z֮Wp N^I Cс0v͌6VOw+X3PI{s14O|Fx: xx-?] N\&&' c:DLJp^^kWX'#֩&y! x,Ҳrc[|.-TnĎA`Jš 8K˦ 9tj&?$ڊ^Ta Z,<|+c&`ԡ4T?|A˼<&qA˜ n?8p9٩ <|A|K6:㬠e#4hyYA><&[E?| o 6P1^&":ZT 59d6s5ͺ;¹]rV& ~רlhF3RXi|fheVm a$tƖxZ9-J>P!X r$}[@GQ5Gu¥遉NӝfhDA;" B,pb3Z]sVp:&VW_{ a3d "Jj3ʁmF^St0U~-lpi_%Mf}dh Bpiv-(/8 FdlG4D]`q} @5(O)u;髩/h)1_qr>Qw-Ӭx~ ˜~K;Z&&`t]M%{~Y'ѢDsKmSoSJc^DM&!zuFޤ5:uQ'  èk3il镥xiG ]WV73mKg`&OsksE Z>7lKQe) V?1- *?}d{*_? _|䆷^[Oo2\CЮV 8ǤoGcD6ZfĄtm;q|4 :'NW3RVU19:$Gn؆ [X)8G xlCGx O>akóhެѪ0(#v cl@Q(bIEaygkI%j]ոˆ6($4#G)mkzxB&F&kba=Fs20sʟ|h?xN]>57nO_M}(|֟Bg<8A2aH?M]?D/|"w|¢N~.-D8xAB63JFx8mq5 ':<&M;ʹh50sG_B=&.\<γhKA( g :/'d~Nܜp%6$lL_"~yOm-) qKZ z 6"@;:窷_ph1ޫe!i6Xys"&! x&2 8O[0!Vn4:(d7A".h٩c]!Lw~bχWTv]PN=Lz.yAmu0͈Koy#8^Jv?#>`k.ّ'5P)!.&=1i?en.)([X~ts&JsU")RuTޜE(E2(ݒ/A.bļ6 aa;J0gHs9{r<|K/΋ձ\)sGxԍFDi 1wa㊳v vF8 Y2{1TpĴ([S,ە;ɎB@FN,0$J@$k[qi9Z0q2V't{Ħ4߂k3wUĿXUmI|'&E/4/!dARyQA]O=謁dߗ/۳"qǒm.{z`[/w%hM.ZFE\Zzұv2y zH ->zȨ;u<|.->:83+ZakOKcݦ$N qQ䝲7kflO<^UƌkS=rIuyXM\U%v:LW]'l`f8A# >).xw̨cE5*n7=en'0G^1..r2h3CsG  I8Q A_bO=c c5 Z Tfy8VvX񷎀G>EÎ(;M2L5 ^ YA|::iaF]WڧvʘU*>+>:6ptGc`|M_]7.zsˊ"_8zsE􀪌U1#w-N^tAYiB\ #bNI_P='{Vǔn~WEޥsN :oGy-OY_?z']zOhk FuU-A_;HD'm`YJi$|1<|HA 0|Bd*sGuUxh:Rȼ8Dhiwd(hK-%a%G5tWiClP8EdAޞl{hw314H)Na[(aLpFW/>gh~iYx~czAdT5Oz'd2a11q&0eMф8'f4JD(N / (G/Z #+ȁbIQ9dґ"1 ika֠z#ۊaPD<<Q;bَUAG'WD\6'j[eUogӪIO"/SΜ PƯtNJѭD/a:jUrOptȰj4IoTèmJmG\ 1(\4O쩕YɻoЌOmF6 {DLՅ;vuӣjY\HP<ChO:vTztf=> 1vQ[:C9ʨQg0s3!wc8ǥ%t{Z/% 7ޜϕ!w`JNB%gK! L-Zk0OspDw賤l k%µMq+ѿ"U0&Q=UhQ2c#sQ_ G#kP>}4H JK+XV`iB6ˊ6,피Qu` z %\XWaRz@ayԅrlanڀ)..Q;8@P=v`ʅ-5 HGT~Ee͇ؔsdok'6V0/zMs7"k{^#K-OWs,\X ڑ焬M8,ي#勏V;$foQ{˓>9W?c{7/) >cZrV$srVJq$G0Fizũňy4poȾ7bl6^4"؃YGqfE;K4f;6Fz%D🖲|ۏ,ef*v#:_LhW}zճ"Pk>ؽRb3 [6xZo=s/-$x@n )ΊMrmt\TEc@>'f;^b0$6CbQv8eݫsO^=F͈9m['vq|lY^>cFdl{PT -Q GUI-/;vgo: x̴cP6XrX7-eblVN']3؈ÉC\x,a-=ٛ)E|TRx=NF6I 9}U$LJˈy]Gw_OO-+6dN.,m]$+Aj`#n6m0Pn諁H7 NѢ7j:2F'&Thwc{j[cxxn?q H@yAیhN){(կz>ٳ)(m)@J2"|gkC/lvHhwٿR[,؅= X􇒼) :;{b8q1cMN|:|֣gȌ?<;)TK${OrK)רUrg˻￳Cf>+O:#kK2dxpś/l 9ubYڱg=3 n,1r`40uQ𼪁kwBYDv4d77GrTvVNGô-EPqp+Gg/XYv仯{]セ,;lYa=n]LRm-LVpuY[N̊DϪȪ( ٰܰ&ˆyI^S 🞺hJԼй!s|&yg'wW;um&E]V urkτ5e1:Czgםe6pjDwvR{N򾭌K ZiXF$]Z> wu QY~{\}ԋg>Koԁr6.M?/7SmP7 Q)D}Zy~Om_5bdmF̲$hׂsNOtZL8#wrjЌX0iyʓKeEϓ}[ LW)E@; 7&_' p&y::A^w瀜8o*NrPYVʔ[~tג=sH9:䘣n4A ݈ q&zHQ8 W ={F=K~, n+pIÛ$xlPIBsrB P+^'x(w7 AlI_S~<џ>1O矏y<1 שߎNx'|ƌ-p4 RqZ<@;vZ_\IT}57Ωx(oEW.{ݕ6/Z{c‡[;'/[ɋU)%`TP {/o|j5h꠰sxtKs="fV$FGHZ0grt܈9YSfʏ=5+jaȅ1n&ź$Mz Gn׫Zq#o1AJ=׎W0)/j>zfMY?FMšG?){7zt=6߻l}E婍HS;~Wv~jCM9%Vqoq&$taZ:۽E-yٽu-r;h}oi #䑕]?zk#W"myTEJ6$NT , e*KI𝲶8&5|~Bx!(0"'멛?=@S:EN)A$IlYQ+oh16/i[7.M̎w߻ݕ,/LKY 3ֳjU֪8Z0nyAε׻>Y@2({@J3eI[y16V(U*B~c;'x(H: ;V'Jx&w>uI]Xh[SIx~M'!2D I-K'X01}ܼ?ȿNNyyLM(-xfZ8ߪᛋcf3(''-=x~b1>b}g-x*gZߌc)ה"Eu_P}mÁIWiYc1soJ9C+wɎ9Ɂ{7<ΕV^_k#\|R]t_RZT~]7؂* tFiю1cSPhqݑ`_A4 ϏT ]XBxGxBM+村;Ӆ"QiAsRg.$%tnQҢcSx_FbR&vG1< +hYͧHPfWVgcM/Y㟞>smA߬𽛊F`mm/W|[ueoA. gтť7o^w4ؤq!1hBv\ XxI0H X ; %i~ӳfO 0;%3lnYϲ4^Qz_T0^v"n~T_KӓrVpϽº]vz=K3v)  b.KxyG{V}|rw^{13hfA%KSg ]0rENTʬ5E+" .a60E!@,|xjPVZ?N= 6={^=ٛգ/Aڥ铠%Ձ#`D'aI5#|ͭZmT61pRA(ϲhIq@VxO+^e y]cSfgE/K]:8}ufІ(x_(im@YWN˨.@c,Q/yugeQGw.hK,}Ğ`#ۗn] ]M7.QJ¥av+~P^ $B <a45*[-px#4?Yв`qR2_H0 slYf_qx۲+vo(xʼߕ/…z(Uy/0zBgA˼1DNf$o?%#=y$xl[\I&MX"ǁg~}(ЖOsfi[G/ڷlwy|:]#O|Lú>mO?-O5z?>c{__{c~;E;r#VB~Zr-<&z ^ {֝ɬة}׏k;,,;(7731lv/_ql-~~׏G[u82읩3kjKDY{tjԩMVmSN]VRص"ֳ,ƽ4-|HY+m+ Q*ÿ<,h*5";wCEqdK'T$7T}?^))"NEsqOX]89b˲b$*IY[YviƑ+\k}V,5ɭGglgd vF]IAVbҶuȮ} zИzᄇw"aۈ8HޓZrH\% zPonT23= 5hϳV&$9t*CuEq#O"}9ݬe5Mf yIFB;Bn.$\0kk4jU,jz~:Mエӗ]&.xsoeGonj_cbL~NX_=o=d|{{lufTE;{NwXeA޳n_YQ;XG,,R4gKߟ bT}ZRI#U9sZJ]DSĒn5>uzY#LGO!W5 *&3$A,<7f?oiç/SV1ԉ?8zh[e‡ɾCe7/zj؇zu5ZQv2x,f:r+!ft 4>-+pjekE’y3|䅢Z.ϮY,oyzDE+3K]b\ #0<Ƴ2geUR+^%6橉g JdryK_+u}'ue`;93$Gyf&gE[$?fMe:IAoZRsݲ^UMo뤝KMq[h"**lc1oc]FR93-ָgEF\<'hbtJc8el)b±/<`#3 _xyY0X0pV~JXi@D>!X[]~3 u]NcxxX~QUSp ߷t˯"獏v90'w}ic< \}f>`¯Fxvym¯<_~?둌t^\gw-_]3|Aj幻֗R2c+m-pW_ (%]/Ѓ`T50 oFx=T eQ]Oi)>XxC@^4mܰ|Vhъxd Ǧ;}KAm?mgg <8ބ!-$ri%]LEWv\ُzk=_EǹEN|Ww}}Vy84Zv`RnU1;nTw?w(nÆut  O#XcNYȁ8ë=ͧvoLZ(>LtQ%&Ȁdg f0W,WkT1Cfi3l{!ɂ gWƻUĺ]X }F{Ov<;3`Kdr!+(uUZ@z jPB "`헟31C(T;Xo(];D6k:ua&{N7sΓ=K Ң|&n^ai 37HGnU WU\)tQ `Lsce# cPյ Z0iLgxXBo{ZKN޴t9 {' x<{bbGbB: xXx厇~cx놾^B_*$; ~ZvRZTFyJ׭Q/[n+=~f>cۤ?crVy=~;xƤ:^?^:>oф_><qnű1 b'{GM UYZ0muAxBY)muo h-u445(H M;="B2"IaufV a[ɬ΂_h1^k$F/4qۚ] Vg,YQ]scVK+"̀X4MA<i=*z9@e7jdj7uL^y^8<>BǡC}16D<+v7_-A/p1T#;0Ka'롳&$†yIQom+auqT6ud+: vAFfe^zrPD/֟ke_^:{|?3O1r{&Tg?5g.?q|٠}Ind֞ܚTA9<)>Jo6,M+X`uvHUy|yR̩c멇~Z7 *;,ǓQI@.VM".Ҩ쭦[ys;wX; CV\`uWJ SbK?hhFX֫ym`- J'҂U9@t?xFN5)>nR}V&/*q[$֭4ֽ,Σ,''|~VHW}f*\?4ҭ"λ<γ zAjTJ{W:0-l;&nk.B.10rp9n~ADQpQ"ZFzƏOU}m.lkc^NgYNP|$~6Jr4 ļcTWTv<6lqv< htmIfsi>afF!U6ZY{f̰9iab,L]1 !8\Wq.ȿi 4[A%iEFeu"kjg[][TT5)=-';>lNN´S/UxxuL b+KcNZup'xL4M;F {W51j&J x#5Z<(u7>&NBUU  ?-~ѤY?둴~Scܞ*u0eCeRflq1' 㯶}B鿄}S5ɐ xߪKHӁ;qAd.@q $YTYLqM/ϝT6 ~suR4Ձm؄NM^vArUZqb_AzJ+}zm4Iŋ h0jn\T"m|bd܈k3sM ^:oeߚ uY!krɿ@);e7/"N> ,<4 xhڑYxpV cP :c,u03]C3#0*mZj,-}æ~i4 LBWu[rcG, _XjIF\G였aSc}geF,7㹵o:{bE/m::1>xQ6Lj{47T"5kSV#=]DፉbM$IwGaE;'3KR4362u?$YI.cL"+IQW?xmswK*է l֊$EQ˒+=rBĺ' xB>(8'/tfvЌd IrgEWy'yM,q HK2eU;5M#V &4 Љz3 ,BR &RR"L^B kg_(9˛"ŸL̏Lr 꺬 a*Vl]S8 dMyn>}oLIB`޻l˶,Vq{`ztBH ^^0cιW ΐYo+q[]p QGGaK-ah;C5 kHJ>JQ\ZDٌ(2AVA3m͋'U7Ɇ&Ej}gG~Z)Z13iH(M%F+F@kB Y'؃g 6Ӈ% x%ự&uXN$V1#aД3M`Q{DveM̀VL7b&X2&xʱU Z `AdׅGEPFg21dՊpzArHJ8|vKm_=>t.L7󞔯օZoc7AxX!KCu#T=_%A8`$(<0h#/eKYm+Z67Uo {bnR)1刼$`8bVĀ R_>e#TY%Eg/]xZ,x4[oZI8OeGO<:d}102/%%&'9%Ȟo|AbCM (-dHL";4/NLvU3pe/x0L5aO5`{aex.5ڟCv_gc]E0wU'%7)蓭כϰ"H"›2  ƿT7Uv` GWa2xMJqxNcv~E<ړ_S4eQ>`?疭M` 4#n|Y3xQAZMsFh4 2B[puɃSBp1d2½)>8@v "yr1?ya۳>xsVbݞkX :o6%3ҋLyy1Ct&{g4g*-y##C'}QN 4n-4^V%`>jѲtpmŽ[dKH/d״@d_+`x}7`<z̥$9cHq> ^xr|m?Ո j3J;جsVA)y,_] < M pZ# ޠS u-5]aB0JvX祺3F8zpF1*ؙmO\DzDl-MʢP.<I;"COc<nA߁(zvu4& ^xF۾3aEdSɺ&LGPVafId.l<`4K7T}slozL?ػ=3h穈%n j%FYQȖ -Su"=ȸ~K/r7 酹"9-7gwrQ\+`w9Oͮw;,-gG%|?Qn#`@aC_: \!G5Xjwv&NNR5VzqF L 6(hj{@5pʆAӄ>ۑ9'hCoa3 T0 -T xFs,>kf(,aLsI .pK t5fK/ qVDSx$j _szxfS<_; 7X `s'%h ՈX P~G ӒŽ2nê[ޝxʊ3d%r[J24og)\A|{ݗ ?C)`0"&"o7L<eVoO6{*{-G6s+2!o =1YtdS>- $b]7bC\b[o4I_X.6`d>h6s[Ӊp<xR?^)%R2:Yo? fZ^Hs{ Vz}UÏ3I;̃as~43e+-fUҨu1S3<x vh'34K52~yr8"Uf(6Ω 0s+ry'94@>TxOwnYM uO XXFs3q0]Gsݳ1r(oCUW)$siIGCZmz7)f<ϩҚx&+<,zdbNdOKwe_^+hEkO &)>t2zvV-m8 j.ؕFٟI$3Q_]G7[Iy᮫9P](N%,W]a7'őYs* `kY-yTuXh7u>~P a%*H.^Fn2wF*|p3 iR)lV;pT]iƩGuVm؛7c WA6rVt蚂 xN2x e(<(hcuX$iUǪDH<ג(:xoSEؘBvyo8[xsr҂RtoTKf0O&L ' $栢:c5 ZGkrKos6I{Μilfg3ܯz\31[~`H1z}=/KY"4cf'-:,1hkBA=<{GɪÃĺst$4C PWtR< U x`<,mSl-w )1|=M"sixbk2.!͐{^ VY!Y!8)pa. ,KN>#GNp9*XĔ 1QǒDGG4E9O+1ab>L r<U%G \/"h{>.YqBHfY dM0:=ί8#$3Ð45ok6 p;7 M:ЗB3P`FcZC#}P(HO@\P No<:,3G"[uΟF%*$3g <"Ԅ/E)`-dyP,tPm7iVPU?xD/`='m.1ꂔI[i~2V]uh[3'CcձTkf`+0Wb7j՛=+):˨8%jKZ`rͲw-|c։9GP[Ƅ Q.:au#qTG%4W(1~yRfB If9߿x[YfY6_G)kJ7^Kקm?^ozT ^+j-X!0ՙ9?|lGxfZ.4udE JIJڮvv<ɦѾjUƺ`>H:zPǭU WKUFPƫ#ݒ- . s]&pPoyoIEMڜCl⢗cO'QtKSZ/v},_>(m_f,(&V~jJwHe4b]b-ĉKf\}Zak+,#N$+V a."Sw_Z>?2`o㗟eMt1 ĥo\XVMsIذ:g O */} d.Dy׆nG&l&k6D*yL\ i%!Bؑ7! &bچ 趂EUkS[\,f '17PiؘӵܒKCCO'"D㷊}i|$b!< =-53"L `eB$oLRlbϨUڤ5lz>cG\a Cmw4XGx4>(W1j{9n a3;ډfc a[GB̝1+ͨFUJ3a ׶5/h* 5կœp('kJ`%%ieV#n_5l<@Z*PUAݱ64+lmv"nbSqx;Lr֟To&Q;Y%†ٵ*Lr0(qaA(X]C\BXft_RGʣ#M9'ݏlQ*Wx/5tp70ҽ-3h6Bt, # ++N@T-Wiߜf9XͦG;kE 7)E7a>Ǩ`Wyd^~jPߝjyt˸mbvpzZ pQ \7Mj.Z7(lUJ;ev1y-S Aן<,⍓ %Wuu9r,pL3"(*'&`@0!;aBf2zZ{b-dYZ{eҜ3tsz(.#̙N '<{5 :ߨ>}v #h6006[#UORLkG { uO⑳̓M%DxƆ#_X~~zH^7f$Q"iΛUWxNF2aT!3\)KD YdJ/6%YBr \.n[9@bM f8 vMid3ɻƽT7lKLH3$^mN m4xG j#."d 5"l]=ӊ&&>(Z+a5)xoy{#h!en/Mˊt3\@ܽ.XE sRV L~ClpV [ص0VDkdhCL;${mMJ؛8z)tλ[҂ٟ8lgq'cD"lvjI`e1DS^q˪XQN4 '(GDև 2ޭGC tϴ@;Sܼ֭? Y]DH}pKåo/G'[J.D~LBFB&*ˡ@ق(T%S5+UՠY f<`K;N5Pqf`xÃ5SF;KZɪUAbꮛ[K\_cg]C\ 0&$77x6 Id;^ ߦ$;7=:21&{Փgـf7~D g1촅:O30jz<}D~]kt*o T񯃛<;'.Op\(80sd/+uY^ kI&D gw~Џ!YTt%R^5 !רue`YԊ*0?J1?䐩 @>Pb̠=!saytXpY:m͸>n3KDSs ur~C#)%sM?FtRu(}oNQ4Лƶɇѥ)UVLT FH"mDm lZfEN u^hWDF:`'DQXH>[ܬ,XhgMVtIzdt;fER8(5 06ῥ$F2M%xM*<`;:njJ1\ PG:\*vlhO]O"ٚ:ޛ7rПjN:J U|ϋ?m+ W0܍c] M\c f&f SHVDKd4]RKa:fޢ,V ? 0p=T 7RZ.(Ư1W+6ҒB`+7C4Q™kRo2)K:&䢗ၚuxd*V6/l}7@Ӛhdo @ Ixb-rsW g>+(+0E4 XF0}Vqf&#7cy)zi] MP 2v ^Q4U?ws #͈ko %;=/3!iU4 ~5('"97*,lpo?l{X?BIpZWuD!l딢FV*G0h0:ЎBӐ`Km_&Xᔴ37f!۫i&EsojUϯӱx<% <3 H0vԬ1 K1I?'wDɱOd]ՠ۰"(0B`;ߖ:+I!u-?&LSk b(d_ >9?/=,_N ^toG#4/R{R,JyrQ N㖑Mji&1V\{vB㘧uJIFBXzx~/3&SLE;hz[;Vdx<v1*0=,9!ˊpw5i1x6xNVgNg{S  ɔ1[Bק7eQb,N.sEM^x4>ޙ`FkĶ^4aNX.QR\1S̪|xI]#nUH|Pgv7J#2 Ɍhfɱ~I1`Jܽ)7>̫47nun A78塒oTc+ط fVnZ`5TFo]|DhQcnժ~cNmZ[ +k<̖1=(iye/ xG&TUNHvT+Q&< Cuߤe 9AC݌k]ueM\rca]F+Q|0NvJO)>mlz|ߦzkUj纼( 4"`k"iY ~Gv\ɍ*D|Q=EsUɆ֋Ȯ#V~_Y\GT=hsU!TLP nH%] KxP?jU ըO:|&T~5-rtnVi+߹yٽYm?l=#ʁq ZjHYFu ï*#eRpQxC@d9x7@8?+@"yT?}l4YNp?;mG sutwΫps=u(.FD햴XIxk}:2݌|lBtb& >+v3* '䑝IID p@Iq=# lBYQ0Pձ`F%h¨ܭ=u|X.ќ=a&j@ι-nxQMYn:3xRc2n"u-V{PEzj~I3"l8T<_N`E{[? 9=tϞy|S4*:i΅ŰJ̰*-OxmOO%SsBrC2augIgI1#Fť8yX-H:Y1I4|b}Uv8!P+I^ w|8_\Sd==n0?iHK[#$J) cj7Z1[=zB-lU%3j$-713/ƈ e%cȄȝeyI!fi>I^% ʽ|"=0DvW%0uw8  Ef|-p7t>ڰsVd(iLx󿁃s@?AS#t}SM3^1W7fr6S>5kSu'GMֈiCakwk\p]Mik l:AO0frU N9 RLKd lFxeuPOP1 BI `Z ҭxM"nT=DxKɽqqT\%矟D uL>gGwfF[I"f{S}%}VLEGm `ڏ6i f, m W]8Da$41"V5T E0W΂ee?>bafy@EE|UszG}yL_mgH 2cZGJ4;w3N$Y/=K(3~37rrSnUq:a"} K>kzuD@ƾ!x֩݀a,كg#xs3:?"[*I/01Uk 5J: U@jUam@9hAu1AnbPBžU|ro7ɲK'֜dS+@;#njlgcm &/Ў RoGH_h?ho0qqI?nC OAOexC.J< iu!gx <bGGxeF#K$W)N08f@j>ݕM-X{f$x)͊)NN tyy;Fo*JaPRD}sxc/a)%:]p_NfY'"npPzT*7eؤNٌy? g yeXjC 䏎]p/뀍y[Ԫ-ghwvoSƚpOKmuҦx*Nv9hzPCPL &9Բ"&cZ9[&_ T>y} 4xC_lJrҾܣ!~> ).7_U$e6m3`.u1_ms9#-aq. ]2Nq$0w`T87ci*22$ *h7{dG:XJhG=2Vx8JNӢCt9]珟ܬ޲3aيVw:.âuC<V Kq6W.,jSw> km*i\ pmrG7Ofi&\mzrX/?>1p箚ONŝ=uG/8ud{LO^u0%or"641@Y&.7vL1ɉgGgD1h8xiQL}Jk29=-#=!` '$Sl $w&T7ք`ѬL xiMۏF#6,X-#ݪh'qMKŘ*D(ʯ V, H آീ .fGf3]p-WͷX9'-& %; '6ksi.~{pփ咊 = ТvT>[yo_As17T"#^Lr3**-ʱ_`Z<xz 3/3Ӑ4.JXW%CŃD.Ä+}YWJvPZl[;]kg0H;&ˍxfF$hm;gMQjت2X6dՁ [kQ{QwtumZGaeKrÕzrZf)sPz9@x%hV ~7eGɭI; s(_o0: tX\5Qw;ٳhiI : Mj}0+1&ߡ8ԽJɆ{'"JN vX\oRpՂi 7`r:ab3R K =2ؾ,?}>>6=őo+qJ!?]VJnrDFM"r+J;<A̪QC9NI-1[\_5t)̀yv?8.ݭvRNN:;Ub:<]՟ݗGVѽM3)d0BagN[f{I?`g9֫ާzyeFzhRVg6uLvO~Q4\)Qˈ;\ilrS#hUZ \E4]`z1:-ǑP=RiNqTG3nj5U߁B;e7efbVLx=7)LW x^o `u)ru踐?>mT`"ӀZՆjk` JaZ1XZ/軄tx`ZN 3- CzթYZ` '6'nL=|N!ŀ`dy2N?,;K,ڡ7wڵ& NL"Zĸҽ-|,ӃCWd9I9ANqSo"ԕdt}n(A= v+kŀGX >XZ@$0|5rO)z7Ef+Y.e3RM%.4b=힮+?4oe2o϶. LAzYOB`ZE7)&9%>4fOXA-hVaS~zKQjޘ﷩ jN\><xmUɇmOBY&͡ µ0 ='K8'[/]H>VI!+1c'E&Z%ۇ{eyQ <m@6@A=xhS7kGBZ. h:j/f_CEס:@ 𼳟^`[®( ۢᶻj?3k8?PBRΐhG(J JTcFOgVR{,)'螼̬+&T,ȻeDõjwTo`8i 0gZF5!KSيO x Կ2yy!yE3% |*?Z)N$nFW(XqBn\x` E ^ǥ$7}wG0_]eiץy R[Jn˺qKP$,w圎;W}@?AzHրgq[ίp*С #`xx w kύr/T== *ӈp`Cu)/Kʨ_V|PW}7yNshK'پ6ˌ?bvLszGZ8g ?  xo()xO3Óv汞1$ ٱ$SnQqn7E F a]`-#ۯ&Lp^Uv oAT}`RҧO7eG꾿?/;6ɝ v |&8m= @>>1v8wؼwh=+(~X;}ٮ4׫voS+5Ã^֞"| r 6&g"= RytbDRc RCCQ޶ln\C*ɠfđ҂JBJ?):zD b|-@g5J>ڍ"6;.slNޙ jz;g̜Z` f3+Zh_BLg*h$k!s i %u#uwSᰴ<`D&QHߦZff+6wI;}Trh.Pь$\p*CWP Zѳ+Oxϔ >.hns==RۏD4j8dC' fH-u {ҼOl9'uq8 rJeZ}|,3NI>$|˺Hu1|}<Өdqlm潫d"t^hdaQjf%\SjO`*`ҡ9$g}pW,X &b1T5C!-?'<`,?p޶~̞gϟzrʙՅiNmd{ܷGs9Qkcg_{n6Rq =*^qf A(gJG*1Խd=7yIsXO,H#RuBc|̒Cp^&!_(VoXh(HEz[^wMՊhuR000sΉڀF`FD٦pE+KZWY:Ԍ*AI.E˱&aƈ*\&xxcsRe+v3cb *Pͮ:fh_ γk& hn?7%D]NT0_:0%~%#+J2(ogڸg~*m#UyL%#'d"r vo,=y1NZ~#(D⚱OxF[c4NPz!\e xf SwN2tNXμrVu__R &{6%ksIb=Xyxg~gݏGM:/@72@B$pjW*` V9" T`~#$dL,U!$ƭ y,c^#%: j@D NҎgݧv4PZٛ<]Wm[e"sj{JŴj( rt:%X2rB\3MkqX/<4`ÎKw V4@L[y rA-w*$r~˝VTÆ/ٚyz߃X]6)Rww vqVm0 6\J3@~I6wkAmÛJh6D/$g$~}8/lO'Fe)1N1`63𼴶\=L[lC4L NC4XE{eC]>Q=޷!lnauuVWYmbC~wiyO xdl na ?DXïax3i+2}2V&ވ}[VgVh/JPX[7҇NVӲŶ /m+DG7k (CP5SBHKsFX3xJM pܪj0tJ+bJ9SM͌*Js3\?֫7^9. lSoX1Ok}5ҡ9I2k쫼sC%fDk=JX=v(Ppj!_PE* 砾]S xsЈ <ABh]_]\~ 续7%?e,?bł7,in6]xnOƕi$GHb}n%y%k'/_Nȧ0tYuJ] yd׆K|N}Uzt_QGk4GH'yNM Ze0*x5yrQQi!rF+Fv̋p s7 'dGGMߵ{gr]}\-=9FHwf:&; u9{Q܆qv VISq ƚH9_ \ƴ4<|-V>yFOފzBTQܨW^-e[`T U| 1[Pt+}9 `%Wω+*"P/)I9"+K eDPF-K?N"9:<!mOO}8`@u5c?tN 86/죤h-W<{TuaMz`N WʅWEϮHga0'J:|EΪlUFCtt\kJ ur ,BG`c.5 WϳwTd q;=qs~GgsIsӄhb,1eKXE &Y8&w1ΞgEڂM,`sz7]<̖]Bp\yv~ (Aq X1h}jT\V(4?e8T&'5Оk]G*!2R)N!UAv1>~NmhN v$sQg=߼}ݙ{efqUro-KVdɶ{`{I#! +5 4wj90dyo>:U߿WmbJc "2P%U_oEzLa]tkG \rUPTL m0ͨ b J#:b@{J" eg:k< 8IPga"5/w|-+R\1v8v2#^ ˷9~fQA,osnM)d s8>"vN~ D1t/F=O$0N;8(E1pP#A;p~R f:6OٶC$?Pc}\i3%3s95`29UlgWgԨ[h/i&xl^!j@T&@03 <CĂ?Ȟ3L4ݘ xT#ϴMr](GڃxFݵێy0Se5ے:g^=xEݒ=¯7D22COmN:۽;HoPtj7M^6y--t̢( R3֌iT?6k54jt#gt(6tԔjgB3VB!%;ģq|C4-;%D!h"vbk^F}VqbKv/oIXO5$y]wZ|_3c 5  vmBѺxp[ 3,ʣeӝS2Y$ceNuT50}E1uϋ %xyltPp[l%-3)%/ #/@VOt,i{y)y#/Nрl,N8J` }7!Q-<%r,C2]yITfE~rBCMi>f1ݙM)JcfGTIP,4`I1Y^47ŏPT& j(?uς3<4M%jRTi'gR֪4jM5 9ONcQϠ.OE?:q!bۉ|Χh#L~eϓa< f.q3gn=2g.!:ZQ3Djz];ލi~´{7tVfeP S&$wHݬ.Lݜ%1J2GUҊ.\U:s$<=GAIGjXec5(HDЎaꨆsdE GI3<Ayi@NO;HPA,DmĻ6.,,ޚ8iǫeQ- -ӂ;}+'˲Hڗڔt"'%!*:d}bXh'6ˏXҝ`$&QmSh B)WOcwTÏޤ67%ApVQ8ˊS. GRl_noqdІTI1=(9hl\2<1)KSk9 x6lVb5]VH#iUߴ횻??ܺxz5x[B^\ ]v\Υz䅹ܿ#7I `:ٚrs.?@(ʎȬǫv?n1S0B D83f,h"&ڲ?y˺꣯xޜv>Q/1gxLc^/l7߶nfA ǀ遇>3-hG㡨'ЎVQS+GvI:N56xt~Q{ըv~S]֊0MPЭ;{}OsIɎ\燸p4~ogofos?ٙ[uENK.`71Z.|t5N~Ԏ\ݭS׊k4*S$iyyU*!y暱F`^xT]UDBTWܥw!JYүE+\lx?7^q_@ۯ+o=;{O㳵1<;29/p1}ei 9IH:3\C2Ϋ~B`3Ý݇aT#>5GUZqm['U1EP}׹ˋ)?YB4G4&I.A@SV%0OJoZLہKBL(^ ?^@2"]>ٟupG9dL:ʹ0[Jox;܄w$Fgs% ƥ#x&~kִn9HvS]d[il鬀@u ďIa.4Lo_Hfona^lD:D%e2@xʆ<ziޫ$z9(j3WKvcxRv*&1ݠR2 SN;Wս(] sX#\ A pYۗyxuEiu 1fUHomFjlpt/Ɉdh q(nې{59)X*䘀ȃRkv-{AT^UQKj7KUĝ_;|jYPfmOrc h-zyvXIg3_x~c/M;h@y~kť Z/<<'飬S?c1Q "Q٨$ A-GhxV2ZœrdP+':Pm.EL{շ~Ԋ.ݻVV;[zJ\Wbo|݊~q?O,D>=2hڔ,apyAT#7QJ6iʇàL *UC < 'ztxjzܤfJ\Ui*f$i6Bq;x 8Cq GU#piz ZnU'=mG'Vܥp[cB '/:-v7}߬yhY+n1~VaKd T qLe*݉庈,%'$X%ѭy~O˒ީE1EnxU:"iE1T8Y{qnw,tG)4s]Dq_A՜U>k2gf.mt"^b:-IN:%8(mL sf`p܊# ,S 4wMo*OQ#Lݏܑ>#ʣ"n ͎%K\P)$dDöVÀ#ki*>uXeܒzd[ƗH^AҬvLK9k9?1 '<&)Ȟ2+ĸ #0^kF0,A+Y`h-2&~B%ƸE1BL |煕i9GwQ-9޲@DžaPNxꚸ$S-/ҏ0DX,inK_;1t(-qtYRIi,tWy gUtHy:e%L+:i!zyKyj~m!Zc<LjE뱷FhQ % `F6ZG$EdZI2\)6tƻ#Q/"5P!QRU@svlJHaA031< |HH;`[ff#\nIНwO) 7l^[Qh[vw{^oĴ<3J~W_T~%" .~8K y <<ϋS3=oTM _>__}U!ˀjsbT>E28ʞ2l3cyB*t\l])RIU^Oqܔ_Py;6es{y^c}FyIڼH%z9-< ݈d8jcCRշ6 M\4?tf h@3֫?j}A'T4JV9䛸mr m$X=AcztV~jL\>8<ԙ2lmy\yC8U#CeTX5I 9&r؅7,C vٻL/7c8.5~,^Ge,瑍AIh 62vq~p"<%IY܉({ ͊(fcALsQzar|&ܫ$Ľ>-(W3%̡xu.oIbyZ;7|36Xz 7\3_!dS_E OS^U:Q Sٝ&Ђp$ uiϫ7;}X\jwE&uO1ku7s^=Mv]1ܢrtBٱddxG%TXS8|׺(5@N8Ui!X|RqJЁMq>p'*3'f%`S<7i2/r!ƾn #oO:o6)& z w \8bUl*Nv',$` fc[܇?}Gl'?= <31$ Z# ,#$d|r"'{p!5f&I3ob gJEpx@Q5w$ O4Α5~6-pgn*e#={N~pE {Չ`C!zYFfn ]LVf,!fEm_,ucQUks#Y;单/v|u}L{!vn G?SDmVm .l M7:jg@h uAz@Dr:6Տ_DM?C}Yx3 x=JT;o J/-v髆ψ ϴY$MD#$*F;ͬ7;>to}ɺ䐕KdnT8u1ϲ,ḙS(tP@wM 7yk5ZP#4j=֎hZL4RK9\ ?b5kƀZ''ԣi@LJ#p?w4=?"y^X&#cȺ8-0 $*/5#7w$U  Jl@7@+;&X# N1iELwA㟔ߚscrQ#Ks[0/^s[NN p0Tr;4n[il7{sz$k?A 7GuzN\?-ӎPNP =iM,atPH"o&Gh3MypO43 wd¤CD3@3%L4oG1H >!QI'ל\9xjq Zޝq|}tݎֽWo(㹒1)5CE.+f!KmDEաSj^)!bV-96<׼ \Ƞ9$'Q,R28΀:%g:ͪD:,j wKB-hKnNo=3#Y^ "~pLލ¼ "&OI,' ìn)ՙ!kshl]1k)1d.<:&QH w`Yr=bY}IxN58HiIN )n9g !$z;Q j7*Ӧ?Y\h[P9,)9ក= WEx0C^&TŁN b5_osZjD5ˌotPE!4Ra*daWx$D K#uo gHZ<9б\p3oaA9GGӃLC3^I<64}/gxf\NzAl>&ln(i;YFT-<<=8t+A\wݓY$ഩ7M+A>4DbFH锝g`oQ.!}XĦa LAo4\;59Akrx@2| 38S^ )v!voخx=uâ |߲ %lC -: +um-)xwd6VϠ^T?L7O3Vԏ"ПjTnԸ`@޿u@98]c!*9ϯ; 3W9c~Uc"OUPJVmЏƬkUn<9zl*p p]Ů]_Vȏf{-ӊ'~0W|&S#Bp' a?$yilHEm!!!$ PWABpLptE<x< (U"]By܁ܺ9 ?+mYO#EPnt/ #o_]YUoL:+#-p_b.uiӥ83+a'?eug>>%5h]'h-!k~+(;,CiQ$͖KZZsXk2IQ.\a?=L¤̌(]ٱL(2<&aKy!B4JY!sM=R:VcD4T_#^El#o1#(!9%#f 6Nvb; .t?0k:5ޗjIzI3es9l+}<3YxiyW?{$&Kw|7-#)9>.Ix$:&ќQil2+MǺ;܌_ 0Ql{M+&u>c]C)Iq^k0UhcKR->T#Kt?k`%_:a( ߉| Sm8"$JFs1<5":*!f=w҇6?l ubڛ?^>ym*d_Y67bcQtnbhNt_sWw}y;.qZ-͇n˼G:&>ަB $z[3npIk{תeC 4@ʱNDAګ6aS^D4WP޹ 5"#J`}*M?֥?xfpȻ F@5^w*g4 GT G:z؍IIF|I1:8~ͦ '<&?5ym^78C7.o,_o, ^0 @LwUvG3)?9sQ.0Xaz҉j[xIю`Ѣ~D*va.&ÒC ;??eB*=;Q^xw uf 6` vŜck 9sGGp}#CI& /+Q`w?__+֔3Ӿےrbcұu7 uH ̠1]|Mҩ60J"ș4tXMylBuq݅Y0#۲cT["c jZkL[v,1ouF`g/HJp)H (JI%aF{A# !K E/XyjPcP =/b! HDQͳg&}IG7h S?aWf]iU9u/AN|(x#N(kU/4ӌO;ZT W#h E!2H;Fh2uAZ#i&Dk0Z.nFl>E՞o`IiTGùX̧pm3m޴^7Ѽ;536.Kڐmmm8χtvZv SOFv!Mlze)ў80`yA/0GgҖ9RU.9=x fa`JunhlG>dWE{6uG-k Uy|7ʌw55oۇtѫ^K=.XW'wI%=OY7P@- t5WKV)G0X q 6քM(a:/6шM~GLfԧIT<A2 BzJ\#8Vx.a(tڱlB}WrT_#/wAF(˩~a`(;b#xh1 ?#ׁlʹ숽GO ٺaO gn=໪> 9JDPq:t]3I`V )|_([4ņ'Cf{ NC'%<#yOu:TD|7[x=L#~kPÊЀZlTCyi˽GUe .!""3hӟre69YU{Խ eCcAitCO=9}p@oE*{DY ZV28(6waccpN{ p"khL<~a%Ae%zX+z-G]m:-򫭀.Ei6Gu1zgΌ KD=.y /z6?]-ߧ8A1ͫbAI޻)=5fgpOQ2hgs7''1Y@,/`9vy ޫ37p S72Nej,9-ΝfHcYyy4Ga+n Hr sLX`-`”]fEm ٌgpHPMBtӠPp ~PE_N>C(w#{i\w9ll<ɋO sݐVH Me"|?S?aW?q^*<.MdE0&;[2#rýILWG?i)'*6 (Q<~ǟ#*]Q5W ^37.tWTm>ר&]1RB0e/TlG1x~Q]pL?8k8^)ÁHVEkzP_<&<(tAN\\tঞ:ۓvP 7<5[Q5ڄpy70;QRVɝJ4Gr5#F&5TiVw7#OތiZ5"6I LۥC**0 >ȔʑFzG:Ԫ=hF& SxJOϟ|SQZXWWe҅Apt8JPhu6pNT>¤gZR:3%E)ixA=(;P(24>:ܤWZA;8&o4DA5 A?@#h=loT}?T%w螋 {7N^3N wId8{ {i}~5QP5؊b(t-CuqJ;-Ǧw t7ujH3 i@QX8QuVbmbjE:x덙bzOTB^MBM43bT.>ѣ(X+P 3ӥ{$8VօHq;:›y +˩џp<43`C`'50qD#"vNbK繺p`Q=3tRڅ[ 0Ícx]$<'ArЁ ^W "䘀UY=[ # 2YJrAAl{ʊY.GC7f Xκ)l Y[ <\4箍 W{nRiE$?o R8\:ޡ*8ҩ.L} >ŤxP#Պ4)E%yzVڤŦzQ wNՠ=4|VҎ&F"cz$lp8 T є$]JXyR.u:u{C 5L""H >٧w4}~. `[7r=VJ"@ۏQ X"Z9\'}w"x#l0fYo&ހtR&[X==ZX=BtA!6h4EQ`NiaĴWEZ``+ ڙYyb{3y4{roWܬ;mZ,RH+ݬ;| p`PHݨY;9" ™FT#fFp43 ҡw+(EE;F-%ʇߣR?zO'u#u[lKZ4)&uz)'kFk$OauERugPhh)i9xK((ҚhV=V?Ғu>!c/ oAjzp- %(g8( s*"5R<3(vmՌ)2}H$=NۧFZ[hzQ~jthס u rB+f}@;Q1ճ_nYܻyBNbW9֩o n]}tgM>x&q'ЍfH}*ADbXk?kB: Z}zP}n{;0(?GfD2@"^ :H+^>v;B_=:ztA6׉'^N"³GʈwK;F"u#i r"DsxT55P^DjנW#W '|г s8ښNT#(epTiȩV 26uG+zىGA߀ ug=0w[;W7%&Dуlq$a(J2=͖Xyo.+K <G7c9=_(=i99bחCwj5?z~h753uS%&cOZ%L#&\1%8)7CUx!MJ(:`UXڰ+[M^Ħ.Ft@ɋ 0&L Dm$d1O#iƛ_71*Ewf %tFz4E9$m8B׌P|Ny me D+#Pl'FFݩFO%L$BUxulD"<pBRyO"Bd/зh3{T琟6^_` nԍB!LVpôx-rOnܕV`LWyd沄b=m٫Ao|jkMɵR7z'Pl}hI^fLr&y4`(bK.n ۭ5$6Pʃ噔hBf>mŶ,J$D{WHB o%bA3[ iGٚŞq kR)ۋ?ۙv`}LEq! ~yɁNpg.aàYx/\sh ^L 0par|Řdp7i<H0c=|]\imQ;:BT%z nDU:Sn>:H;N<~zbD𔉻?~tw*a~()7w]L,kb-L !jv^J͏.rX\rݱ_lRLMX) ;y!m~iʢh?HR6쭊l.6lz@)@UÌ[ڗhƺ4cݨk7ʽkdAk†6-mR<;N: o\"^щ ED[of4,ݵC5WdZ[.ԙnFUP;S|QU}k0oh1$zsͪr& _F )FO5z4/?r1? @⡮VL#*LUj%^]R_]"Ϫ,-YSY\bT1 1c ss۔){F{ Z(*P E |!gum˵Nؼ4ml.=X}x[k]rz\gN=uúW"ѧv qB_O& c97#wG:[gszxw 3}muK/Oa+]'S94<2Ђք@`' r_ 2/|jƎg{ >D =F} .n>g}{6L@]Phf^jNS4;7I5/#.rZόI~[I6INgU^stے3?h5w|^Ǟu_h%4݋\ëb(WC-6d0a1] Հ ׂKwӈs:o > I6^ݚ ,Z4jiFu8欓bzaIZq(YIFhr-QѤ4Qyҕ `HZ:OW=/ڦ(Ie%pch&-1!H*&ϒ] '?LgRgLdԈ}ELg͑9-lII A7,@)FĘVۗ׷-pǸ|Kd=mu;O xvr@WPgc0Є]nX{}r~M?]~i`yCiZ,BE&BN ECwMJӒ2d-Ã4* NRDDE`y٤V.5I,'B !U) U/ylh?̈seEh8CF.Av:͙=/G÷rÝhSXO]co%"¶5Yx`Z4N5EV6谏s#h<3:LM w4]K0Mز:zh0ob|7D{Ap_186b[倆pqž1n#!,Ut.J޷z{^"FtÍ1€w_ _1>8>Fl/ MAs#};֠;ف& ~F t5㋢_ؿx2z}>9#`hoޝ,>ԃ- Jj iky~ d!m }yt7%, Q !K4{@*^zs | H G _?h7jA͏ϙoyr7< O]ã֦ 8uԇauxՄU8C@X0:Rg;ZSͷ>;V;YDe(s ȉL%6}Uiʎ.MXb1O4%_AP<&xR>r= \Ip<8W&o/L$WFG~Zy凌!(O/ -hQPNjWtcT>0eYxVI"*CcIҦ Y.5Ir8R%6A'LVTcTpIQĩ?i8 DD ,:J p qGP\oYUnu^Rciuuq ;aNfi]H6XyQ+Jd_1$UdSX*$uh"Ħ &I-N40x IImdg"2,!HQ>"p҆\wk'rڀyV'mEsU]h~b c}7=wNܻwH'rg78qW7Xff!uG†7!uh gm۬聞sLw#0#t: ͠]Z? , 'F88̷̃KAƟ'b#T<ЊWA+%P6|6 Sw~q?cf;"]h/!rF6Fnهo9Mg [q%43|KőKjow˽4=޷޹~wѻ"ް? +_bh0m~{-D\_~|އ9zl﹍d{هٿ}eX 4p9 m>sgwt~G5G.Y`YUfEa|MiTeP '3ғMd|bG|:=Ldz0-{wC3lm󗶳`v [ +s7ty\z xz{ע:?rWiEʽN~pp{$قTpNR=#]~2]޲1ʦwV)X?Ʋ|sdAh"ɓGH%d,5STd BwuiK Bro͗\Z(4rZsӽ\?8 ,'ZgDzcyqܢDaAw~|i.Nc;]pn낷b/Z]95r͒ߑeu3cM阤 zf[Bb=*sS2s%)0O񌓆,͏d9/h8=U'>z[/"@Q' z\c /)QP 6|lzw@nJ^;zvob[0pr^ xOa#D>yۑO a)jc85^mye6c2l}Q绖 /~+6X\fYQH#:G9Zvo^xhMJ`EY[V8/;0(`#܃g(8GBfq#}b57Mrs!DMRbX%,̉]rͥ)-sV%9( bW$.+/NQ XNn`iRXş/jer3/G)&فr~;Day1yI==&9ڹ]\}纂v |r>6d? j_:."彗5~sˇ. ώË7ʃ;F;Ι\ #9 PO<x:fPOG &0Vpwh0@5Ch9Ić ]Ϗv=?ƀ7<3> #hCϢ ( ᠁pzq?0=OO#]bx(:Q405 }:mHwu8>{۠1Ɓ[Ã9BݺOO8G#l9D+ЅSUP=nyot}Ƒ5'+ 'r8{'~C(0 6ǣmpW7 ތ_ E}SwJش$fO}?sܬqävW-'h x??gn_E!)4>%i У{)(eS2|a"duHWGwVQ!3\'Q餡Yީ+?HAr~wޗG|sx}gh>4Ïv|laMm9S|}֎qk"m&oǵb7kzuhz18؉ZOŗ~M4alЈ>=ǽv-G DMU^,÷f#  t_ dhY{e0D}O}  EAo·sx $ (]=؀Jal' f20-k?^+>]-_>@ײu}h-]/@4cw}>^{ج c}7:/v8uK#7c>khn}m}Xcگwiyّ[}[@c}'w;zpl-'9gUW,\\*(`TѸdc;WȬ̋_VVa@(͊ƺ,[jR }E.J+y8ID4;p^4ăPMQG9AEqkJlչ1bȀiV5(M2u5 38$gqidye9FAff-gX>?vQQ?u0)DK1S- O4B?%ϗIp|fu}GtU"@P2T_n+0R$Ov3oVϬ inSfzLy$93At>gC|Wb\)N3J âQnN gbgk6?udyKJn,y>>|_!hqSm*?==Ph ;_^ӓhM~gIh{|sP-nwՏu:A#<!=/c#_Vs w2 z^]@@bD>h $Ҡm]|h@SZΠ-<l>6B,yly=~ (b?ŷ_n(1>ƒp{\9<|Rx_ ߺ u"%g~ %_>7>l~nFZ_\sUnJO6c;>wg05]u_{vaSK͆ -פ]^ТMvryug90?x~N9к=S~c\U"1dye+(%Fn& 6m2!%bH9ȀP#iRtuP̿(9 )3y3(jA<"ff$9!aG:3 h3Lrbnf酁 Zb˼$Ma8iËɚ0<0@)J$ES`$Ф Iu"'1ZNPp=L Bf<++ 3el7jdl8]x4@2B1CD΂ eB=$D3 ơ(T[* ca< ]b;4ß]8O6f{`B~wpMֽ3Wٜd% سr3X) C-v,$yYkyŪ<Fɜ kHZLPU/9$nSעQʳJ,uf }nu@>xT&={koqcظDZ9t+0}G[8;/|nojqx9'1r?o q?chM/Е3_[C Csfh+u]p4_;noA zh|OEH Ci<.D-x7N`z(e.l;(+~>x*yӠ}.Øב * :J}`σ*l@ E6\l /2 ;w7_=:WW#OGozpOOn׾Pa@ڞ.ϋۻ_lvtԵ<8~žG} ]ok<-o끺~|h?#0z~x|es`~{wg?:dH5zW5~ tn}y pi/=:6se}Tk~AMyqQv8Q߀.q (}uoXh.|-HԹGOt_GKgl};Wߐ̣ß<D$_y} E>u1iƇW_ٺei i]mg 9 ~j :^D{C冫oor#!]Mӆ;Xn:\at37 xO{j'ΗNd?$KfeY:{Zc& P+;##hbB"ۻ@^!gb aCs< nц:yV( ^@>J. *Q! sUd,qh.yXQUd~ʢDA12$OU.W+Y.`Y\#6MMWF22[rF!MX+ç:ۈNZ"i9\@A" @hQ%jxmG'ԯӇn8٣c{?f՟:rX=K*Fٵck״2ٸIJoCJcyĢ?|KcU;6gƛI>zE'g:XsJ,E̘NJG>4]O6Y1lhLV%3)kT,)KM܀8qhwȊg>^7?>5_|w}[G6tby.l|jE0/./jA?h=%M'nh::轆f|} -yxmdKA`] GFQłdk@"eK[ H|`h; yk͞W!m"} %أ`b˖O>?nOHfõ7-׶=m|~a۟56pǏ_]|uۧͥ7wOAg׏WQ߲'ֽyō 3nyns;[nTƹW6Xrz玭5玭pr'w/޸c;*m)93޻2_UwhyjU`sxk5&VV{pSs`)bHྷvj}k[ZiﭺO.~v{@~vٝUG6@thGV_Toae{eC8tʴO._uv_噽orn}Sqڛunltrk4n}p~ם-GV^xv=Kw/ݽaFmxrK[N:dY7:잪.^!oqa/n=o;k-׸V;nmt|wܨeg^:ީ77 }hm׍oyغ׷w?m{x .|sͺ [uq8${lsI*bo.D& M S ZaϜe"<&[*)JSIގ7%%*eAyYm~Q*HQdŋ.J_]a[3 =- _kk ]AݭKz/#%ݭ}j? m=@Cpk_>5C f}o ~w'A,.{_CRmO7t:ދ&zБn|gS߻/v<9&ۛn<[^)QESh!SMY$nn<3AjhlQ ܐI\%|AKN: UbVtzo&L`M2Yif{q"f$F qϦ` pI3.K^Ȓ//5-+3T嫷NS<,tQf2@Hds&1dNY4Qа´H?E"yO&U%M2 bO}B쑖HcPɿh Jb&Ц )Ղ:9(s:%Nc֛KE5YɧN&d1o^R~T^t i:Jj xQ6NrD?BagKL=aֿǤXdd RS 8) r6gY?~g Iє t-]Ifjrti&rW BmXVE -c5 B i}! 7}!x§4Y&I쫌ˏ@z:I:j3|/nbafixJ4Se:JY'" vG8fC.9#]5G@r / `lYA촀\\4H\ CM%NrA3F@=J$sJprP|~ef:2x07 Q`=YQ~SYbCD%/™Or!ȀZRv(3̏@[( x1RB&jZ?nP'6>46-i^XR<@af@ `?J^>Q yy < 0MKӒ!%6aMX`0˓%qt, 3*UeXYi~~)I6H9V"[Q,-:,)zn" RŦF m8yA *$'kbȒm+2Lx%}a. @.%F#t">7K%.)KTd!#%eeV Jg`4,ÐoSVYulVeZ rEz~$uYl)^_ieQe%к%AvtIQ!U8PY;Vo_1l4C(SuXWkEѺiI4| M ,+|kWf bVn]mENM )NS,-۰8myiBh= +Ŭ=vh}r 4_>,AE]}%XA'o^Un=nڿ޷>Ќ;n樉+8 5gƽ=fO'GINzd:s~Q\R`zZڬK[H'IBӔDqXP3T)N .N'q  !I"5IC5 Zry2' giJJ")r~"Q(S0C ǖӔyUl7TMێe%'K c}L ӣ˳c6/+\8kMe Ա1L3g<˺qА8Eq ڰ0ETŲbʲx4LЀ$A.HJR-2bi2[W,YITFji=yVhzS XIX S0*3fQ^|AZ W1 fZD b #%?AW.C  ZN\>Ad qBԬ!;cYB4P0c9d}$-n EeH(fV ? ,E>7Nb!+Q5Ki:^xfɢyI:ILP\"!HA UNl H."X!6 A #$E H``@ IP(١nFʁ۩Y X)- u击+hz^Đ$&yQf1A Ka(ތPgidEhD-/4ZL<lYAW32r Z_$"ɍVQ1wOE¯l]t,&!=-%yZv@9?6den ͅ6DF^4@g1@S35Q*JP%(1hgS3 LgYi̐id&$ϱ*S O[jSDy3tS Qn,dqǭVVe mEј^X&YUfޱ<}}U +,ٽ*k{ %{VgYqd<(ٰ~=jֱ3+tOwj^=K6nzziO/}Ҳڼ{,Inm~ˮ{gN.F 4_G)=6.7^jyf4uNex8I(f}.UaM"E̲,]4S’gDAh@ dby'룒t*% =S kf'YZq?B-WfU EV`mDUH' W V2.yt$_*|5By  $CIbU@ D9\x-vRcp_`-dkn.<'jJL1L[ͨ=R}I2EU4)VyxT]ΎVk[l`X9%ȌSTZL?nmLDQ"8K}F\L>8Gʜ8Ggp3<R,QK\c A`Ɉ kҫ8stcKTdR/~?L_<=9hJdiLJ h6m"WTPCW8G5?Z:vUAE凹I"B-#3)c*A#8ʣs4\wzHDI g]EG cZ6 $)p$8rB>@+dA3^S@p 2 OnSdD0H)?LA&p%3hvǟ~6H*pV:N>{&k=p.@2 B@,Tހ 0Gn; Ho p 7ʄCP<B.Ѐh*hP_3 ;gC4gB28VS )*9k:(%;M{Ѓ RA2#gC EhQ 0W:>/*fh@À|щԜ`1p+*E#əaj~IʀXJ\@)3$zFC# 3Çg[m  $C90;;uJ4s Гg1.p fp!>32I^JO~ Ï qA& iA"jFsG3!ӑ% ƃD͝JrۑBݦB 4nMpGxK>3~w%cϑ !KHgmrF?QI~ၥ 'JA0w 1|787=XDwۯÏz&9s:Du̿ 4~"6.3[ O,4Y '5OE!8BB]__H!#XT3  PvUU^%NN)RH+"MJE bB|u6s1\svɞɞKGgGBQytIB AAp8h'+1qp*t"tIΏKI9L M _7en5!ccFqVk*i&;˖qɄ3- cYԉͲD EnI&=k٦3yTp1\p<1nj0kBڥ|>0SgUs,\xǀF21W ONRr{itG͉:IE>"#-IU3 ɘ 8qef璔z$dY('hJ=22e,<]Z r-I9f"g` w..vTutᫎrƌϐ)?\Sb.(QNu,&JhE% :4lR ۥƙwJui9҅mn  LU%x9nA61<;$-٥Y+%'EXK8*C8!WV#5sm,", 2ӡ8i@)y8i-d8m(slrͲws>{E+,]$Ў{/_ů']^ϦpꢪU٩D)sSQN=޴zLIa뤡/G=*/Vs7iQGɒIG; !8!)NSh>U.LŽE' h¬B91zeH2k/M,{+#)I#0w8O r -tď#82@p#5~1O zC%KJbȏFvYF!Ÿ)]J< 6f53D* 4-ģ'ccc\R8u4ѨȩLrM""  ,zE./v)L"LCYPVV)4Σ)p\AX JFDqn5>9Vc!q,v@ jVww.sv:뛏&][W٘~4-;ȫps 0KecsZF6)𬞝3͓R!)pcNrvzNoj8Gʔͷ~1,S,1 gfYޫ\R[&{ҵpis,huxMLrOϖ+/3.xºrvֲΥ{WOgf?ɵp㓷,q"WJaQZAI |9شHC?噢bMߩB-8ǣէ51@2iIjP1N|%+Ң5O(,h$=i~uټ̅Ub\2H풷RNs-|QªLpZ.* sh#F=4p\q%kJXc]qr@1 35F- s"HZ_D!y@JP`UcPKc!U\f S9NkZDMe $+E!Pc8?q n4jD PqbPRpyR&9M H8Am:gFnaŤ:#xpb[_託_R 1?:)nT+T9"Ƽ0zb`f԰p|)?!8V"萡;:7!+|7vJvB1``ی{5!xKI ʎ0c~Rf BP h#ŒLsX1Ar>]DĆc"  I'b" h)+R#L B& XIqBF"9qFAvSTB$J!n4^|!%Q`GL}2 |"QHX.% /FBHV) ˣRT#j.P heHf@q*C1]i*IL?,!ʰw.aɢܠ)49="L4%'@G)Jf8DCƠ/9d42*3D I $'$jCGEUc@>ht-DGt!#E~F0^ $G#Ҹ~2naGo?S[Z:!O?i }}t{Of(0Oɓ98grB㇓旘[맧&X9o(OO_3'gM m?paQ ,*_l^*G tfѼb[ߛpƎ_`)g寙;nӢ+:mϦ$?>a/V^\1&۵y;>j=g]Xn/)),;׭;8%itCR4eB˔B,1* s@DP!p̑DCUX%(;i~PZy %?tfYzltɛ;۝W Ls8Ylӆ 4W|v-i `M\9Ƭ$ ЕC/N;+q$eTbSRư^spb6M7ڪsKY[ř&|s&~nY38R4V+6SRtYX^wtӡݟX:5^'7Vo:wy󝓰ڰ`)?w>8'Z/{ٲM+f_=UícWN﬿y~.ߊNn]ه`?XO_߻~ymNnsg{/zY_wҞӇ59~vU^9M<r7;Αz C:϶֟hO>mA޾Oާ7'zDjMA !_~n۵Kz;/YO~wց'v]D'׺;Ο8)ח hzx;i ӟr+bĦ擖s$ɂƹۮ]]&;^Ӆ=$[}al}x|m񣛇e{w13s>-5 i:wd_~2eڹvXi&}^mK[}|uwߙê>;QOIi[W;w}LUpnQ፸qΩt96ȰY߾ݸ8/R1oVֲEeRcQ싏& d{a,̒Κ?+O.QڵiI3'H`nI^UyFͭgQJ~^YULI"?3rpl13p haCz0H89$.!S!#% G 0$&hDؘ#7c^ /`B¨E 00u!/Q=hK {A0 eB>L{qk/+(: |3u #_d a "1A3%~,/f$,~ѯ2bfi #h/rtRHa\,*$=TV`|PË bpQ0$㍂87SƲD61]NP3(h!eRIgE*5VA $I#\r]hsM) L-BF~SFޔ)i&QESey> i.%#C2 ](gfKJH)'T`0YUlh6t'9M=#[|FT;wq:?y4;WUeة҅<`4;@2SMd0w<1C?)K1u֗V쫏=C5vd&{toɧ|EϤԟ Z4DW褷::uD_+)oru:jq{Nz̓:R%Wgm]/zh;~%'c{y 8v1>m-({|\\@fy*?*ޫ䪑sA}놺*O]&sܳ.\d{}䋣g=F^P(9 9{8ۦ[74z_.lmg6@=/nr/;qdɣ+_yjOn_wؚ{7?}R햚C+^Ukyu/_o[g,>uxݷ;|4ƕanǒ?XbG˖,Ӫ+gǣAY|&/;#c'+bӌJהRM1q:挓K,22ީgzg+h i:Ya`aZz["c ޡERp4$H5<|:*tqPxF0' "4 s 2"Õ&,R~0/w]dͫ&Qyf'S'ӬlV\N+UO+Q9@;+Ϝ[)5+C{)v#P`J4" ׈ SR- 4cYbM'ſJ kiӄRFdhB0R4FrLp՚ݠ?^T`S%&i57ʮK xKB** HF˘CϟDJ|WҌ LsiCٜƪdF-ͨqQՒNYˠG}I1 }[UOOu?>\Ӧ7bO-MǺ}\<ϛ>BWi<];5z.v cQI6WԴd`$/',$IK,P90x/E+IB*c\~#:i8T*3uQF;ᏽB;9 }<Ԑ*֋iEk,i}-At#뾉eDctR|| <(Hjߞ_jm@u\rև' ږ[Gn~;]X:< Oz[.=Vv7_/{+ޅpp.^sz-=wѯ~:nߪc?<~яklYzn[j{>ݪy;>IkV|r)[M9e3`_nقJx8SK/OS;9c%\3.,qK 8GluUԑbMS1Gi<:ZH~7-76$l!gt\9Z;,$GjA:veѳ-ܐٚi}t=)ܽdY?~>cY}~u~R%)FiYQ1 3@;B7!(0B 4&GR l-;MeЁL&>,Сd9:*]7)b3ʯRUeIiJ8TDs43U KmsU Mt/*6WTeH(%.^]!̑NȖkbUQ"'WHEel|$J7rt ݦrh<ƭNKdYjphT!"Tu6vGE],Oݼ/%IfN0~NfQ(G4<ۓl%e2?vd)y# Y.X85viLW;0'51/eRM',U 3jQ;2(ǚUQ.CG[<,tMLmRPdQa`:Y 7:'aU3* zBO"nb"[2pCvFoW+CI#$A 0,( = ȇL CJh5%tJǙ,59d7rˋLc^4n|p̮ɃjH{{k:?HM[>i;to߃(xPZ4="{~ؽ}m?6/耷ijC\6TZ#ݍ:zZctWqQSdyI-p?no@9NM[!P #7=Y' Pn>X ;Ir$j& J|PERJrުT~ H*UADL*gХG*!AbD~v?z@hKMMؤͷLm@͗mӡ.pwǞпSj:~j_V2Ȟ&rȸ0ʇa i}I}4Hh?JW~z>?9opuP p? oJ&0Oc5CRtJDAO?-]|DYw}~2;z$iK[K9C|#xswݽ`?x{Pt=>;J*5]j<8i%jk>C].:hƗt_(OoY_$?KY(g?qHíw6jr$i8ߦ?>OH׃c=y}}{tg|o{nЛއ͇ۏO~;ߣleT?w[;nq]u>qP O5yVuCݿY[y~yzs>{nٽQc~_N2u{?vlͳ\ƙW|yƟsnvOf:#k$;Xv ϧsƽ?5g _/%1jf5% f(h~☡jEp"gv&59,GUid~!).֨ ~@0$C" E p<.I-%)6BTt?46ߘEQ, #8J,hH 0Xzv<*(wdKB3E{=Q%B\5=[I+@C5ܖ<5]QaG:=KI4P/.*1O˔M𤔺&:yKF8Ncq*"M h"",J;5:aUHF:uNeUgeGMEhrlq8.MV)۱xe˅SΙ7ݙWUߢ9iKeIȂJQH3@=VQaѥ o:eIwhWi'|^mg#6WQ$Rh¦:1:i\ǥ JJxS`X_ NO|lԑGBш`0X sIjфAxf; ;,+$C0H9PQZKLUx:bd˼ Gb6k]*[1}Hn$#m( !Ơm&Zc0SB>+7=0l*+5;و{lt?vǧ\I~xgˡގM;6oy=w8򤽺ǖ=]5!.ie7I=|0j<M6|aSTdߛf^80obarrMޏ;86_*ka(EJ,,2OuJu*,[0ĵBZKg9n3%ґaKc,*'?ܚTihoe)'KJogʦ9xW>C R+r- wTj7IC̉6u9T)e2#&䨲tCBe(N/⛥c %IY$0vY+5Dьs2ޞjsbe%nsY4'(JeiVAagS׋ SYBpH3zLc+exk\<7U4S'sb0_0kā O&8j&`Êy9&`y8:I]:PavФ(o;VEh)~|ȝiJ(ruܑnyp1v_~wqD˦eS fq`>_fai`!k2]̸`#ޞSOۏz{N|h9yk? hz/7Krv!?H^~TM{խ4?>GI_益cMui)0CD¦Kۚno z-o,/nW(oYWͷvطx%X,90̩-[IwZYin2"[2/_1;UXMj|>br'5s[6mI)^=`Sb<"k[<5M<ŕ2-L%(c/3/.1fB\yy/xfJjxȀ|j[\y>OO%BmtY[mJb)#q В&"T <-+[Eʐ'fډ5Oؘ4ęzrj@(3KIT^jVeȧeH+$nG<.VJ \yL^dbfhX* B.yD1#(sř\-psǥ'UiYN:K$3J̙DOE0WʞYQV\(*%}B4IDxɬTQɔr͸lV䂀38Cc_QF~nmsM}(9SMY9 QEY4M- Ŀ`M(VcQFK]iТ n9ೖC}͇Pz[?Gލw`oa`ݾŊޓzF&,_5Z|Yi$P?-> jD%Y 4?Lg )D',T-I|vy>j%DDL'zAزbz3anLHcbmɺ/wO;5/%Պ>ש=J2V_po%: ᜠOѩSgN.Vӑ/}j{R,@:n?-Ԑ{#ZHh!k)P-ӊJ)r| aj  y{@o$ #&&3 <(,*|M$2v3]ц[Q§ [`G:ei q%e5ά9|G?yb3]CZ:ȃ: /n|pn]ob늲u̝|0lEӍ N6-(V.4~: Y9=YmawWȪmzV%GG/3@8&N&X;Ŗ4N[ɗ^Y`bNs ƛKJ TM_,Qb/Pę~`LYX$SjM.2rj`Gr X<@XА1q@AQ&xPj[Nt3 KR[$zD(llxiZzU`M 0Lax. +62KmIYQD^]9Ɣ@5{d&6@JB"" -[2w1\b0gULh>!O\3ڙ\X)&5@0FIvJ%R \G;3|Џ3ZL tpH|@ptIFe1ڢ C) -q*d3'R Y.fa&_#da3m|v~=ʮGǖvvܫ 8w|kBI*/gx;/l|GxM@]zZh뒲ٹt|&X|fڕ?IG-hiɣ#5ޞ 6ƃj(>ۊrޞ'w{bj}ThEomڷ2P[C}֓(kM>igojeF:!MGqꙀ?cײ濼^ QO}>{3foqZ|\`|F =8;o pA ۧqbu m|x~S`2Rrv=YX]-ׁ P #POs(|lsv#j.! @;Jd|~+@zq;%tF蹡vJd]W %ai=Sc%SCc/mWv^ҙ h4'BzhOةv8KP$礎P,Sg;Җv4_xa#Gkmh[.n |P:+[6_\:,鹶,PG\Eڹ-ـuԭCIsu{Rs݆Fv}+:▮;5j<jHu͗6xԊ'BU?U{_p$Gs' K3ɦN-?4\DâgIWh?x\oX= &Y:Nxˎ/r"m(AP_fW3 A` p7NY$ ILxO,3f[87SZeMZR[-{+M*u~\$?C)ːƧb.v&u"3t #T4(XPkJ ~ )4rJmrG j,<:AlvReR!SSz$̅阕I.QN%#䗛r#>՝\2)=%BX,D㔅9`"Ɛ/ *#D!.m̄|yƐߟIr<$ 7gKynmdenJI:RH1Im`Yc3ħY pA#q0o8@j8i]0b#bBF0JWɛ\Q2PfY]L>coԢ nc@`ɰ3K9 t{"hG+ 0ʃsl$ X!W^0 PO&,ٟ.ZGpgJ25 1?ߝ)˓.>c١̪(2:ŷ̄ qGquupYhiC{43{?<փA'znx.5\vDoeZxv۲ Emex*bVb쌙NWKOmdk:re,tmV,=o/ߑ$#|{| ͔|//vv`zϓOxz5L,>&iy.Tb1g&LQR9N&|ȿzyy}(UT?ǟ+l6W_o=#X|&*Rx[O6_~w|>mgKoɢs?Ǩ(d}DjҖl|x+j W9>C,鈨_u&+-[2QG%!o}53ݷL-\@$\{z3Bje6 $VZw IP+-Ũ^@Z e&3ΐzBٍ$kC #P`@p#;o Nm%~>n]qr :[ЏBөuh;MNW?PPD! ɨ=R9z XU?Q8~ek\vy;ȡAg7]~e f{C &:6~בH(v}sݦ+;a!'"9dq/|o#qQe۾y9{>gwжUL( *G((s" `ArNnD`6(L޾\w>g|g1ǜkUԚߙ 9 B{@Ywt/5~]C`!pL=Z[0F;(XqoS݇RrU)b uN94;sv'W$ K_﵉Q8O-Mb%yT`Zr^s v]va /E%Tn={zpjfL!m5 XBd",Vq|C"뗶fje/ FCrU*ݧl쵠)Jj֤'u, dq_2` C@ ى)$3;9GDZ[w^J~& ҂D~Y<%5#k+t(Ra˵^ˎ;.ˊ 3} ӕсJqN7 whYeQ'v؝ss+s|ՃS%gou܇}z/A="sj_?س/h_n]m.O;Ir}4/hcG[ITE(nSe?!@,I16S^%Ei3)@>ɷ^^!'^]G~/d{&S],Z&(臅ߓ0_<>"~{:ṟ}ޙ׷O@^?]|I­wNԋ[=/otzq 6P_o,@z#W@AtQgO\${2X0=0$ NntM۵#C+G}8twvkhYԡ҈mqA}1¸VN4;V$$A;Kk=\J@C1dݝTwQTL_]Wx~o#Nbkk%j)irgd'q"s $Bm&ɡI>鑬0_K7etO._xy+#]B6 I bkoVI,U+!'Py͜l;~!Y#۸z?y cPۄ҂k}gެhF)6W@8 @ AR5D gVeB"#R@g]l$LeY}և7F٧yk 2rOz|Z%X>Dl pY4hG3e- 6Oйyuxj\hUK|foO+QR^}tԇ_Ƙ=ԟ??;չ ^^rb_u#;g>p({_ x@c|_ۑgW|_=wU_5>2X>\W:#"lM=P9ag^\T@6"^z9CXDSQ';;Dfz|L^_?b'zN@?|:хtjDtxXX2'F$4ӆ.`@:h؏4B߾rxXwV#rv yd۽jP}8Ytd/6:H=:ka>cvE>md#+ˊ7F$';ZPAv2g̦W9f f*'2'[hʹ/*,o,&cottrhovAF'pY3w>7Y9tdcۅC`P\adu>TF`C$7Aw-/AG\{:!>C ttOyFoYf2L0ipnmy9b",A杆iq#d!qx9A#onv;Plύ^N_6ZF}DhݫG4 y2BCg aF$%{`y4Im8՟O0T'i8U{6֪ cfC"wt۽ v2IE]λ_ntS,DQALӝ8 d}5aWφvꛙ_~8gf+ /!7&;а9%wnݖ&ۙ/?`v_~`uEtezgn࡭1Y\ݖ Hqn"[߰'S]})e!5T5v-!!voq;?7ҡ,Em6GX!NAeVYҍ}bYRR$^bxkBv5pZZ/1qY|dkG'OrSgey1>{*n~)d{.wW'sWr\;[A1r]{Z7Z[ Lc&Fz8/\\bcOjc-S<>K 'j{4p`^o{ε}ZTҘj;hp/=p  fx`yM"0{):u:݉ע6Zj@MKi^ig,ٳJZ3=lpuI_A)YwGIoc;-4֌:i.sVy2]+ڞ3cgJ<0d+@=>E CwٕÍ/G5H†93? #Mp/[`X^"ַl`Ay**t=W5)ȂMD(x`ӳ?PA`|LGy˩}гC֓p/&{o*RQ@S+ ;VQ/_^(-Jq4ҘXeݖ.;Vo#m* ,оM7[ˮO˛h(+t081?8Mh1O1 rU'K9iuGEbyssNZxfWxdie3Suf˞d_0#b اyMJcd:O(uɷ JR8G m¼- Q*k#ݖoYF~.kC8fbݛ~nTCA0[6YnWB$Hyfi\VAVq/B`\) T;h]WD|{=VJMWrJ qy@Y$ Y$ <.?ṭЈ-W_}RJ۲(M,!!95Vk/ns+%R gdgO]r]B)H]Orsonb/Z~v_Rc+[O,,u"alGq_BW|#P!HmL +?Rq[cc\*>g4Ad#O%J #^?4BcF"@h:vdUJTQI,196%=3!0cؒgdKw/gW.V,q[Όlu*+JO4R3R^V U%M]9p'yîveO{~~|hݡ-~E.т5{3y=Ixn]x#TCSdB9PG'TZ}}7HtoYfҏ]Iuxhq ^.MS~z]L<2sq sL71Uf(z^YgAxS"~ d~=]d'Li <;%G˯CQ{}u+4%{^Mپ# s ֒s僵d9@~;nH!|'uWSۛ[ۧ_<ߖXAA7An;J[/?^w?p'CfN?+օC%GB |*H%q͙~i#vfh]zprہCG7m t+ṗn@.KnVOy'aYӊRVG*Ϥ\^Ԛ-Q) L;VT9{m aC}fG8usaiixE)c$ʝ- pˁM P49BAr@!kO2I 𤫜JUE ? b dAbl[sE L BX1|?+ø2 G[_/4 x3JwOXVx|}6d@Er1:՟"%ۘuaQku\צ9JofWqanJ:9MyvwV2OaUpYkm;X/v[nZ'hՎ~Nj i1 Oj Jɼ׊ @;5oY |h3bSiQ,k%.|- !61 M>YrpLt[LuhI'7#%qJhޖ8t˖[h}є펄ڂ*/쌛i)NQT6xOo)˃=wEɭ*݊U)+b.IsW;mZ-̫ߡ0pGDe`'A`$!uKf1#\/2Iز=5)E)'\ܼˆy!; yjx2<,|XQ/CcE1bh>9_?+S%8l)r;KhYc(g}1/3Fh3Uu fpYcTz/P[:Z 57_!= Wk\Cjo |(b?G\25RϕpƄ `I8Jc+\jU'4֟y9-MbWMIȼ-Je䦈BYGV_ 8&6KycK'篸ކK]허؆ mo$⬆x/-XbN;LkU[8.q_ quXl% [Ϭ?qsqRu"##zh%f"k((1{oH qK s Q F '9kUNZQYﵞ}+Ktbӎꜣ;b<?SZefB l_Mz ?h7%ʷkfak<~>nfSQ<"%ۣ5]i,h.-l(۳zKС-gK"GmzԳneQ#'JOWD_:߰պc<0,i}9@^f&#G k~M#a/Lvx91(RPR/e-zy>zc0 5<nO ?!zj&a l{IziWҰׄp͸ Nw0їR80p($ &MV=gH#&w:NwA&;聼!R#y@qp`P" g%& .M炙Of'S59-ՁIԧʵWrz\//#"hԏ[%6_ "C`<G{xThu铡'`##5Rπ\u@8(NI[cQ{uEyN"MYWKm6( L,P/ 9^V]Q%]$|Ymős6GF$m{ekܓ{bWrSpX΃sfwHDSۓwFZ #=U1Š0^wil]B7gڡT~4CUW;$z8|YhI˲]ʶ&;@hHO uE:#ru< KW`x]5ֲfB"+dJlPs'gOv Gvg(Rͷ[/ cExƈ9X NqO:Nh $Ee4WV llz8%ɭDDM5._*=W]=&&Rz/UsB3*1kHqsH-4 _aa/IA1cɼV%;'8(LIr>-2Jgg-+-bYhO|rGyFJVڤyx8QSw= ؜{x¾{dtku:?42ߩ,u{|fK-m֭-EgEO;~VyemS z.IجSYwn}vP湾٫-MbHP ? x;|厵b΅v8!O64Qϣ5뫧ГkuF ܁W#K/*"j{:Py\ad~stLg KEPN`z4KD0pK0Z֠xMw!: H irMU!o\->+~Ђ3wf 7;^0P1?<Ј L'*᧢g='I=$r?:F<A y1hҰ0Sn{0;Mv}m =GH;"Is!PBwA=ThMՙ&zʥ4/}z;׷ 1FtȀqu `1"ywt7zNr1WbҼfI&Z&%o~6J ǣhf^2tFw~5Ӎ՝π7]ρa`IQuΙoŃCw{c;';YP`Y-<גu,LsGv a V}_aⰢ8QnOi|gNނ,€CE;wu-[)JٕU?ogYRf<'^iiQHyx]6UeSY._k_sJٝ3HmJyWK+Mb·u.bCJn;½'Ȋ|U<1 IgYTeo_d{շޕ^- Ljǫv&kfhS}%( #DRJbk6G|0'i//ۙ##I=ѷ"gOvt=RJrGyqۭog , f>SS,ݝJܙ2Quz󁬀{3;v$#`ԁ뷌Vٹ+ =8aI ;ģ"KYЊ dG1DfMG^'r[kJ~`]Na1_qZO"̱t(XaWɬBv*{_Fo*Fk&wf}hR!`A7Ne$ʥB KOpYᾊar78nDCGO7n߁JH|UZjd&*56jN) zD`&nzɄ!*Tha/yR3DZ.-S8}y&ZУ8-s VT6*@&5Ji㛅KeN+$ @LuTaL\6Wb<bUA^ضUF8?,#^Y"SSz$>q.P\_Gg=Vl ފφ[ kh 6G}l?^xb,7mXmCKLtvOiC>g X8*G=(j흋GP-*D#`ӡFȳa GOVGpkǡg|9ν"?y!~"WkfN/B6 &_OBC=B/D6EQ $m$ MwA`Hmah3lAP3ݐwS=zy;z{=$F` ƀ+4ݯoOIw.mwiLm~3F/uy<=g- F^?p ̳U~O@TV͐>j'8h90TnL(C?\!hO~4~H?,w/DqHw7O sXφ#gv0j{U*FmY.V(k&썟slxqmpH=p = ĞB3!k}eܚeŪXX|7l> [frtR@i:rF*0 Ly^kM 69bf 6+2C)(yx8/WdBK7ǯ&q Tu_2ۙntP#lP!dᩝ^yO*T'B]x?:wOqzaށ$ Z!Q`#hr;A[oBB7zI$_vO{((#kUmHo7O3 y7do':?tOԼh@ތ1/髃`xжW#-Fظ@?Ԅp]=xxX? (Ap "Y'Wj AQu>` շ3`˞WS]=d_F›ý}{N5޽s^`O3'LxKܿtwo?yj] {GOT6uK_Li)|LT?ZuU& " Q2hyơzkw/v̎[T)n_|0^XVVw DIF;rRU9%}KS$۳U*jw24ioje$]YU\^NgM$Fyd+WFFsZv%VוGnJM˒7ǰ=b356U)X oKb}# /R-:w4r[Z缦P].04cɟP*m7wQ.չsb?xwoKb'"k o=,mY/Ĉmd_ rYt^AlaQ"pi,a#}J.VjSʍZ:mfj0x[JKA,8tEԦqkoH(hcU^^J'6VVVʭK @n <"1PQ q^&aW;h<*X^mEk'\+4V*%B:O^+T-X _=elKVmI"ڙj >k!" MYWw>+<2 Jo i h'O}Efkuv_`xt_rYre+^ >r^) SJMelwFmZ kseVb,{|r ̏<^ oB |W}rݫ w6=cA=@ÓraP<':%ӈYB>m9gvZ!0%@O۠NvPy21Wk }O AT 7>hi|L~}P=ӱC< f CP1g/:Oi e:WQ^'o_OuCLz9`@= tELSt۔v@IBGb 0h UBݰٰKރ1Z(}I  :B0FZEf,o/:ɷkxѵ '_w|$z״.4YoOvk_k{;Lϯ7~5 狀?|-Guz ^ԣIZœ邆MKAP E XޅCk/Wa4Ï_#qzɪZP |)A&(0o)08ԁ0Whm A @cxB'E_Qz  c]n}߻T/う jypS=ݳ=۪ *_}wxɫ%DXFj̋3ŻFg'C2՛$3jnx'><<2;%WDr"ٛ8Uº2XͱŐ!^[ӕ*vVTd'Y%i ^9ќdށ͚#;)OW]]_p,q.֩WHA"fvEfb~E#Gre@+u78{̾}v\0:x6^0(h3gL{Mu |{p{n{J͓! /A_ G)~ⷛރ:_9':~8덓иNCS?0A=a!b%ic4lZ_nAAڒ96@Ϝuv/(o@ t@DJAK%mcf~pwtEh.e!ZJ~'@x'efztsf(ܿxtLXڶuV8Yun~swG_}HuE] ^:^pFzO;Ի}쾻}.UOrmK`^G4ls?hg/wVkd=!+"Lӳ\\8hs`fYǐ/afm0wc8ln}7Aݑ[,R/TD2Ypyn;'xqqfcVj|t=QbY>M|J]+Vk#~ s(Blm(+~Uqq[~QgD t'| 2d4/FC/`׷g=~w w+<:73M8nA,d~X9!KWrsH!w9S<]|u w'-" x rHx}4-o?//E׷缈m{cF?kphGa$,>(Yx}ES1(K$J7F"ĒkÒv!K<ȿ b_닓gͩ/Oz'6Ov|獥P_3Գ |p}H@ {.6/4>>lm,u< !\iȋӝkQTǻK?= @P?ݛw`$0B9M=Z@ 7|sko/>͍_^BWѥ"@CuT$uE(TGId!{uU`!LƁN*Hagz9tչwp쫫co. >ݏOݛm?xB\S' 'kn3A6Mw&N=5 EONw=Zk`pH8f9lt|/W=Yj}8lB B?V`:t K3-*+ɲ44}$j>70ԭkt5*,W/-s< yq0TXRph~N?l)WI[R`l2{4*-ވi{b<mM93jw*DGnA/֫ <0C#ĽSn,uo{zw_ߚiw翽? f?n,h񇇄[_ߝ~}%`|!xfᇭ%Rʷ_<v8P 7<7H;rgRt{HGJ 5)RH'YEPPX߈ŐO=Dr|__B'7WO^P;@a+Co.LZF~rE_D^|yc*V[.4|;x$;k0?V A)+g(&(% O vMcPHO 9 X6cA)Y Lx}D0ݙca![!ޘx6^} |eD7woN q{3 g"Hg#'kCVզ ]Y"`[4h'':!-׏֬u:bGKg[+f[c#,WVj嚢P)LRչNncRh]eRm:jO`pvuVz8ϛYǣWE32V\Xd 7*E~}^o,NvFR<: %(5W[BhHiҐG0&TTS"f4zYF6bi-U4;Q=5 M&?'Ϩd)c6e1rsټORr;4yͷ:ݷ];O&CoxBVT(ONig*$]*VpNJ^Ƴ3YŒV!ˇJ%E[M%Y;9YCKu<(MUŬ}|HUWJRKh{=L~ X[D,a#;L:͋S???ӭJʻU⨃[Ľp-pi kʸFUv5h.(+FWKPV% JEkqFuL"2_'Is,pXyCqѭ;_ݜp;_N~G<@? ߀@'bip",п^h{s?9ٗy7w?G"O~D !cE}(%X(/F#^g7~|,~vCп\WkěI6 !CC%#"b9_?x,?w)H$O %ߞxO.<$jC iVyBMӝyz{}|`9 S/ Ћ&pq_^J1ԫ O!}P#,|FAADP^_w'O:Hj;g:_< 3Za|0p.l`Eg2')JD{uE3;#| PB߳8PtC)pu BV|">]a8][' qb%Bdգh }h53K,܏gἱHbR+Rlc.`lÃéllL|pP1Z/PVbgκ_!22mK~IzXKR5/?H.uݘm6}ʢz;ͯxvUiE7Ujm[0s⺀\.L7 }vNt#*y\usc>A[gc;6.pVZ!^G1U{ JgIR^[.y~'3*"BzL ;#h ){ʈ(rQShtJ{keSm慞2R3ZyoPt$n ڪKk7)4+ʳaZbb)SK\k/zVoٓcU_o9QzYnU^j qtm/'X > uP4s],MaOP۽ UJv2å%] v3Q"H(Va,e9 ޜKGX ݅E{8Ȗ#-,'WIU]KbSvux=PCjQه>-oQĥrh )3[W[ H9@킢=⽼ϙ9gZDۡ`U4U;ªJb(9 2^qN9mPehˤO좴.=^)33l0M];c(ө;Z򠂵k* ,2C!<$Ɏh$):L:pXS˝.N[X:oMl~_?5]i\N[G9\Sac5X8VxC@K\X^J ^ns(Z) KJ8-?R"W)ɲiv.˛s̿?;ͥ_~z͝׏ 6$deP"(??Zek6~\|GBik{~g{ dDFXā[`22("Eʷě `Mto'@HY0?p5KZ~"cg=PwW^tnny/rc/k$˫#(%uu}砅ӝ'b['[@Zm<^kG,yqBQzE3GSë @?gQLE_lD靹zȽ+DVDz}0'k=[':voy`r+V(F4=K0^01ﯴ|" I;q/6!{$@Ayc0A\G C$JC[ M6?UgIJ sh)_.LY[O흯Rom^r~qΠLS'ŏ'WۍώUnnvY_L:i'kvZr˹F`!of)́ [fA̬$8#γr}h@CI#Ɂ4xHK-쀲xGZ\"()Fj㦛>ivHO2В*!23CRkM*O-s1oDRV_:Zor%IJ+ZWia%![)4sٟIJ>sAx8hcl*GTxM1RꅩO$9;uځx{`~T#:, Ǝ =`fȮ.0s:,]Bnrq퐜(EE{ZJ-L '`'os3>pYϲ~W=3[.mRp8?6Rn"-Ӽ_pمp vn GT4?WPt49+b~理(!Sɲ3gc,T(:!nF 1O=zwz7Ϗ4:+;=Vw)LGWVF7Jg2LՊ>pMRyA&G,\"ݤ,UIJ~zwȤ9*eAOŚA GoZ\r̀Ġw; ΒTvz}VT `h6ArkH9?u5jYc֖t5jlRaEŹ5$W=_/yko.q96Nf? P%&8|hʷx)(xzǭeϗ>'Q/. @]_GD##ơ }K nOBk?K%-b'+ +X-gNB7w^^ui(zvq`'^As1daDl ~x(C̩NVH@RY!M}"zsix|>!?V#&msmj} Uۻc@tohb\?>ahi 1[3O;$( ٩.|\~`BίNtbt7XPCOҽy<(t8:PFBrH|ʼnX BuWL~ >!,i'm`G$2,B,,6!vk'ÅQrȵ)RG)dX ߞߜ%Op39$4wc/$Uw҂eL.L&}”Kf!奴vr>~Jӭ/ k[T >Ys*,"S[_lV Er k0;M HMGu[+#r=Sz"F7h9A =$íhЈkݭv{Wl_֖kBک&jT! jƪA@~v4f#ސh4ZuBa%H6çI )rcBSSͶnsvrJՉ en{۴7K9{:UtBTZl2,e3O{8aՄV?`6kuf}\Q)1d.~[fDYvnOVv䲼zzbF! '%y46xPhY`j3`'UA@6ql? UB:j&VbqV `ːj({aXRV2FK dL$)4S$;%]za-/ B]0wOYٿsdNYfgs IYD=Čzih[  %eD Y v>}7fU fwa$XYKdjIp%/3Jn*eOf':?Wv'dRV^;BePIiRa:q@%Jcd9GK23aQR54fvj'wԴprIh N27o;=R~A.ET8 , 5;_]^$IQN;ªz/258f(2^2éֱN9^^±vg~00<4>] g.[C[K-/zN?D4u(j( qJ6תR]" _(7i*U]j ]H6ZXdi府 ? j}06Vƃg梐0:Vʪ]O8Q#`$8L:mS= :# %$i7ŭ6[CeC^,UUe\U:Ag墍m~x0/`˛ %/+| 韞|s yX~X_xsݵpܟ1B=#?m.~OO=뫣omzyqեg{u Ѿ>9W&AWO9N|ş[p .67WA5}Shb㽥&((}r #G+ 'Ĵd;t)/` v։WȃYl#v,"Ɖ6VB.2@y}QD%7鸞 <3*篮:Fpax R@=Ҏ1!PIb?9;#V6`C՞Wg]0xctq̷&^$B# $0RcG׉CD}+#QuC4WH 8[j^tq!2uaG)F|n-N 1(.$:,*%\Bl@f!ćg*P6ūNjD(t2~wf r+0oF|l/G}MVӄ' Nczl{!ۍnK1 7gGk,k4}ΰIY\T=MO2vn( 7 rI]a8-Ay,LN3;&2kFr']=.:lՊr9᰺V')h M*2&j_d POD۠kMt9=czP+ lq2;|6WI?3mYy<ʴ&*M6BUƼxGو_|ӹbY kW[-{gb+ћޥ>ã_~4^v%zoȻ~4x*jbS6TD?Oyy*' tK9b%#AmRobxř}I`8{{!12􃊂=6^Z \rJJst4V,S$uF[Gm|;u:4ZZg=?7; 6,OuI-ɴp{R$ СYF!N'%#f}@!7 'SPhSfs >kr9{y2AOثYۑ%BzäȔʸC2~0To%$+x)~ Uc𸇩@;N:gK2?0U(y; w )JIB!$)^RuwQ(Y rI8й{l.Zם@,ۥ-R*LD (Yfq[Gstx$:ۻ<r7RJ$v*l(edR%#-!U[˳ZʦcNaԱFO]8Z;P\soq2գ1 ɦȴ*3U܃*^Ph;4^up?4E+F_R7MuXX[xtÍ6"ڍ3&}[P; p)(3:]OnhЪub],i +!xeOTqŠƠ(fW[dVJ//R,N'Un3C@i*Ϗ3]~֠|yc:OK;?n,܇{Ӥ| P9q( gc;ǠC^_y~Xysu1(O>??Oςfg̉gzNu?X=Zk~AOĄxiڑ[nYD| b?8L7o@lmx1@(> ;ɐ.5*[&ߞy0n'OU`$u6dXg "[Oby1/ĥ&UQb7u\d8bA!&y?YrDHq) p oE-RGc$X)1KA<>>tW5zgtl#,ޛk# 0^>Uwk\tkuk!h&>ٚk=Z}h+#U7oDppe<}Ҍ+3~Ep(Z.Gl2Պ$`4p8ЎFT瀅,\. m-Ưl z1 q|ŝ}KHP!iQ] gIC/FYfi%v]a/qs[Ѷ'7R6d5.A\ j[Jv= i,uCv{}N6ĥR 5;[uzSOͫ @;-C-ցQ?t֩ZJMuѨפBu2+UryFH6Q40*L4 );aO\^J~ / CnW[ڿ>}I{*7!N>Vӵ86d-#1Jgٶ\of;QT+eC!@IpR-h ure!h8TN hnIKf(*쀙:#v 2t֒ qaK{|\8v*5EA-SgfcjMFOHGP؃"4ۯȳR*JTn |"H1%D;yNuV*ۥ˅nWf`B TDͪL1{/C\ PvJىJrvG'd*sM }+w{v?U48SK ӥt>0@r3̠)^xYn'9`$uaQ'@})؆Q #*9ʸIbAxͱ٦/.8DٱSu1|éLRܜB+3RL<:^JwJ咖d))3k˅း. pQ+$0lhlrql(=]}Un-ň5VVVOZrk%*es:#VHOT l34ªÙ6k[N Q/hk.iwT5V)ZkՓ޵c^TsP.jQE".ϥr)b>A{@XoeԘE+ݧ |G?痩3}!cnN,ilFyF5nn.vڞ̄MAnɗѓ狍S+?ߚzмP E8񵝡F4w970Ї/. }77&M#YX@Eq.{I,t$xjńI`mK7`e|UBQ B*(zI0_<dhq=[? VH=o.+`KC<'ۿ~E|hTz/ j!®cLQ41d3HDjExW}(0ube,ׂZwgEH\L%VxHhr6[g6fη\ܜm;Q`J۹\#V)訖ԻK:eowơcw+-Wb\'.\ܐ UfA,tTiV7Uvq6&~@5-G3+-n$^cvq| (j )2_)RՠC:+-uѮXtd ZBNVQ(o"*'lFUA{*-'VoV}XhWN@Xu^>=:;lg6YZ=U7MSc*51j-^&K7S=P/LVoq*urflt#8Rzټڤ_SO^-E;ۻ5aYk)\ח*/Mk~>`l}5b8k :S("͙ *i',h)̠XK_6i\RpJ UG;hqI"$v8)HAuQ_ƭP<:!@ 6Jk40B]XkbB@5>y^u6;Z СuTVjMd UJi{esOD߫'ib^`"ե2M$"EVK-[ ̊p^$3td y@:ȡfK2,\u{Ф4u^*נ9VE'f*)N.^^`7`/pF'`T*iD&SC$EjYT.fhBV""dۨEGddXTT)7/y1tT,Tb*8*4]L u s_=xSSu+Kӓuk[FNL|DM2P:\#c'C7f'&;MO:['bg#Se>}n,(:7XHeDJPע. M5z15X+)r6W\UY5aG@V;\m=5Yyv.tDPRm=V7k+3!>nwF^_܂*L(5AYmJÝZ+lxJ鲒a/?=ˋ-^|uo1iNvxwwf&幁7FrKo}q}qby}eƍN7{9vuht=9ӷ~ ZȒѠ 8D1@̕S'ܶc&¬AN[p M&LK)p~q)umiL iq3FG_s=꾻8@P Y"PaP[㐅m!ap>v% x@;Xi!ׅ՞r nHp#.2Օ'8(p|eLxw[S F t-YC!?\v2%18)$'B ,#@ʛi7W#4䇈SFFBHAXȶH;]EĖV/R_P#y/ [ F `d$#E-b h% F_8%d)c1$\Bܛm?Mcǀ=ы}[;prf ZT!Wݯ%9 ~kqV^jՃXÃ>>!Rz@RegKr+/j6 a,cFtZozn>@ƊUFj_<GJ+\*+֩e !/hˆ],AdivEhhc5n YB}-vh 9Gz|c_eM5ڎ3FZacR¾:`ʳRC6zB_J/k܀[|*ݯ+6=_G\- }_Um).5&1Jf/ ꥰրrs=w|]Lџ//߮4bKV ^pi)>^;l6ЦFYN2|NG (=WdZXf(C )aV8Lm*֚X0Za1UNaLP.ˍYH+e*5ƫP7ZQ+nUj c) 1F;Y+J Z*jyGcqI%!#MB.-VpXH-*FSMkX(5.טV ,ѤH2(9KT OI2BzD?'xl<,Mťmq1 w<&IphX}|fHYvp$YK@QJodd((2i$0UN$^$(4 `W4J!"c.rEj)^sE-/gQfznLؘiu^^Uać.׮BgD 81 ˘vEpԼ6Q;d>yHXZ ú6G&wO?>볳mMƓÁnp2 !Ltib0\6鋔+XyoV5Vk x8^mw8bUW644J9-yOWhV^?XQ }ш%x Z+iRW7Y^A'4yAyJguEN+f)DiJba-!d\?r׽Mw~tǩ^ɺÕ-|u}=ya0Ņg1_zuixx;j'_:_;$KO{;lw[&1sJ̤R1IUb -0Lm˲$[톙=wǻ;87oDFDF"~q#"54_vr|5~|:^ črR /bb*~umw 7GPI(rF@yK$)+ ";AШ@o,;:CR@rh fZRߊ2&H!L @=D B.Sp!lu%5/CZOreQq̇APoBwgBO/W҇s=Ȝeo-5{4~+ޤj5.#՛=ɨ$d8=JiB{l ^j ~@T3|&IҮ7*uv&씗CVd+lCJǩuj>#,vq\@jw[Rot@MRu$]$/KKtj>+kf L̄d$j8Ÿj(WM-sCɂu$k A8,TcBf@H0V8RiKk6S k1z9 Fj-%Z%]Z^Z%VmQmwD\HՀhTt>,zùyϷW_]NwB`3?pFRɈc#(pv|aZ]Fԑt,IzrD9deS!^'ǟHmvN9R8)22LzV딙 UK30bGa* 6ΘOkxj=Ds[z;i> krZ.=ՁX }R_q;m6ݨ Ė{OHgz[~Q GPϨ٧m&֥k9:I ~RM j] ?m)w w}ᄐ׀i}> GMwjsĵ vTP{ls퓚C<'`u!YC P ,Y4㮦%@8=mqm׿LB^I|LÝK9#dTYg@ؤ 9{g"&39 B~Ձ(5ݦz63\\{1ED$dߝև]C!pHBk|/m"|v)FjÕamغ=z9 9oTj`1$dݭj(ܰg3l&Z0GV,ǕR\TtpNSLǢtZ6_Ue0)ٖHL^+!घ4m.fGfR 8F9F%Iɬ qZqXRQIGd{n_^/Z3_NҺ3ȥu`1~-UF7Go,Ce ل:*Bd5;*}}s-8:1a$rk" c/{$wyr)⏀p< ={9\E!|rYկv`~d7cAq Eɀ']̫WZvB9NMo']ѴR4d_5+:kqbn$!e'tŘU~`#ʇ"'ݢʍXv)!q{*B2 b.fNx(߭j;HQ!ru17`J17?TbPrРb~i_+s!i6(I2i/&I[GV#mbD:oܞ ,FEb4F)O9'( bJ[HFKzEVj%jP> _w1PAon^iM!ܛqf7K_o&մ z`̼HHl6S9tzgk4ͭ K2[ծ$VrP1jeI%> *v~[@1#q jB;kbpW \nqg)-cb(ps1 _M̚@>9{Dan[L&w@Yp|P!ŝwUVMKω{OknVU ؓ :?N|'bG N # Ķ~TU b 9B8A S9<#` ZTgua~k{v*c$t=|*㵰H癈I oyw~9"V35$Blۧtr5T]uq?e4Mj9%Pq0-`Z~kpRa[>l9_0^6Nmo y^!Q{NP{NCL)Fk2Ag~e@>q/Wƪ 5t뷐f1d&kO{NyWDхسk;ˉfS{ޕӿ>ZSQy/GJsd IJlW{.i2o]lPI~`BpAaϓ &b>p(VMGJdvQ-l3vVX.WF cHF\J*CvAPs.>p ;t4hQcj& ĢЪZתWBLۉW^?Z\uguYbL1].ĕ?6`LKo {{6嵱˥[큹ĮԽW__@fl=i!gSwF]/5Г0bd+A( Jr!PoLޛ @]x{}PMs,.^\}J=9#.3G {y2k[yǂ| Y<^D6ۤbC!'qH w:g@ qlP0v r{Qo xsY"AR>? mh- QPUpДr݅$N*@h* >YNځB1AAAr*_]9OkȒV]B @ h^hJ P =.C܅XN$C&Kre @֧AZ#KB)4 ˗ZHYtˀ<ʠۊ#ZAqB^cfg~(w9d_1\]@yH_ R%ziy ~AdPG&[1>;X?^Ɔ=(hn <>YDB΅ y-=ۃ~mCܒp1>NVB,.jIRrAAp>d1zR\5Wd{,Ʉm )q0.O~%l/]v &i:&Gw C6P\bڨROK)f*o*eqt=Y{*1;$j1I xR >7dq%2Y_up-2Uj -qCn!.FkB=n ѡBNU:?ƞ6nVAksۡe8cWҩ:n-=Jt3ڊo.&"_v}oW lvp0fb@7bnoo#3[4_SDN}9x}!Q]J֢9d/)9}nG^)&&Vn\"X8>YbKt:<S؍.ryEX+mM)EGsZ@q<Qu3!":m~)6&e=fvRuZ;M?c E=%V{cY̎OxS"Ys]7:UcD5ji^\?>E9!t/!#"5- n-rV#h礂[Oh{S#!^C|m -^K̪j:.A8ˡTU?GSzN9-ӊ;N)C>K~6sɕ\9G=/UEiT4`!2*HJN=X3Ѐ:Q RL S.IT&bt}ص4h .'?ܸ:rMԳI w7ͱ?ٹ6Fv{RHUga(U2NYI rم1Dބ`!׌W-69B in;ը@QS}lk;l-MgF!s ZƪÍɠk!Bb3Gl@Qp)Z#htICM(^vYX2f۴0`ٙݹz56n>4]2q*(!6=?f#sWW'7KW&B;шT;Np5 A^I9w;S~y#czjgxsŕQGo].6e vh IsO26@{cX K2A2MzK[90@!,丼|Gad؁,wnqPg8]t9zхǗ_\+A)wVT=C@/ޅ~āF&_^`y~qw[ݛIq% nALe>9,{|WǾ1țMԛ`H Kyvߏ7!b Ahb+ U|<6?6V=-9mCUד,w@[A +jb 6, @wP\+) @MIu$ron Vܻ{ҿܿ7C5A!eL/N<\ܞ>-FzJɅ\dobH5U<.n,W"{ >\>~r!rԝ1Rp1x:d|0|;_?]JA'ӑgݙȓbNqҽN ѧ€1!TV?eB[#nq IBBf6&OGN)-{nv lb^V)Ť.3!@3AWӫtPිݎLx~I6*/اJ~YcS쉼9`{ lXVf ;vY>[OHf$MzâK `>u"k,8"MCAg켄_Jb0~=(i"R %o&g>Gjncx3 M+jUp:!Jn4 )&p[4fSŁ1Y'P[Lq)FΰonѼ?\흱o]νo{0'rog{ȷ^n+_{?HjdJlDùBg;k1G\ۯY&v]j# i}r_vMv~ _Q,fb3vvOؕ֓ǽJ쩰 xa'IayA1얰9)#c)Iq+Be8kձ3A4נfYVyE}. U(X2VV5P|UW<*?ٟ1{WAȥ|DW6C94lG29>t n+l'4ޏ,) VcJG3j{Z> u(i2EA8&W  "V3P TW B0@lX}ZY+9  d"d60 sg@8 ,#!Fy<RЪ=JNYd7p@JZ If?1 4t` 2k K;  _r.$קK#c-*#|pFݙ_?x}oɸ.l [f@PHڇ8ܬ\H -` #I숓M;^'CJN=k-f!܈yfĔ =fߦb 2lIC]$x8ԨdZ㼒`PfI+Ј:<\-&U!΄mk|eqyԹV0d%vɳ,3卩oo*:FԯX㵁3fitY"4<No ;8̪ LT۳ar1x=irf ϶/6(/.r1d5wptsrG`  m si,av\;.fzg|±@OР($ UnvEF.ďF-/˾& v3*T)4@ aʏbO \BC嚯&! k[@h! 77Ǿ7͍џ![gqw 喲!|fg7Fa^_)"%gu1·y|CqʂhLﶵ$r_6>-/@<-oNț4Hd_^+I Y*7 $ B><ڂ>߄ˣ{xHv)h5z>NDUr'Ȏ/{+s=]QVKÁ ww?84po!\,v>rR⋅ÕӵÕЋRp9|=l5؝?8Z<[ 4?|8~}z=??4~ycdc6~C q\t>#1dL䀅 D[ƫv1$$BogYi=!b $9m1Lؐ2e%|Byb~n͌yA dHXH>TQ8@ >t舐{ 8^ Ŧc%:y( v-1npia;;䥼BP^#nQ)賰MJVq~j( kf=B@6_%ܔWH:)*o€4>%e`>ע4[X$Tə 0hJ @&5A9`V;_\RJx!p0?ܝ0=yxnj/ƿRxx1G/xg}7 ~Φw+Zqܯsf'f}Nz"0F9E2b[HqfN[Ƴ:P౱J_T:#!oX1%!&q%>!Tx5썪>qWHMQ a4 ~Ja6P1%4u:#dlో:J٩ƪBi1Q) yXH8'VELGFEU,a p LE%|R> Ͳ&1플yt% d0p(]'Z*7駽mZZoY+4d8bRjR)f7g*EtQm69 HpJťA9~f7Iٍ ^ ãV)\JpPH9%gK:aYevZ ,th-Y^NTiYd cAiгKA|xcW3\Bg~t\(Xՙ/מ]Ӎ_mbnnYWÃ{f j GR̩~(!]r_Y NT>xZ6xaĕK6h.\r)1b$a)=doc4|Lv&50Z Ό`)}vJج|>^Exn'lU^˨[$1Q+h/ډ⽕荹ݥ;%XHUp ֋aֈ6a\.دL¤r)7R%ퟸy#}RZLtWm긬Fl XL13v E푟Y<(~yu[3OJwF7]~qe% _] jV Hp9Zf- l3|i0\|{ck#(+)6zcٳSZ# cdLݥ DA,0A ^C (9*p o AshYhu F>#yp%.F& no j }9\砲4}8 /c;x|"Rhw>xwڿ;߿R Τʐcguw&Y:Z`)yx4x:T5dԬsr~^!$$d ScǍZI@R2~A*$qI~JA~rCW&E„_j!ɢu` Kn#աYXz).\Fk}:Nō:>q-)Bd|Z&$3Lc2(LFB\3?73EU`؉u\( >IPO1 Z˃|, Za4I~3K:Whmjn 4pNB5I(RFMSQ!Bv+V|g}1b>qͺ߬^._}K=__)_MLBmgx0${0hdNRq;oS3~{u+?2-6ڧ 9^-Y-q(t 11-_CaκAZ&3kגQƌ1$̴7[yY D0®AAAd~\eXVI'sRH>6ș<)Ng*X5@A:A.ysc&| 4,FGX#q*&S)T+~#~U% 2 ,m2)vUQ%c6$]*^ xCq3xj!E:$Ыj Z¡Ԃ`FiB!d6:Ǵ|9Tħ7B9 yB wKj$F1!bԩm6]?K8`%m z#YDPsj9QpWyꨵ/ΪGقj"#W ̠ 9u"N=V ݎ[)>Sw3ԢCrHvNQLiS!HRaQ) {XN#ޠP ⎰GR:Bp; *eVգW:,z"0dx"NF"S3s2NIѯ &rٌil{ R5ϥviOG ɨ;2¨Ojjߘn-pxfO/__x9<˫cǗGf/s^^)F; 0,0XK;ᅤ:n${v؍R o\摘4ܬ7F~@(Ŕ}6$ɅŸЯŹ$bJS&V1=+a` A&*NZ˶j>pN4ԘSV ? }dP2ҜzE6:CO'z_P-^ 2CGB63HFjQU6(ZYN! T00 t2QĠ2g;(` 8@n=~,kHGh!Z7P2}"9Y^23I,ѪxsJN^!TvudR0b EL`720'v6,^Km~޸e}0_O}90J?įRhOwƾ.矌;Afݬnϻ;o-Ip`+9b)3%"Yy]i@E3q۝bSFlQ RZ+g>aWD֓ԑv" \T {*|HsZR&DXNɱ[lv3Hk8~%^KsKzfT֨zv &R*1^'Vл?u};)ځX>cb?%X/N.CAZkU5~ς 9{~jӉ{,fMũQ>N0;XtZqBj׮w;TOԩP DMAE!b+S aLA'VRzϑzNs>E#]uty*,p@xxގ@A֏>i ƵBǝazN u]p l#e7$]p UW "A؆V|/^|x[X!Șuy*vUVDl4FͪX}_ ~Utzeݬ[?9:nm9.i'#TօxJA԰j̢F%f'oN+æ~ᅢBQ7M%#QTF9;hN&Φ'tD0Y#!HL}y69hޓJ^CO%A3`cT1+"nV>*ED c >^x: Ioͬx^L[H=2xs}hkFq-h1V俼:ͭonyA^_)=6t@94G[0?&@  k+B\RC7;ï"G A sȦ' =b.9z@]eOZyG˴AVPC@"$ y|!J/̾6CȋKH3.כo~yy{v{ @Ӌ CYBhk>ďt CjwO ޵%P--}j:DnYA[ f쯤,CS %S#uF#{Ȯf?D"52e4Kgw)nBL ,\>>dx1 rJzGK e=d '`!zPI».>_ɗ?[[#t ޖ̭ou6TX=j&ƬшdRas>\.ٿ<<YKhFEXň$bqFaS/,Qk$ǽ|XBcܨ\>3%`p'Ʌe#-HKblX M بiM!&yVrA,Ť/4ak"[S跰BvvɆeS։1Ƽ.kY1hD z>=5U~#ݯCd/˲a!t}#YX^7R NZ&,)ZGڹ-$a #Ԉ3R.;å'Iʨ{d5U@#pNHǫzD!V^s)؛r|1boF3׫so.&Zߟ,|Q4ۭz=ZGA_lgwcg^0=(׼fNŐրe.ҥTl:*Kluqž3rRۮ VBiVOoԒ4Z ڱ 5$jXޣ={E3#!y-V^sTo$ VY>%^9{JD8'!Uj؍<鼘|^/l* c x# ϰzNQ>P OV\ DshFҲ= J^ZPeS B<`׋A ۈj~FЩc JjTꐰZ-6+%]X̛4g[-S rVì4Ꜳ6cG7B{W#(o&X1DgbT3Yjj13"'6BEǥ`1 XJ=YU=v.JE>ˀ)f|"jɧo*1ZmIvT!Ij֤%ne]0K q?Lth@B_2"~cS5a^cWbNFFz.1TЬ^h0AxNVgYAQd7S;ӗFCɅ7w7F/%ش. =r // "_ S =O7 /AK{lZ.Ži@h#+)7- uEb/G7 h [YK5WGџ7&lpY9\?]/@d%*P^E9~pbzY5҉e| ]dI @,EϑeKgn7kG?+ǫ'kx'yc?ۿHoe^^'A7{kQTH'Ɍ°5gC)*Ig|CaQLMm;îx,*I3}<쁰N u b$HGŤb8"Jf'| YLhFF@tHqK IVğ ;<W0,LG FP&%k)JSN[4Ypx(!TYU/xiw6r~;,Qd#^tP2GAy-UaL12#ќzhŒOZLʆRJ p$Ct45`ZtL#Tb4Jj[{!ΫBZftCҒLR,[! WP:O)y]Wq[jB'1ȨXZ_,FL׳#7Ӿ糾ޛ'c_^.ۼ\ Я^]|+F tߛ~4jl+,ڌwR;%|׀>'vLF~&VmFCUjՔz)Hq.l51ZǨz:Z`7윸"jB.hcC_kمQChk1 SV|nOV-!Wrz+TS錒]`ժ 0VL62FrG@>'i w$kkڎ!e([V=h'M bFVTK1=1Bï[VcUj,k6Jxj&)Z+㶒F.N.TJ*<strʡ7ʶhb~&@4Wu ,RJQ1BfDIm%A'òJ>aȹ-4Į,9 .齧J`^nd0{Nɧ^] ,H赐@LIZ~`X z^|fXb68MQ}'{BZlʘq.]ܞVzNyl~?w4SIF^Wo7xU6q W۳ޥ![鱔|2̪̕A + qU* ޿51G> P0_ \?)~q8WMڦ V(@֦|ܔg$ ` :,]clVvdb1,A0S<Q?GMcSaFb!PA3&^Uۣq. F $e=L0T"78.[᷒iz_Ee6~cby ٽё-IqkyǓңѥ2cÈ|rZx(|"̣E#G,n^_}scqTثش5 #TWJȱ<H%IY8B! ,` _,/' >rvAD : 7@ye@m!@pJ#0PwP& "` X&A. J8gki`=O4'I룀7BѐW_2f" ~~w?Cwu9Fxq  h\ZmmA߽Rp&ay);-go9Ab.Do(5h<$8XxR{Kgk㍁j*rZ8x tr8zu@GKЪY(/YݟD'Bg]=@>9 s֓O,/ZǃAg0$N*V'8Ђr¸}<Ը>Nŕj4S IeDžnV7L Yx@SW"jv:;lɆE~pZ0DyDЎs}VUR]^ K\ユyi7RMb\9/țX'*{x( pٰx`GPggmvb15bmj12fzVkSnܭ%Dmq4ΨJ)E1!ҁn,J+'ai3fYHnh ϭK0@Z(eYA+kE*nV˅sRF]v9 |\TŜf`ļ;nr%r)fƏ13?Bx>?R/^.^/En~8K@y6w8ujz_{{!{/IhzZ]*j@7:<2lⴁD7"۸^1Hov*ݼl.ZGЯ#96m} l@1.iȬz1IЮכVqwZDAv {{idf1 ٥P'jѪezxi)"k(9^i2I]]Bm[0li:ZM16tder{87]VmVeKLt$L`HD9?s9 0gfL4I%+|֞G][uֹFUY}HGZʛ˒I+#nebdFqz 鼞'] e_?@ U|Ŝ 6[?U"Z9XvidnN(W7+lim5d;2nux'&iϢ9!+( Ǜ$63YYH2I"dҐ4OC{j*&> Ѯyi'ElKq̳#MY638|MOPCiCsAQh3@GBN* z.S?79nL rRo0P\*?96 PۣuckG-瞮 |zZwWkW+I8?FtZ*xy0诶rOH#t=3L Zfjr hx*Ni)|]AO'Ւo6 9ZrZ mlQYoi73,-q7#-MY 1Dp=Ɖ\X3ΪrH:P++5,TsES.$:9=<@z|%%/ƥ8$lVgjJ<&RHq3euV%.42`J q[&U[Fմ*yD\@RM*"5Çb\* %( .sji}.5tHZ3aY*<&XQT깒{䰕EXɈ17+ woȩi,!a!&!yGOu}z>F0yAL2c[G>I"xV< lcWx~h $tюAR@ QLrj丢?) ȻǕw^~?fx|yq7 Ozv'ڠ?Y ?r.izXQ66RLGHFgZxXp܇w!_51 Vވ|'%h)#*Sz:G4M7,y3!e!mbuሎ5PԌn=ܟU4$&^1  *.Û1uW3݋9S'fT|맫WϢWxo=7` 4ie5{攂ޥ\j&Uk ;U+g&t@Ї)Y|6]_r': |06,S DIʹT"⬗Wcv:mv7ڴgAN#)zt/ӨY2Y˩:V1a--$ĵӲBX rgrfVPeRwS.a-J?`_͆3m&OxA'ݪx+kg&2dD%ix${87g3'hNŻX#n阁ՇP4Ê{ 7qwfv j:n~w ͦ;5=7 .Nߝv N38̅o{UNVb\xb;}  wPwbl=|#h9u<zhzoNAov+wOW2/7 Vsow+ /66T|-z~n!`@s@=Nr(g^md.F9!g`nBR8pݹ[t(µޜFe>IOWsga,m99p~3~ѳctPvjth|bhKoWl17rZdl=V<Ѐffӷ[qܨ{ nv1gPv-m +nṀ,r#k+Bc{tZ>>_[Vq'-j|-vnl1j5~~v}#r3 )/y~N T6K;t.a OEti#fh0hFFZt2ۛQ;NI;#b5 P K򆹔j&L[i1)nfRssaa*uᷦO;TӊЎ#jB^  y=.KDFb4 [I&N4z,n~>IM)S ̕a;#h@ BPrsaxi(3!Q5nwv=!dbbT^)Jaiʪ$4gbĜzZ{6cʅE >Ţ ZG!x)yc'Lxa;^ccQO>1 i#<":C}5I  gf9^#ӮBhfFO;,y=g3Ä0x1~JOǍ?(Fլ `ov8|W2~v%fn@~Dʋ{+,ڍ)2%h[iVS)\O,لcFΰ]<8:p삙; 8Mڸ6&栁Br`u Gr3 +:ӧc^Kh6ѐY01P. h&~9ӧ@(!?@ 4ŞՏ%\44.'At]H$&<1PK KDkGT6>yY8gءlx`AE>M>95Q;{,4R?aݠRHt1{E! :eT@QFea6K#U:G@GwRvQ9oCFx8%*s Ёpa. Xxt%G/NEk% ^r@G ԁ ƿ^*fRj {jF5Γr}qP_)ۄssi]JZw۷WRa]&U9Λ`E&*rB]LhcQEjI'BD2Sm{nlW]w3@+( 9djeuLAu&b@l&B@XoJ:zRzw+ԍN`"a;m8A;mbg:SxgYZۋzwmx17Wb;J 514 fNZ ɜFZ2dlZ; `)8>-EpeaNvX՘~.kmDU5ɭB\ _F]o+ЭF_ Ԭg(rgַ[6ߨ vc߯>[|{FQaGw9%OV3(iP FH +i(!DÂKw_?!d/ ̦A)CvWO3|{c~>Nfoޢ8?L9J>YWSnߡ1fнl{ ei3h1b=sߟ=^YYtfj%kWO5y0?>7uhۯt͂,+RcOPFmxC͒u=g=bG d! z@+)ۖSݺ -gQRZ͛:IJfԜ'G+'녧k'+;|07yUx}|މ |~qJ>/6Emep) x%~6b-~{Uy>fO2O|~ylfڊ!Wjxyq3`RH=")d6i y#^fN|\󰀎ڥ)ag}v0V5SStĈaYgR>\Ű6[E3G`I6!N"d7.ͭgLD x,Z0}le Hh{^#8fRL5ҩaw1\0vY6d!( R I{n-1RH(NNʂTRLU) M!" C6A Ԝ~u{k !vDM =lG=(BjAgdd2fI*q]7y<1nx>kST-.~OE?~yrz+v>ľLj# ?ު~1q1?oOr=aòz.h-WX. ^N*J5%K: !{$2Q`}R_>0PsVvTCrI^ŸM:N5٫Ҭ_w V]ZWG!:X$b)˜0rV/; hCz)V¼ oo%>k <ҍpK2r 5BȰvJNi+q>)7ox3sQ~Ұ}Dx0a) ZZ7*?@ 2|a7;9ͣk;t3-"֜SHW&==n Vzu;8o21|\!{%NH@E)N((Jp)"rp@;f ;&ሌ-!]AX<\T <_:ۧ`_mu_j(0p b~h؟EGg$ZdTEM1 ]97I5cV~QLڡ9i[B?oQb@ Ѝ~: wOcQHx#1pI|E;Ϧ9ֈVNPKAx xtg}*y.a:Ŭ5RfC =?'b<|RZ(ړZӓ?w{?^=ފ xj:^ NY],jZ)q+-ɸ(aXЄ+kom%J/xZjU}жtҌk,lDLC( .祴sy}l[Ӟٌv>oX.[nfݚv652JxQ];5QLxD ibM  + KXk@y]´?5ܰ P⌬nSt^j^^|Ͳ+œ,~7I7g,U@jp2zW9ua9D_l@,xagB-d!^^'`Fc#wO7kB 'Q@>á;%|=T~9sc ( {@ק,Ut(v=zh{em.{}:>pmޢ X~tbq'Bۅc VSg$A)H ]Y-`:_u!'3ZQZE;n6MIwt9QyL @^ݽYEZ ,\{M:@NθQߘ 4=%+5޴`&;fX撚VY+ێÐy6%o] w|qXފ)Wy:̥ 9R޺u,W3͂e-c\IkϖS[q.]ó}5]`#[IXv-aLDNIG"r5d#o.7{3A<x{9[a;|aGL85[IUFU䡟*Wpew'5)#Nn:,9X Bb~A*,%œlw2,g7Oo~o7m$K>rI_ 리x'~۾^u|-{g:_5ƅp%dΧ䷗7<+{[~;wKKUu>Hק-;mNۻ\ ;ӉtlbޚlϺ;UShd4`!Zcӌ'||D+qϺ*"F%8FAJ^{~ezH2RJvjFQ/_rrm~x$rI#VXx[q V Ʒæx&p{6nzo!je][!bv7KVWurSIO7gρl||9sk6Att<=lo"Pnnw@9lo~{4v` N ܘ>=D~ v.l^ϼ8]}@议7Vt9OIl-KtyO|oNGtJMk>)R($.X:t<{ qȋۭқңΔ{&yՃ⬅^7I'LW|HIX,Tn£(xōdZLV kIE)$n 1a⒰᱐XNȂ&n> ZZʖ |Twfb̮&|DEP qyBIyEMo)'="mxmf5 u:DA' P'b'\BX qܝzN:uTU$ܜfip/Ee5 J/'źsoߊ!iHqZ~]/|z!tH@Q2TLx.q;7YQ R[O(6%^gaOX![N BHń^Չ>ůjIQݲSfUj#,z|DDO!&U4 cb+, N8qAASna(r5 wǡpcDnp"::"^J]9=q8G#Pq{2F7 p+)%Ht稕'|!^?%FQ?ЎW&-!)I6v_ɘ9^yҩ(9^&* ä^#N\Τ]j(1F:If C*H_a1{m8&wts,KȃÞ|Ƣ\ao IP+$ׄs_P'.qp?@%Kp#g`h٤x3TgKZᰊL^ds WN< I6_ׁ:rơ&? V}::;dʰ`"#]:yCu ^a;cѱ{بhzA%98)zyṱw" VUCY9n32#*32Ic>pmcz-:\78bDrmiɟhXxFakV +)˛~*!lNV'eZln.Dml'%^%_lږzlsbG*1yLC8W_Dըrp)f KֹdcVi)kM[!$mdeO61g#KXq7;ɓf7g#V'q.,g-o,-w~dmv@r@5_mUmxnBە;Wy?R`Xx,6g>g_?(3L) e_.wB;jz`}L`kʷRs{|-KrͽP+v[UnݽUF,owg܋qd`9}yy(fl@>/6Pzy]+.G$WÒUqVҺd6f^ H.A+L.3#Z d fRAƥ4m̔rPXi:ȟ}fR!*^[s2A^!挧ST"jL.K|@spgV:5sv1S0cP֒Ś+c*_(9F l.^hZ +A@/I 81/X*9]:,ɇeRN[ M%,Ts%[ژ-7k3)bͲXCْi:g(Ebϖ *;l@NP QyVJ!F 27:pfޥ%f{[Oh6$ jy]O:a3baq U9K0EbKlQ`cȑv kM4e{eaA{}y4ߩɾ[ݯ;tOrNAslzyyrhw;E?,^[gޗsՠt;k2vDM l:$怖5hcM=P{u;cfPLH!%%k{%xQxɘ rc%GHz BnxPT^%:>W@D5 [eM|%v ѿ?QrqXCEdwqKJ^ (W_RNL) 9 47B&{Ձ84ؘnciyOҭ;FA: PM!*cY>)JF @;R !@h<<v D %in*2 5`Pa%@ )=:? LR7#b9Rvq;GGWﰈ PG@: Ѕ+e^. (]f <7 vM5)gZx%wJvfd-XTu굺̀i,`%E\WhS:Fݻ3[us'ܞ6\oϘ6@[z˶0X\7$dWk}NTNvIwo7R(^fώɐ0d1L&DUl^GM9\ tɺ.*#~3>Z?]&{Ó|YnÍ?dNw J^1%{ʪ5=tk"3,ӵ;[B'Íֻ;Nƴ2FWs϶ޚ ]JEv;v?Xp6KP0vvz,`Mݪ֋ `:.A/ֳ`y_Az.A7owJt=J,χ꧛͏9,9Bmt( ]{}:tvNGO9gkWPq'.À=lΛӾ[ ̍Vl 8S}B?fӻ>]9[9C+ԝ3#kSuJթ9 tׁmZ)!զ{ᆜ%[d^Jkbͽ tٜ]Vݛ r`h={k|tH>.%h}z؉=^I>]Kݞ ,ƕ3AIY-'#%k!)T_KS^%*DͨQTuLDY2naŋEffkqRF,҈6FëGЪr!!lnv-ezݾ9ZW6v`j'<39D-}uul*kl@V!(n&usyLμ=騵zƄzI|؊k nr^a/0Gl J:Z͵\A;ҦGt JaY+[i8WqimI7TmDzBwCv6ԙO(qe* j մf:olWm:uW/J<.T-R[C%hb@,xltܐ6[Mr(&RNLTjr>)|G.$,A%kJKK jV(=ck#dR113gco k)qn%~5i=zR2h>tv?uz:?7NQ;-֓9-ϳJ䋶Jj5$] )vvVKE+Wi L!u+I]).)uqŞVR#ԆrXMq} pvNέ"hIn59ۭiIz.)DX-{@Ԃo_StEF)0rETK?W$t{o^XtTskjturLJqt>17 !b_UлcG~ Qd:s*~MrLBrN dUƆ&{k],jJA$/HWxXA e6k?~^"J,V?,C"v a~bM. "HX>_ Z9A)pQkE&`!`! O-ŋ9|UwY}=M %ڣSAE'5!;腁< ) #ԒE9!AWL]P D. [/IpZFMdj?bOș} :KM6pUwܢW ]ϥGh= 9s/YW%yyXS VJDB2{k%v@#g =\f'Rη"ɠ2ᕥʵH1+ 3TŊIݫǫrlN[n_,>?=؍p#Tsaf;8[eԞNɱZqG`K%yq3w7YIrQ4a׌O1]:C;Ӫ)lI_O+צ7:ٜDj$g["SH zBS 8.O,rabB" C\kKK$kL튭SnN͖m`W.5>bev.Šbeۛ⪬OLj1LJӈ"Րtl٘<GkI5Φ[SNsiL\O;MR߉l* e3Y:KQm$'|(SoC uJۙ-AD9L{#K*ހ^<s A /@~¶igbq[ jb+(}̨oUoCɴa~%ǃo|~__z'˭Կמ'EݯRo!韏o`3Alw֝/xYs .(IZZӳ\l*H&(Bu@":M8ɀɐq젚┏8FƧ&6 VL`T^-ѡPЯr{At*SkjV?4~MN`">Ip%:A'gt8ׄd 0?ѯPA@:`$4fҫ# kD-Lxȡ'\ 9sbeC{뺂٭` D+"X|k$,Ĵv5EJ'`,eRhUs?.djqX0XՕ(+KѨ/a:q˜1瑇e_rk#D36}li'E\}J<`ᗙ3,򸄋kq3:aGƣi?fKz.b_#/Pyا/0?|VpRabyL - lӀ-Icp:R="kƔ͘>rJWY@ o뫆ߝ6,*w -ҪD;kŤOe"JSa%BפK 02Jh~Yu ⅂.~!È\(M>n;ѦWHNa@mEqOoNʯc5C)"~<=ˆ"˶Qj+ Kp*f!-'MA˘S,|.#SY;V^=m7e'ryvtlAow2^ ~%A^|8Ae3.r u"AozћvV $!0H,َC1p^۽ BI( 0{ s> Z"Bvou/x| s9> w| \4vmYw V\mw VpE;l킥ԁ[6C^|Ú{-A04άOP ArjE ם;eE77ndu[%kho&̈́dRMaӵ[6Pm;fL%ngN- ~:imëD{ W1(Xh9 JۑjDp+IJ٭[-F?;BΪD̈́gGLZD\I(7ݜlPh3A!򲊍 6fdfVӂ&R1$D 69:&jR|(1N&)G$*hcf-Tp:QNf`P.` ai6 XN g"V^WO at !+Q*止ۤfZSɁaXQL`fڇQdh9o7\VcCvf=jR\҃͊S4g Մ ,$F]i&DQbƸ5)aS&ٲEQ R>& @⒆! g4 $[g*MMKB 6䯲 B괐:xR,4<ܥdkQobV[d/i)c~B?"hiPH)W-݉Yȿ\Tݷ-u"ʶ[xb* 7*Z}#'M(9o, W*c6.Bj_Fji΢S&KاMʸr*29=>|.(Zޜ9#$Qp)&1u )sJ'Ds9]I͢eHϝi0bc瘱;- p!Kxf4Ga.R(5LZ ~թku2&~qsIvVŝ~%<㑞dhx^$?<%a-~3 q#T8 aVR)f@~b5J!2=cCFA?^]zAM>QcBns_E 3 ) (`2g̐1O1 ؗ\ "FΪ% 3 ̓$<@M'\8XC$eFDSr VQ&fR .]~`P]\5C.^RMD嘶28ڐ]rƅ*aP0VRQúQ&`ƩX,.5(]5B0$R$1ȣLؿ 2fXD)8|ˆIppdfSF6?mgPW #!]UQ'i_^}An領0ҡВbfWsNE)5pQ]W4B¼D¨Y R[i9a=%%}7CfHϘAe?oJ5ua{m0oZ-+|9hUcv6+zZ*ǤPO2}FGOq!g@%nU.|H`k)%,nLi.Nݑ !q#'U& ڣCRX ~ɱqq>~5<eV(x͔JJ `ö-ȋZR*%) h[8RJ0q) kBʞI@|Ԯ+4#&w ZvUыQf9OG 24!ťD 6z te90`2(ź41}Z#@kJƴ4Nx8fu}xuyL jh~˙-"sp@S@;.} > p6g/T8.qz^rB椘;#j^/$1ͤN 9( {^L's0et:2Em찙!'Ohiӛq_zg!92sZ~ L[HKX ~++e;Wmƕ%7LװbVN-bkq$kjvӶnDPʳw/k?nɓP?m;0bN%oiP 7=Ig PBU'qyܺAh,X_m'x0 @QVv!fN rA{ A.A~?$^# G^ԉ\|ځuC 0vUG4z\TҭbWMe} lNd>@-NO+f>{tb9fa<5Jɝp 4lc$I'XUd%#f-UTm^qj) xD)9!cW\kˊ`DdUNwW6lm~Q+rv#P;~(VCڞ%\w֝qzS4|3lLΓ9N@ܴƆSPj\+ +:2bR ݿ:?"s!aTLװQ_D~.wK_Oy,`8t# yig^,ҲGElP>jM+ОZ`?rg Y3P0#򘆿(eLֆ9u汐2 ag>_D!6 h(hDQ`NƚV9:q3(S\cNBY4HӤ'@5Bƌ>ͣNhī*2$ 0*}Z+p),#q̵̏aOĔIsvco9Rس|dA%mܷ%`C8""\TQ󻷭?]7Af-$+%TVL'3j&oRqU2 i!=RBv<Ƶlwv6Ӳv^yaT!JĄYOAf+).j֓ݪxr>^o}OȏrBlLbbq sH+%( djZQ*owxـVe8N*hv֭4oU G^/駻|ռO5]B'BzJ)ZT`fIIEKNG&hJ 2Yq+.OM|OO۹ `I! #c.ibɈc&FB/J-jdtzppp%p> %o!B0kߧy M\ +dE]7j+tVV7m5oz@;.ybw g lӺv;g!~z3sBgbDp, vd#]vͼu=ǵW\Bnֲ픜ݬ|D8/{%W?gj[9AͶW,]Y(SZR13~Qi; C 7/ZLLQU+ 酀ջ8דFdc-/7䔏|1VJ>, hu2j)U9.{!`zz)&+FITjV^ ;~ & UgvșWcR\A09ҥJn.\&ƦĖbTRM`9Ƒ#Wdc 'E\@P|&:M!,Քn&u?nU fְUs{l9cU04=ȰAq)j-[` EY]h%-|y.U౩Ȱ93:\yT9Q(vDDeN7 4hZRT`(㡁y\YEщ0&ٚN棁|$6~T]Q! dUzN>Ob?HhzA۶ oYnv^7o~웪۪*8~P}p0[ey׾`³&]Ed[Ot͊Su+N;Yxl`\2#'Mظ$k*septycN8dd/ A2##xgns̗`VGpQ %ae#1# @R]i`n@%HK"G4&\ 8I KD 1C ȑ)6t3cEzha #aRSKSRmeHJ.=bЮ ia潒ͬ##YN)T~מxZ~*ؕ>=kĵG]- dm'%FȹFxI܊ڌR+> rnM/JNyb7_a6BYvafFVU7f F &~ \ N ,êy \3OX!,=/n(`> %-+DXOE4A"F 9~IG~AH @ 3<9d(wF"!e4R< bf@ȝ%,0 hS3渴Y c/Ia\;מX{UG@$'>m}U:'5̈́eGH/xV-Q-V]:wIzkmZKlݫʻ{azw^O6릳 ٺh4mu}a_wISM yQMWU=]n^oO/K{MW9 9l`H-e9yNa3)F:9òWlУ-aIqZuf Mp $eѓe%l j9I(fš5mZ!yݎ]4/0vPy;|^mnnNNxm?}ov!tXz]7&X(Ϋn 沉g> 9B$=!yBM7|t휷<@5Mźv;e ]o^v@2 š^V8h3tjl_]' 'L ^l/7uvE'r3:6B{Uny`[æ_>![u3QԌi93B>E;pV;y^3`ٚ)X@RXL5^9(Yگx/#`7$j+e-9΍$B҄o}F*$k)m) ZlL ?JI0%Hae)%SV"r` DX)6$`l4hRܴ_M*! bL@*@v5ơ@ #=ur!!(F|\ Ai+,T Lss0Iu`YzZb9悲z PGGWSjP=ktica5URfޒj^Q&yns؍USڭ=lgZUκ<6u0fjJݴvQȨIy='5V%^k&5ej :24$@!m (m -t~ʴH  ',(h,JX:1VEChVQz:4.JF@쁈EKGH SK W];Ӛ_o*U/A{^ivG0h{ZŔ޿ݶ_ Ǻ0Oj{@fn의d&t4 \JԱg3^C &8@;Uf$quZCUg-=oIAC+] |ؤk-%6i B&e/ə : LΘݮxkq,oAA]t;jDzE/S+OFN@C鵐Nc.V ,6l12gI3[vvM^_q҆ݺ_uvrcؠkz!L6Í=&;^տYr4B;>nՄ H;%nչ[NZݒL%vwV=.5GH!""ԩ%L-k)]3O1Y5N8Y$F|@\Aըܜ *hIdH=D&-aq˅ZZ ]4Br"( pz#>r'&Ť.zmLԑ}VIɆ$G$P[+$6A+3\R M Ch+E'`aDlH.;.aKUYtRZjL*b-)]l7t@Q0uJh9s,[9=$FR>"mt@8EW$e͜ O^ީXKZ0 @j޼6&R't .PC'.|!N)8KLH'&B7CܘU]d~2>"ƌ;a8˿y_  d÷wRU?۽$g^#`eR\wI^x3&TDfPۋ»˃~כqbnhz\:ar\x{9m>X_8^_l;owݜzUv~sx{s"vZy|;H}JH+8fVը._,5e/SqGpͷppAʬ9, h)d愘3뵳 ,I j@;E1PZ)]/QQA,yrrLՔG\FRQĴ戅t"L᠟dN<.$o筗!-u9E鰺wlDN;{h^F2h@A;rM*}1qbxYN%)/''$K5名  FyG'\ƭ#`>7jgty,@[iU$idRTNq9 gs^I PG+\R9ȲjZLQnEߴIuۦS,n~A_ׁa>Z?~{~CsT]d4O8׳ +ﺁ%o o[uWumݕՑO531+-%ISF6TS/&yh`5c] ?I%uZYPEq1i\HQu!6@ӥ)Q/\Trg'ju4y9gC2',gwa;(1Kj4ЎA87?N 7":b,hxs<^#kOx$ xb3 @+CЗK呀V!f=zKG0B(sk åj%9o¬ 9KrZs9y`i7:I˖ 1CO84CMp C}@;@ L CU/Q 4؛n  8`=:kӆ\lϑaIsf'y3HN.FɼNő:7W{j#Hb3vՉl%ћFd#WsM1j+N@Aq;ΚEpN;Xjq ZNیlU|ݒA3] bDk 3r஧,nVOnl9&ΫSޔJvzƴ (\Z`@> !BM'?n~>$[2~zh0mըYq>(22C1$B~eU⌗seS3T$f"pj)./Fe~ %ꄶ3&:$y}%VVqḿۣ=ō٨jdtB66tPҭ  +^#-'I7'o+ N` Zɇ*vlRLvpks%mZZ%ce"ZP-nz"Q .q]vu#E:1*+E֣͌Ǜ6W+|r=yb <$ɆnÚׄ1 Nks/S丑%ݑF[ݭ6U]ސUdEO=h@oAz-t7jI-ј/v\AվqɃ_&Ō]=&Q7v M/Up( #+`ClB'Q-w?gT(Fv#uKͼB45N>L:u,NMFvq%uCG`?߹1eӲMz*m_Se7VaLF?5W%ei;yѽm oۉ-׍؛}s N cRmALWpesԌ[ɔ[@{K&; <ր؏9>{pTu@J7¹ۂT|?9lѫc%0rqdJ2 /:dX)x}/?XYpÝ wSG'mqb0BEFZnl>Љ&=1.MyE&(U1eSģ:ZC̼Ú8={'1AZ5G~ 0v[ĸoi UpWLdotRZ4'LjB8Ş'Lj [ '%1tNuK[%ߝvڅӈik|RyQփCA z$HWW-%|vљ` RvKy.p2F)&Z"nN.$ "e6y>L[z1iʂiCJZH)~PIk>U; /v~ӇUOIέ J^jQU%Dug'mP3I#vQ)Βz it ,l~cZ4g 1\5٠1g&v)̐ʔ_ Z1C=IMֶA+Tqd=E{GÈĕ0V ثU3pFT {'6Ęq`9hz %g kiO=hEi;|;XVlTՈ91AVζ]ns:"p\^Vj JͫԻ90@U~8j|{2 xv-h@b)a6vz?\=[_Q"79`t9K^`nF`?]OԉSP`S_ GQ*Qs Z by k985/؛u Y|vh_owP SO٢.yʧ^SMXyZ*nfβ?쯆+66K a􋥘WrB^Km܃ĽҨ[ w5T>WF>ppBΏHy7gp0 M;oZ(ERԓzJ +zZ HVL9Q h["91<H=Cu!e5 jJ$jU / J 5kM MbY*O܇SLX +I'? NCH1)1hD 2\@:gIA'V&Oc]LZi䚵\X;9g>nD typjM5G-.e Rf=-lu7tx)iŘ<bj̪̀mѡшJ9rP3j#J#пRL)8Q:A?:N1ycTi06T(%Ǥ[ \Nӊ'v`rVˁ<8"Grփ:):W૖Um痵?J9(u# <+wI"{Q6a|r}\쇶{o{8o:ʙV"AD4T<2gr(A0`>"G !2 D`KH^fnE7` 20C5ϛRHm E/8sgMN-|d E\g'嘂sۅMy 3wOŽ?qAYwE?R9qeD'%w+Ih_*c )>g AO4sәG6_YST9CU J8e/xL"|x$d [u1L40%ײm8ߠp#|֨7)N*$txltA.9$⌃ 8!@6@&jr` tV`!@8AY ghxV`F R'_`ܒGT ٯ|R<LBhmUn3DZh0I& U-{w\XxKcR̆q?2L 1բO`<"W |a']s1x.VXi9Cw(G;8Us^)3nq-LQsS3/We% Y%aH䄅C}VeHU-MnՌ[ԣqRlЏʡ6M x!&j$@vIB\ <גZh63ڈc)I~4ᖺ4X6inyRUkWfd)vL'4!YA ǡ>a+4pA//XQ|Ë|ҲGH9\Ps C;  qڏE;:a;V8ǎуNxn368{axM^6k$Ndewt咻q2|P[ %J ~.X޽AtEQMˈNɖIDX1킈K$-:S!1sK 6>aZr? +JLHjek'g,bXIɓRD'0R͌zl*@/԰k?r3+N+bLUWTaybsD:8Q6@C)ܨ$Ujʐ *Ä,nI+4\Xʛ{A~Y9 XoպࡆkRKWA f٦dq!Yݲ'jM@:dPUw,)86VY6[n(R9C>$UP_a-U,XZ;J1@sTn#dJa'Sl\ J!NuG K:F)ЭYat(TA!xW/`VѭZy[ *d܂1|!Gݨ"0))±kvq:yn#tȁ@N: g~]/{yQ~!xvE;Mc{oʖEl^i#{į?$_%iC\\MBrZsc?- ƲJ A8'1$;(|hا27V٬7g[/@! [ErCt3i*c9tkg_uKλr]̈BtX-z.zM &&~)n$C8:™{}v,9sZ^2B tLp5}צ3)&"m pS _1ZX=$g<{|ʢA8IdF9é(h9RKfixF.Kfd)T4eC `[D4#M4Chhry.%=aRsML9q(f{|Z+bfY~!k i$1ՋT-,~ß{K54)BT҄$CA JQU& nDˠMt,T㩠$zL v<`D v#6kFrQIvS}ބMybZ"$z 5%Hwm7>7Vd=%R) XXI)A )_kء FJ5lqj硴Ix%q(!lu9Rz8 Z\PqbܭkC*Cm9C?E>h-4QʈE#9B֌V~OXxjqPܸsFH7崻Ui:M>>JwJF[WǏ[FVi&u`tэKs%[ i^"qy;_4oj/vki|8$Ζ)ny_}]|F}|=l- fv4@zSk xSyYH~r+)`@A4 Q;pBש+^\M<~w9Sjp}r- g5>FbBK@ uhM{։gI'rРh.0ϋ\^t'\@5q?z:HC9<^҃~llۡZwv2~U a nα^Vee= HBL+F(!ȱhg U2! (T>G]!r ],^k>$DYc$Œ_HQ6Tj\Hk9m'ň<Br]'t1΅1T$UdHmY` k-_ DԜ% CM:7rHi- PrZ+X IͬuP% oP>TS6BRL;G*%F*8r!m>܍i3xuKC.I§h䬃`@^Ѵp1e3VL4͜C۵@>f zzJe1WLT$<``&ER0#}`=Tsu 0_Y:0 q)㾈~Ʀ8¹A)-ǡZ[LHoxsf?-em` 7bfVPɻ3YbL/}G4QO/^{`;zdfIj4TR=.s@#b~ *`6º/e? ãR G!Fq6͒($(0EÖrE>㑔?E%Ǭ焽(tPhf=,>ЇT0C%52/JL؏ٌ3!k` x`KES =7 gYo'ȅ7Nk LBMh)Ls-S΂8GyWlzȅc. C7eP-J1V55u=z[1f},.e,ݯcQ [^+So]o/ɭNfTQqkv^:+T.)hB%e fJ\TjH-P~"h:DKBST6\` 2,¨5s Z~ :NlI˟q(mވ2G"N%BOKNVV1fJ .N)*f6l:ڨ!p<ƺW0ђ8C}6zޖ;y`#m酵#:F)4#5mCNۉfL}p=z\{ީٶ˖ækd[/8ar,),"bGqS:oRWӯY j^gٓ~즧Zʩ,0۽2ϋ۝λNT_nmdovvWFm7~K./ן`?VfFF^%oSo@_@@Ζ#G8D^^ 6AO?='An(j8R,>1 IGMAv"$C}-'vj :4ğ\{@8 DnՈ&ׂh*N;F('v#vx];%w/k_){5jerX/{kA͵[;Tǹ0vJ ':N ~+ Lx0IyC=Mu4: ,{ :RCCkqJəj ûPqShY};kPJxc^0O{ML`f~c U'KkM:&!av3 E7#H%"=H)a6]jO~յR*I,C.>2$(>\\ .x@YRW7bT'PP킣_dڸЈKq#)"F+4k5~?zblXKxyPv|ub3z 8@`% 6ͬFXwyxŸWr 5,K$,vip hĪ)'pCvކ1:a-+iDy,otȴUÈ{AoIˬW >mĞ}5Yq6<-ߵ}75׏_['_?F޷ܯċAm}SzdˏE軦 ؇dَ(㚓m5ʠjϘOJ~Griwa@E@G?go! ߠAYKo 9 xեX9C0 ].cZ`uKxˈeOHM; 㿞NRQ]iR#vlʀ[4r=! @W T\/VͬY9jŏ:lJ8$ԛ[JCFa~%[zij᳹["Ɲѿ/YR0dR0x(@ϊy` 244R;%2g԰VmjU'% p&?F% 0;cgn+s돂4y92,.뱐5 /Uѯ8Cpl#81X( 8Ǭez,Tg e3vÒ!)$Lƍs"ŜZˉbP2x)(if265sq蛃Fŋ29BXf~g9B0.T#ˍX?nZփeC܊ɵe6*aCN֎/p#ļEjh!H7@)rp>vry!04jF)w.*{ޜ_1Xzf˳ZsTZА})6y[i8^p=c_ T@~b yI8M)lF&M*Gi/CkQi5s|ѭ[ʮ\36k %g8bfMҏb\ւzH[v$2qXtnߔċ6~LǀaݻUV|Mr^#2A&UnhyWfg(Eɐ!QV(EiY'urY \O_WT6sG(O3~xUvYQEg:wjo*7@m|+xrjnj=lZr5j=wҏ½tȁgW7{E \RĞgFw[͚])@5ؐz^o@_ Rnq][C6!NąRLz F.I 9ZM!(Nζ^ ~ZOJU5NO5CE<nT:U6JjR44F s!X9[6.@#]9=~+g9$FTLŤ1ճJp }#ՔQrbna)J l_%{!#"bcr6pkijaj?~o͇1>cWܝEpn e1RGEJŶ,`W* @`דX;kjyqWT|I4a/dHE1%,Q0⤼2218fG0RIzh?1lypԩc| 1 l:"l[2slZF&Ig6q1j̅F9 8Ǧ]WA:bSZU<5E}Vn/7uׇ{LyzNێ+Z ȪV^Gk!ŋ UO7yr/ٿx^ݧ9AEHnqHC h*]wX* & PSsGs}A{B۰P8A<08"o h1&Aٯhÿ,|!b|ə3j.>n_]O@ ؙkjJt߬y4JfaRH x@]qJ@T%OF4r.}£}5?;ffvJr(^ dWmP2u $KfZǂMRldBE)pTtVtЎGaYMIh1MAV4#&Eq'1!7 dDY0^ L !aĹzA)4VȤaM|}N۲~37,e E/yj=Q;/63kZOV =eRmLx4 Լ4zVʍd //ʮǔ/҇Mٿ7AuыVC \ddB 25 xTm bc%unaf%!մ&u ՚ͬR_q^wwI92|zejÙ&%Ih@!Qp{hq]3?֣sg3&N7kZ.XU-)ήjDUwr:nڡx9(R9 OÜJ>)'4qt:bexnT)^^+H[;vH vߊֵdX>l)1~̊**@[Ff,[C9e:BHV#%G%䭃0m^w/9+q,TVժcjl)lCl6ݽ vs;w&IB\m႓vjPY9-wKl&P @)|P.8t\:iϸ1&`ؔsN|GCp8? *T Fv Y 2 CJDF-C#0Ly:T2p~&o9tl6I/fx9At\r\V=}>Z"8~JY~7|fe-D5a+K zaդC ͥ/WȿǓF@Դ.q/\%ѺWqNu-mv&LQo*zcwMY Vq|a{_AA2< #2 {A*V~/%[7oVO'r=@L: |DEh)CEl4n#t9~t, ـI5^6rA cT!hE= IK 98LI6q)';/?Q ŋw MK3'RC>bN !eL)whG-aJҬ:n;ټ iAk eX0lTlI8Qƀm8_-u@gD&F%֐FT搈=zl:+hi̠d1nث#jBh(ƨG|vqԫt92k;S>L@; nX1Gd:꨹ێ Oφ[BzJJڮO~|+Q}-o߭~ԫt vŴKM X.=j]3{]wJAghc(?c٭vq¶+gpٴm;(;zO6n'c!̮MA''b\EXr$/[U%*E)E!eThhAυ\|uZYv-(kđb`AռJ2|͐H!hjF]L*JIUbMD!]| Zrаw+F^^~zxzT{W|wXYaPP^A կvש^˕E&k*R̳Y?\ tډ^\Gs+ˉ~ jb- t%yFKg-vԍB0,aЗ\v܋u{M򜺪9j9@P@[ub8SZA njѹU&Oc5fҐj!3_tvJLԬFҔV]0 (%W%eRvx jw' q$We̅M|sn7R3UcbX] k~VIe.$n͊6182bL @RW"07Hhz[YJՕ! #^.6x֡Tt6YJ~fL%sa5ã2.HhW=IzhW&*8JIZskRr΋s1^?/ IZ<4nL1'J\jBH-2lliH~ &${0d="vN/dCx̃E\j;hƪ]bA'gG z3|vԠZ FFL)% N\)f^@fz. gS05[`$%Sa ;oR(+!O!v8x(pB8vkV36E&y9`l@Clj8v)Zg@f!zdEi^]6$?>+ѭS^rKLYЂ>l|"JUJ\sTq,Y+׍N~<'ީ!#2Mnjz٤!R ~`+-g,4%TNhɃVM=I[Y(%Le=+ʛCNhzz!LְxE̠ S7.^=[;%䁲(Ss'56ݜI=rQUbͅzysVjqzF-YQ'?UԢ:+P<&'"AeH3e @-yt tܬq衕4ùO%~rVK tB3̔ d9/U?]vSx/o' ͈.n{䴋Q,nռ JwtGg@]494u̐Oᄢ*ʅuI7C;9fҺ,$vRWDO?=[zrriN^QٛWi[)<c-l5{҉"@;T_lsFuPB,'pU(Y^w]g6^;tK )y؎5B-@ u 5s zcNܩ6r`6+D;aU/0JIaiAl7݌7,l(R |@SZi"I!j`pbiR ~p6~) a-G5QW"j\m;SrnI-i/筽,Lic=G50$Jv8RPJ0)='鈥~TCj\H7zݵRwvTX:=MmwwgI}&F݊}XizC#Aۭ*i[ԲV5NUpB)ԯK +WeI]ka4S\H-eYn^ dZ{:E۠ެvfs"qsLGȗ Nʔ+UsvĩejD㸂pSa̪g:Llf&L1fͺlH^|dzƋsMN;E5q*tbC1.#kqɊ)z.w]@XN/^EI F/b3o^0qqEyV ^k}>2S+=7h\m6Y&!܈J:)4'͸-ܐu>Լ^T\vW~E'kK͘ ]|h $pb8t H%W 83vd?\o6;H㤍rXhZѹQY7Gr5<$ٹVXP(Z%]-q7bJB]Mj!1sH/gn~!+AX9t+ 16S3+oNʛb~9 fhE]˜yE|6GG!,Kfb3#Na܍ &/ڶ'Y!qMƶT"e[e 2V|(y{-߬6*NҚ!8D~yKtM0IwNE5D)GZ)ԩiJd¦[ ;t6]گ޽Y3cU;rPt#f@[9\ ~9&ݭ^zNy/Z6>^u~_x O_~>_O/O Ono~W뿼\jA^AvOWN=w ?^gp@$~:ߝw7_mV?n߫۫~w|SyQxY|y=|^,} B |W4V=sЋ ?Q'~Љt(<]62-ߍ&] Sj| ˉ&zNb<7߽w6k{'tN44%Bs9LOO{כw߾w=uJ nnTX/,>ǻiHڣ 8fetX.u?\.ո$ń |5MǼݲs9fqNQ[ ~$EBn$XGj 'xr_.2@#=cjX{;%LW'$=x(a+O7ZӻӐqKFûQ~7=\^n&u$:v\(m\ZgFB[Yfh~V&l ]u$BưOۮp`!i:#RH8AKP@[,l؋l.$Z_;Eqnd]`3Eb1"ZZkI+ SU\U!hMz X9hMYcTYfPvrSz90eef0J_r1 }J)bPҠ14w$ T@;y0NTӮZO0K21k6\r.ġ]䪩OW~q9-xoiV[nOe&^4lIm"Gqy>Mƙ_b Q6E@>o 9+H#ܹKЋ'?R@FI(KUuš PSS_/ō_'>_CQvS_7KcfU9G]S(81 u֡ȉZdhܧ>'Wn@΄y%}`譛*I"ǨM7"N[YyѦ$ O}3V%?G`@?\QN9|{5*o?6/0iA<0oqT@;v2' LYu>CA|.D*jqQj'h m_ͳ8-Ȕ sSf/Q,"f^y T7A6 7x0d+. xƮtbrED~E=}B5kiy/0ِٸ`f2.PUlR?N(.^iaLI,P6!,,Gk!^׈X-CV;iߜ@?|7f==$L? k:E]"C ~=TnTyX%G1!YItienkM+ֆ(,IRBPW\7CE#l9e_V ws=r4\)\M-qb eqY'.Ӿ]T~ʡiWR?/zko_m}D:B'm@W۴#M0YM|ؘ)Uyk<])Ꮖߧd(W;qZt*pJSPZ{Ydz>mܣjLЖ۶Z2jAŽ*)2Cb0 mTSY>OWճ<ňk -{7`fSrkeٔz0%jUku,RLidB̶ nB+!-_=Gw략n@;4!R$<\l<3&r1*&a,-ZyRx dZ|oc')r=E&|H6dG-ppwm廹>QF"qXa?JH@\MqLtOlU9% Kp`Ǫ~TEZ-)9YXT0ЖG5&xONaG } #Nv׎t#@8lQ50ҘsژMmj0GeB"lrVN!u`FU\HUc7 G;nZ$y8ΞHY9iSuvf86ec,2 7P62v#AN/R*.6̹y0 2əf,JVԍMGHҭqc_2p<\/EHos~]Mvx'h|f? j UR"spJE$c$hr1&es> (yj:,(URrULc$Z΀fFM_iɆrv[_#DT[׌o">nXEr!ZqdYKu*bkinEnJ:Ri1u3Vo:Ϸ+wGӵ쓍̣^ OO6>uo|}uT{~P hα6 õԓһ۽^8}d㏯z/_k.!0Ͽ_ PC̟j[?C?<߆A '.$A `Dx얟|q}yxyPs~u|SyW{[/NoZ:j=.ȝ,'{)ZzɓA?!.9h[ǎɛr;G' '-* qXmĘ%2g47lOO6 RF2wӎzYQ \=Ad%cF4TcDȷδߜA_)i&i₮sT+MT݃H@gM@i"[_oNehdZ*%R շ l9i+Āb: gp+Zj*02rsYX%Jqia|.FTR+M}]o;y=%*)*RdgHјT."7Z^tZiݭ ߭߮x\Ll,K`4R boMm4uqU2!A97f$/qpSՔmi^sD(Xy 3 wcyL)Ev:dnve5 ,I^Z;d]p2Raӏkn%mt8eسT;4A'C,~7!7x2`NM2LIQb\': ݄*;OoJ?ު?(3.p kiȮB;yp5yZ$y;I DvQ;Nff87|.sqQ&D8GWJZrM5AxЎ*'zæҸNc.۴:],S%G.!OX7l .&I#b̫=l6QaXƜ 6Miq\[RWu(0ɦ RLL#1T5ƠrW0xʄRpz6}|3h!2YZj1 NP:-pݔjz‚by0L7cҴB2ڮqZղK968n(bd.L}fP߀l"jNv _yt+N$'$F_3IuGx#H*!"1',^/t_d˂GotSƯŒt9_t۔I? [QQ0\Pl>璇Z̆(F;-;=_+HK&9GTSXHy6K` 4KY{'DX㖥 ?UrRNlĭmHG7+qIj/h&lE-񽾴T)K"3U TB4j/UcБ䩝w]ś)K#aiA٫U7=1QٗK^k8ϙ_ !l*aE%*!jYl$CYh -q98081PxHEYAu;%%XńdL,N;ٸX0IكNd%/nUoT}aZ#`mcќfN x=b3t#õ㵳p#bso@(tpk8~aNV^Fᴟxu~]z^xS~U~Szqzw囇@&<6P͟៿8˗o~s_H!y u o O~| ӫ?{s|tt!\y8|qPSHNzn,nT4a'z܍G÷WaVt 9{kGwpNlq'|.xwfyI<ݩc[%ZֲNE-%80ى ߌ5'ô %wʋ$$-\AOz\x8DUET7H#bl',9:2saL"sE1 <@R}^YPr `st<ϙ)~X3}` aȨn𝍓 "K#BÁ ȤQqE3_n/ 6PGԫxJ"&71pdB/<`(z>fF}2RAijyrCp;FY}܉1ب~w~F'P%4hn5bP|Jfgҥ0_As0 \8?eñO cX4W~@Qe];F:s&[ v4\QϹm: SBXHqCx%Q8i]py>qzIq9(Xnnj3 2<Ē WYCqӌD< aN|j+ƉI,;+nͻR9æ"ߣKGq@ Bʑ❚ V/KP )'N!$x6JBvɩ]mVi< ;IpfKrn!JI<6z5%9dʧ&nf])'Cp5 /9<Nu l ESTD)C&&,]vFY딗V"x& 0q6bht0L_oGHtZ:i)'g8WqZR% τzNf è 0j/@/SѣFg~7Nӝ61Uf%uʫrh/K,\k:-| AGDlCP cOܖ1Lq]^7|"\,^i.&?2.SI0|\ӕϫ?2.@6ЇpqJ 䆅;"d('22$%ieġox)|-&jZ*Y4p%įRMʨ<=8mD8MLB}2j2nԎ\7.~j.rtS1ql2D ,Ra˚ُp:SpS U]YT;nG;Nv.8$S~K鱥Iux܇}b mS(fD`VI;Jb2 2Xr,QƕTA[UKQznQL8LTgأW^W`:Q gi9ҘqE3J挋PJP&kqghTz!€YjΝq&I, UEfΫG?90]8NխA7t zٓԷW9/"3qz,W·]ӭJNJ mW]0;V$)2Md$Cשd!Ix-4#QfS=眽"_ړ9~_=wʞr{.F$iB4!9g9d1C\<5Z~ d?Ϳo9A?Ϸݟ GWw>X)qۃ{ ~z޷׾8_˝'4q; ׌Rww7w7 7 w vOח㇝ϓvn8A%U2 _'[ŗݚo*wwW%wޫK{y9ZzUΩFm5q"Brs8HV}Uo+8×_Nת|3I$zF<hl$f`o%ROEl q[=KҫաtzO[8`R6 s*Pvn$E5얼6sb*ɐ G:GkiV'P6<~?ꔄf]\ ڍ상jEW[T2 B`^ `ɐ^rU*%np- ";%O-,%f-{Ygl%NL:Ғ sc<]:‚ELS>O$z omZa ޛ'M# 6&evцN(N+$9_ 0,i׀yx<K!fw<e-B*V)Q\p"t ?ukg$H[&]<>Y12* bׁDx Eu S_nP_^ph̋.=p hRMJqy>HxSF#.D"U)/Z9?L)є 65Cᧂ$OiyJ_,~͋V5Tyi9An_7=m|qP~S|Wy]O<>cG×\;[WyWnVN^~NVNQ[륇';u`!lUlU՟sv7^ c~x//w G{]?9!@ۭ>,>n?٩<(>*?*9rt=]ɝnfSN+u·3'I?>GOO9]ۣΫ^rzҊvbGЃ7[' Eɐ [uz9VOVS(Tbض ʞN"[Yz ׼kUa^d( N.-@# _ȇyh5LVpC#=\lt]MSŸeZACijݭ-U]tw[ ZMp%ۮͼK &&Ylp , ~Y:.?h+>`{JF8gY_[<\[޵9Hc&K̏T*y}o'">sP0JиjF[Yp+犉LOEӽ bq&6?/p-KK\:dcKϳxҧAYA~NNM`"(UIOer #) YtsyP~ pì3}-)@;a=]CGBNk]#h騀&E^J᳠#ŋptLQD~^ ivή\0NgӏpD΍V';/Zޡpy3]fW95z)H<qkZ tFClJЇ!Ñj yҺI U!PWQB`<#ھd͚=Â| nqCݞԒsP %>8p"~@@k]I &9<"qZ8g:Za?9feA] [ag<^;]MLCً.g/owNu4#?fnɽSr+d3zyU)<[Mfd%a勃ғԫ_7A@CWzó?:l-Vã?8l<٩>+?(|yy-lvGF sZ.'ѻ[͚}qX󫽟w6ԻQƏVۣww6s^N#ۥNt[Na%Ǟ%|\ݬUrVBZm6k51%趚jųݔb5Axo6~zjLZ2#Xl)Kh_lbu_5Msbhkmi).k*UH*.@Zk9g zM31z[ҙ !9Z+ю^%ю6M mVf]T-K^XL8 vB * +P>ATs@Q2d-vӫfrt/JMJJ#݋#D D2K`ψbeqSi"WACL")քyN9i:Meo*H9qi8DⓄi0W0C[=B6d0 mdIrʄ<5qs_ "HXQ踇g# a^7\`8?z>M,~Gi$!~bRFYrxU3#tYI(;5}<+:)<@L%yu 1aaF+;#6#X(1}*.D9D٧.l\U}d\p[~zL!$2#kqcMI9u!HeG Z]6.ZNC."sq!uK\Х4MHB+ƭQ:)e(maP~21]9n3rsF9ת$v01.ڑ! pȪFAn _<@,}cV]N+/O* K1`](s28-H5C65O,ѨBbMR:@GVtw%3h*YA`uvtq6'9$b6@;"gx !7Z)tNJL65On@MK. 8Q2Vդ : Fg3YS +EcԌu$ A X!pżVP F3m RK&BNDizYAridVvӞj Y!HV˛ q"#eGnmT$9ݕNs8jN~,ndnv UYWBۀUW0/yC\C^,豄֐N$*Y!{:hUN){gg`*9:!OšApihLweiA"Ef倝w0:{o5 +Jb`Cճd+GgCߐYB1@N!pr[`)=nrsLT6 w'91.&v=H%cWgD5l<#cAAVI^Iw)u1B%F!r;[_/.Eѐ܍0\ !ZW_2\u-X4ҷOW~s_;1a1cr];JI&E,#(Q,}>YK=>߮o=?٪mUlﯗo^uߜ_wA^<_4j|p;ַOׁvvvozx<Aġa?8ߞ7OvŭMIVsG~z9Y߮Z+%zE<tg[?>^{|wG?/ZO*/[tf$٭6͜!wC5Fz&@}تiV+ÝF[67ps)tP]n$4!s!kou#Eh)Ze=*%J'ʻ%{5agYmKq4y|P6~Qi*BDo+'~-Tz b2`AdLE_>;o+@=-xP \L; )Qp˝X,h6hr +Y.%O %a&uJB!K,WplW^WϺۑZ`KKt:وL9DETd 80-Q^.fCJ ^vM ue~[N:SD0:+&l^fN #:!P ޱrjB r38Q:~4XO6<(ͪ#㒱"jz|dC8H%IU$4A!Zr>v HERsчO2}B3ˤ|Jr|}&#IpɦUq9`sB}WI Z9rSn1k24OkaBTyɮ9zS2:\W-cBCz]2/9tcCɡ@Z9MKaH,Cb~G yb0E}B2xU-CB|n%A'Y|ELy鹳U¯sQQ gm+i4p9|֢bP\0*/Oyc!\ Ў}>k ] SRCF|H`NRd5"@;p77^PY P`Wܨ$IH jV(Ҹi4 #8DD!/ARe`r]:+k492  "s!'(˲lZֻ}U@~N' hb;9y4.˴v7RG-> ӹ-cpZJ*="1Qy#\47P,UP8@j1$3fyyL<'i6>D%Z'Q:xRdh*ILb!̀$:c">v +dEX]Y4P,Jhl6?jY27|D#opc C5d2> ;ۦZD9oFUSf~n]5Lqj,#:v=Fuw5\}ah5^rO'LHb놉/V$u 6+&H)4JsB],=w\zF1]ԮPa O%ʧOOkI֫n㗈ic7"`1 g?Qǀa,̍iӀsB(,8o΁n2T{xxi\ciGC$#EJhJD" `([ZILˇ4au"@f `Y暵FP.&E,)iUsPxMIdAZ<;(4= `ڼ*b#EF|K!A9!rTaTenO^;],^/vru>YJf)#zA;`[9=vw5օQ|ɠ.E]Zn0\$680ÓfV`dM:瓾jȧPErpCGir^j^?n*<禮WeH̅ eIɚ%!t$@㳄g<sDu3**d֨pVYpg <4GXSVvV(0IAAzA*IP8\)k'븵)O>>[]r?It«HW ݗ[K׫wxmW1 ,3SaM,r[L[;Y_+nwEz"IIxIZoӓ$`wkBNw]W^e. *i"0kX؄KX$AdoqXMr&j9j*=گ?{]><_vl9sz(ΣʃaZImY.ߨP?9](۬=ح߮,ۮ?>|h'@姧|t;1-|{x9l! Hs~}1H~LuoO?P (7|s>9K#X7ܝvCRUIl@f:nOeun-ֹ\^,3 RO*\g+ ɓ+MiQXSj76K-'c $n/(t TGq8Թ#b:řV/њ&+بch=J6掳Vh/D (C:ജa:`$ԥD;F.8L n\L q-$[w|6Ӿr̹n.6ƺ h}v$q"ϲ< Gxe↏e|c֒`gM %?Xdrޠ\cn,27]L \&#qonĶnUD3^~n>*z5XGaSur;ĬHdB2Boq /k'`6}]I0y! >)3/3G̟+:p_ "Wxr^.| 3pf\k?%LK}qp1]x짚CX}ItO?4iǀ=.u Y}Z =cD(!9 _EE炍qk I Z)z4GY'pQsg$@&]3C##LIqFDgIv>:fA8@Zy̬19sƢ&!=M!Ht f:y B!\s;Gyg!<L\QV!hX (۳ 0"{ug@?nd+hDWD=*(XFF*-. T @=G.a; X`[+82hk=h9}s1Xa_W;Ö0hKkR[NYf1$.8ׇ)6VMQ%aO^ė\9wjR[,eT )$ͅQIʼnJ!MdMǰf1\$ޭqN[{8Q&׭3(H[^D=k]S 2jꖅFHL`lPD|e^$ec@YI2N85rғ̤w 2961fF1c6fDM)P;31; w)9OHJtpA  60 H&)e Dc4piQ%77Cx&j/'єhМjwZj <'2kiDXuw#N89d17yU̓Cłt,kSG#/2m֊h Pb~7_v.|8WwrYu-xGSShx1 8uWss_8.G?okOYt*0x_/$Fϧ50~@]zfuj4 zS3N5_ j=qǸ]{Ʃ@W׎d|)R<ԶuBq1]TjID`1_:3%5RP8"a/؄(늱t)*8>9g)EӸ{bK8Isq!!(Qv{.js P02n7 ~:k!{?J!&@qڧ OBEQx+r+Q> *tg%E&2ϓ[ͮT^&_-my\=I=4"m~QYLagdWA?8ǫ#K鷪R&p{%sODѹf&9VGp3P}g Mt Hf-CĈ X( gJƝJnYcwI8N"]8kwW3ŤD',qV:jHо%ao9=9 k u(2 h#'*G q:W|1aWV`K5qWŲ77l&-jdS#*_ at S1()4:t8.,*qSd5E&8ge$\i59GYb|쿅OUnԙH&ۥK%+Xl ڞr1_&QKíޝ˳^7R;I$@gȀ6"bQ7E߬5Jz>`ѐ&R;cD"IJUT_->9h>*<ڪ<;a栧N|?[+܃]{{GoNy{_ οllbxsGi~f뗗w97 :H4ˣ__yg(.@NlWvlm^_ |hu5L/C/fI]'e J.Cޓ`Ba#Ze^)!૆Ŷt]\[f&D @+X% '6z1]1ΞI1<1 $bDF!{E\ '|Ʊ\KIH1Cvab/X#ђXFrw+anSA"s ]I⬋SGw j+9ƚޚeRd!|2BA`)ZHq&N bhb@'dp`&ˑ2QӼ>iycy2]iI-cF,&| Κ!Y!kLԛ)ts6p~k.+:cQǵc 2 z'*2am]y}ww~;t\PH60}f5VD+d[ʺ{)WY8:q]c=c6E|Kss5^pkKD{5~^`3Ylo/gi_Z:Bcӗ^Fԏ}d*,G1 ؓ)hHDI#Lz-Wka )I'HdG$BP<}R}9OO&8Z60)fxr B4B_Z>BBq\1tťe]T7w3 #.} %2ɴ5x 0 < N%x}/_@J76^5| B—A4dVI1 ឧ}F­JD$2κ\N5ynE"D'0FT^L?ѬM|@=!BUĹtDVġvZ&1=CX(A{EZEƥG6)i!眃h>0O9MJa'j :1dX"qB>MNDyE5n>I&\XB$"s9x$B\aUsVe%_?޻Y ./f( Ӕ܋Ix6+L3zq훝E]/xKFREt>䞕( 5И)Ρ (8e4$W&Its[]5宸ڕ{F nYmiå:ft7zO [ࠀvu](Y.dLSANh"s TxG$.31IA^mp1,{[euW;+읍G7Ǜ~~ޜg>w~z{'?Rjx>w?$I{B7!T;?W _x^[ {`WppΣ{76T&|H Ā|x8a{HY햲ٔW+ woyY=Zήf#^|rvw^~k|2n>>S^FSg_LGMZt3fc(0+i k)H#s>`CP!mIrE.sF b JGFPt]S3*@kzCHS5b90)AEMKlwk>\<>3_si/f?\rʔA'H u9PӨSw01bj81x!OB,b<`]l-&i3jiFU0vE:sBS1`GXx!N_0CpW{̻cN^]bRTb]Q&7^PDfb":'"m aڀw&"R1 Ts>VKuiP;% 8ITGc1٧#ZXrqn0A:jzeS'Xg#M<0#n5:0ʭȢ3<67%GNBhW+R\)ы0({ .ArgtKԸŤJZ~)1q^"iF2) yڦ6GSVq~yDu, hStsQ~~wi-*#25{o62ƒ4l1;Cy 큸|kpkPQL"QbZZ6B(K\ݵG[胓݃OW{o)i>Xyw^6|( ʬ)-:Hde$VK,{AQǎY&\?Wԯ m䃅4-`\&4"XI(6*z1ة*Y_J/V;4()UI$ C1t9hçZ W'VFHyx!];&]Ω0`YM7?G9v!t|WKsRd!Kd9Kx(=Xݕ/Ӎ4>(3PϮߓoTvNkœ Sζ}ۛ[~w7_?;~sx7G~`s `sU9۷ ?>ۣ__{(z|NQ g|RW)*AN_x 185\Hs1onBRmъ⫍bz;Z-@9"{Kt9NίOv^Q{q{ǯUa&;r-Hkn5n+U`,)6fd,PʱT\hɥ[Π1ިH7Rnqw_W8SJ(# >Y%P:ym]=7*iYK<鹚u&Ecrȓ1`LNDYsN4D.#IdRΝs ޑx}JęsnVpQL}̓spK ,NC24S>1S>LZ/ N5) q9?Az%, ]W9t ~"g2j̠3 d:թ1uttS/|f_c\^]Ԍ}Rƺ cICM]Ko!`g1 귍Yf>ì7T=z!Dh+ @;6eD pF5qu(e78`R^VS;_Xđ]3l`'4\.Rə:nu3E>h3;u |VUZ۩bH4 nU}B?me_&=3\G4dQǬ2 ԕ6BxaT1: )!Y؋f 0X3n䨲`Qm*F3w)w `nqLw"v}sdqx>v](0U=po-{z{v{9S-7wW5n}̂G6Ty4+߬O~-ļTw&0f%<Fq*89&ӭ2뙃Z[{nɿjYKβi伋5bID3r^{4sxQ񞏿HrR3)ʿ )\V1/>=m'W[ )z qS;T)vNh׉zMgU 3s)NS`6paHc%Lޔe:iDmP޹cS%nHƔsdL,%%UJ|˷ DADkznXPTn&9^R<>P]{Iki%<32_B,`<3-!RU^9GF#6&Àf&e\ D9lUh[1ߜtmֲg;[[FmtGذgǿ8~q7}wӳ?8}8ΏldS_# oo{Qs̜3ɇ?_p*iCT赪@ +6's7AoY?=ف䧧+`F_wߨ<[륵Z9 sV6k\&v+ܰ!?h?oﬗ9켹l۳ǻͷw{~I?/c]do%('E+5~-Y-5F[smblx$˲'ɘ֛jChKX'Cպ8(s*_K1 x,WZBҀV~ C]oWR+ꕘajaa[ASjhXZJ :OwrrT:d,";* _*0+VM̥Ŗ9MzGDj.ilc10n5} jsך@Lc.i]sEӨe̶pS}1ԵǜrA Y,jAϬCw 6΄ [t2%%ba3V^)xoFYad^IRMGh≠R"=YɗS"Rּ쯦C`.H0>q!Ikzئ^-3Iovy38MH {#!{7#B(>J98"6\sYNRyhdVu ̋FpREzv{dʧ 8%Ҝܕ8^(*Ynɽqw\< ۩hӽWy@N/oŊCo/gldqkB5s{1z'%Qτ7z%.0(R6Ρ7vW3O,;i$vWKp#kIbNKqWVwfFƹ?7b;Ot*SA7sK1yE]wjE5ȣeAnYX:}oqAW̸2I{+=,5Kⓙg},y"BhɌi؍/ vUdzU>)"𴹘&p> Cu@V6TLA҅ #oP)'ܭ,IWqͬO$ko*Bpd&蝣0 |A9Vǥj7D z@Zi%g=з7:{B`_H#ìjs[??YwOw{uO߫Ԟ<ꏭQ%ggg''J/̈́^Y_v|9^/ނV-" )C5O'Ԇ'%X8סo-'}YS&zPPठ1D# I|5)Ug :uN{or@H]g%(X Afcx( g9ɹX y+pF.#Q+?QIZ'fҴ$˼)5$s6j18-3vsqy1y#ypcpynO>1|9w㓌;u#S>/=&k]{Oc8,'|'þ)8w \3 Y'汀seaUW'<1xF1Na=P|19b]DinI PhL UX :`#Q]3SPb xDIagJ#lq1}RF8h0b@|ΉG\_WJm)S4lai:gO0-{P'ʸ T1nh(ns6J9"ڒ# 2~#d6EyuJ ޻ؐҢ2^KH(aXJ0 ɐwq=Te./xE(zbͥA>ZL t"V|UYL=wVK<^bu|{V 4wA"_ $(J;OAM3\kE#~4URd9A%#(3@nKG;1N[%YIw*tFouY$ોք`orm~ɎR[Iz՞3QRFuW.UUڏR_ Q]\o ~𔉧-c)Ktܝɪ|s-֤Je|}Y5pFkADC>nTOfdR*1ER[%|q%2B*a\`2EBcKpN N߬eq:RQ'Rҗ:+)_^f+S b`wƌ9Bsp- ̲w!j$䵎Si <$pҧS_G4m眦qkNfiQ5dœl!rjQa.G4qUzxxrͭF%{0Q׻ xZwVzvӟߞ_;}݋^|wwc޿<7o濞v؇d9[ Aվ=p[u~}ux>lT oon~w@˛뫛!_O "?_/zowUFl}),Oߜ,~}}v{gk/o.8Y˛7wֿ{uoֈRaM'6 N?і7ڱ{w|/gufVi)T NW" ~jP2KotAng)\4~z؎gcJWZ;ôT9㾒nd*F7ޖ[f7ZHZ/5坕0֋_ރm#Ywze0y9g @ H9 <#4 ׶lY,9w~(4zH[}qj'Val\ѼFRxwj9gcӮ4{t^K6;4l-pMB!ТJꂡF)YAH|Dbj..n-3l6?i m#]+2 q*`]"g,-w C(M}ҠER|tb9E9LڒN;A njhA ޏ/&xd)R~**M%b(^E0JiɪnP'?<.R"6&AnJ&<_H'`ĥlpk(% IɌ[强c QɅLZͯ8oq!u֖]"R&z%zZ&x[(N>PVў(exZ<ėHx\L""KDt%v^3WLڀ[ $Ck;.7⹓cL¹rH0_[!UckKǴ+ x*I7L ^= $aql- ],i! uHǗ\k]W66r){IDYb(ARs i \.;$l`-wXz-W"A Zw!u@# K w฽oZs,t\xӵ^:!.9\Ʉ/v"j(Ɋld57th dL]u gR d/ ^{Oo_g}M̛'fOov~{\@|y~A2.db|ׯxLcϿ?GzG021d޽zQg<ϟl?;|z<䶆G')3vwz0H;O~0\ӣ7?;>0p|+29vJB񺂜mB:oܬgǭq4C## y[ nv?xfi@fJf'N: [N)txP'M"#lBh&ڤ)+lEOxHzP0nӮ0l z]z])qKAg2*LGQ[Y#v zPg{UrРMfw'fi˵tDj *0WZlքj- #TJ|vdYM LA#c~ko.t+tJM9ۀ$M$-=Ȍ(F".=@;Z[% +@Y1hMMC d|'meGTOkGLluŰ&֗:9P+;]$ '@;u y"FVv1 VZAmp2 qP%WR|Ĝ=SWB+L:䒀a9#ͥVE=cȁk%Q}B{5ڮ26&~=. ZHܴz>.$ʝ2<k7JٴOȳa-NLŽILVIڀ cȉن7b\D5"am6?Hր%@mWJ"AW ,p2t;lY[{,dBB2fFMd@ކS|  ?^ù6^o۝1ׂcA@2 chh19]"|2Ox}vh^,s^Eש^)!E{OOu!I`X۫1 47ٟ?3hH>6@@l\to]'LUؤU&V#G UZk Zc,i%$r6 pAYiJBb>H!Ӹ=F 9,/"96~-lʦ as);DONu|Oݝpc?=ɧǽM~x7gZ[{\UB* sYnr'c51\#gŻ~OiqᰟV2:H۟ZpQF%Q~:_|/򉭮luGI;[rVhl fVɦAdIJx.r|\f*%ݭʰ+PkI U5 *ٙFJxD4 Z"c)@9^"+!"SsiR&Y8t jZW*-uR*^XPHgp2p lwN /e,)U5*Re K>뇖]C#wLAs8#K:c˩譒fDYL%R}G UrƩnb>Y_|0A A%]B)g|Ps!nSg4t %S(SЂ "!_EXt U0EWͅ 1PE|UW9J>EhFj<.+"Z`l@%rȢ⡛dN1HK.&r]$l憵47 ߲-ot!@DHGؤ%GO۞eS.C6,W;Xh44l]]x3%`]A䀥ox)@Np24Ӕ/OH۱b{ӽ~*v^ iBMCɠ+\j^㹒.Eg6 F*N,Dt#`f_[++~<>+'];:=`_.u8ż+ ,rJDl~H#vE1Su~T:%ƐF씹r{E(0FԴIt]F^ $o 5 :Ѓ0{x$Ƅb;o%1.Nƻy Ll\vnM&`1k"|6Q0 \txoj*)3TOIT8 p.l> X*RAl&*pIB2Su 48aUp%1J&$IPd5%pDQ;Y5 js?>hOEI ûO|_?/~{߯?ϛ)W_wK ^3p.P7r.d@ s?goL=6ao/'z <,5vA ǯ>{pv0T%rTȣ?kI[UAN[j %4/8C /sG&i :&CK1-{X̑ ,$^6KYj\fww>8Fy1fq+^6ŋ 2BYOJ=og]xg^&w: DPXxCa# o00iJ%LA=: gE@ q+'3!.i"U qrhS'5W峤 2Ig`y27qt tE< ]IpdxĂx";!-L-~^]";XR07 s '$;PiovU5󕇿KR\3=e+foV椈\"Ҭqe=- GYҟ)ńL cy)nbUИMB&kCp_}q%`>/BsûCqmwP(EB/>:@v-%mx+_CzV@20b]r;$7y%WָfCQuuNu;Upw1ࡶtU xH*B@`RH$*5-p.RªŰH{tI9Obx̒F%+JUkt1XG|4( ^HLd`vltDa=H$aEbGb4wջݝqqԖ5Qa}7b!ڹA[{~>y~=7@5?܅&ߟC_=y=}ً7/L=yWJ~~>׏Oٿ?|@ ~oC~r6;]e#oatr23m׎gӕͶ8kfݙx'O_~p׏>~wݗOwx09*ff4؁7s1g>۪1m%>1ȳ~:V I<ǣV@h߇YhmwG鼶3RT) ݩvUBzUOݱzS5wAq>R|U<^TSxW?WL aW~Ra@ZliֹP3aOvZlF^C7QWcg#u<̷\! r#BEJhw>Ԩ~#;Λ{b ޭe(Djb%l\ҠBBU@{F"Gy k~GB",:1d I2:mȂ, qd%[̙Mlr%Np$VSj^cH&370@;p%#h}>>zUAdIW*GL9 I$Z\'+of>Rȸѫ5]B^-<@Y ΣT%G+bPg}Ll\Sh[$y" 5d+,NƖޫ: 꿮fܝb lpoS,$+<Σ:깞ޠc+DxQ&\_Ƞ.=+A5@%3)'d"k\J q?lTТ@LS'̷]YoȌߺw,s3٩%N6WZs+_o0$ &vxbk#xN2fpWܻncsV=H]y̼d3KKkKmWCa͈zݳ~g1S:v._q\E<$#n.HV%:)eܨRb\:rU1fYnm7񰅈ٔLlT%6Ge&<E VX8 ) DM# =kZ}"fR92gA+qhb2VDT1 "e,frN2jq!Is2e6ķ̠. gJtPB㙰"9OҍVx݇s0=ٷ~><9h?2Wpd4Ji|p|:e<6zZBO'f뗈]VvkOO[|mwwg-4lqy!XΛor{lެ0g-t^>ۯORlvywSx>)m՝Ҡ'lUm(Q'kefsR9o;Ұ-;20Oᎍ]]ٸ0(AY)1h,H<*"[Jt>UNYJWn 6[õ<2iЦXlC|}#Uze`R8ϸ;%lB Dڟ7jJPuulwz4,2.hYI%0dIـv1504B6*dI0I bLͅ*U5VE5!@߽jS\̜[ͲhTdAa0?9/`(kލKXx,F=b2,G 1PGp*Z{?4 /W!b2T\IEށY:'c. ʰ&p? bIV17E ~\5U%{)\Jm E`bPص@Ί7Qȃ*_ͧzafq?z?ZUݑ9| `CگAy@q\*iBS bKy.Bl\ڟwB]X.v 4b΁ и7_I,&. "J.dW`xGd'yB;րx2b& ,PEy]xez,rnttUdIoVe@kէzgG{C 䓶7|5&Ƈ ADhq6&:hx8=OzkRl(EO:Ds9^zvʁ[` qSڣHzTye:X&\*R-E"EX4GB-K%,8T /E6PR2eK-7=n\'Fq# Tso[9vxXs'[|bE{ m!9ƕEڱ5Ȟ̋Gni˾ivP'dhR[gybiDWDݚі$b1 Vf1%1$ P(Ed֗/RS 9QjtQAe?<#^@ ~= 2S3 (ePgvJSOOڢ@dtbF;YlGe}x]7.,K8T!(Zb n@RP&E[QFeTd̆|T< WdҕNyuikPܟVW]e:*QZ MY!;f/N~x_?= ˻TWꕙYS<:qK)'[Iʇ?-q:g>>l{Ï=hţٗgO[gS}?6T?9j?QWڪevH=ߩ4ح?JGSdveڑu+N:nO!-]XpƜ+6 ) df= jUYpeúMj Q؀Áqǜ/=Wl1eQB˂_g=#G􌳯#{̬ȸ 3* I/R0M댏I)a۩Вs]mpz/W]-]KTL:+b8[B:ȣָfpyK[1sv^-Wn\i)9W/Ċ\BZ hʒ6@J!IElZܿRQCF+p>PAmi !ǭk!:W>XvrkYZ' [/3(wJ*[qz_EːAKTr\u 0\".םXn]Fˮkw)h7R}{F:#z.R@ Hi0 3m+ÆrDT.F<ᗙhI,td,sR.1Vs jB)Ҩ]bB,`h' RRDشfJ4aR:X`5tcd (ő,1d/E P w0ZaPl ŧq 1GC72Fl<ݮx\hX$d3p^O>iTx tɃaU-?Ϗ/~6?rN fwӠ?;v/Nz n{ &ղFCFn %V@MN t(&A*+V!~TQa&f-- 90IHґ Lm,3xa<ҮU ^3`Et$oҠ'3h֢N7?mv3WņuE:2&f_heVYO;:Կ7g]~jX5XCK&U*刄0ԪUEH¥ `GڰhCf]Ő*tMG+pz}/?9|z28 4~`Z:٩oG)PlӪ ?'ߜo~ywѷ盿||3sQ|yoI'x lA|Vp^ H;)<ܩ<6{N&n_>׀y6{՝ OW1<}0tg @>,tiWܝh;|԰ hV| UaFF ߟn`Sk vo@60SӁ 3)ٛt푾57'l#e͙a\t4'@jBvCi9Ձ% T Z::k2Ci'O3 PEPBWh'_$V 3 l}豆R7Ʉ8\@Whd)Ó ZBHAqOFHd#Љ<b1@ |K%1>2~c[.f`6O{9𔕸Cd fblxN!p!tr<^#EGz%TVFE9@7$KDw3)^>SBm9|t5&Ť /&}7id=Y)eگ";'XhخChp a 2n#o9YY7֛k)R . c7O %. ~ou v`jޤ"vz3ZB<+A{ \262-r= 9,ƥ|:(19N_;Vb]5>cT܅G b7`ϰ.D4!^U5U"5hi˙J`Ć2. 'v>ZO.]: z T{)̝F]<%s6R3` ld7qt.ר,lOO}:߮=کVw7>;n7GAk'惩I)ZiOpfɇٸp6Nz2D5TGV~jlp٪0QunU4~;[➢hl?“VQ0`lUusA6kt+QJE*CgҠSedxb:Ud1^Ԉ1|"M-AS#n,+Ȱnz:(iVѽ>o[6jQKl-^hK;A71҃:,b2awfSC`nG5QHAA3+;œ N['[CYХp]O g- wT۠(W@@7ox_}O_y璘x: rY=mnǓPf:PVCh|adgݯNF?IUuBj Q2wG,P,؀\N5l|z?x<,E_wV!?j VS_o|vӝd۸ۗͫ6?oܭ=f) =HU6"SP !''Q"M% &`<1ˏ"52WdIB HbbWѱ %b,dp *D[ELtTFFjzR|fQ9@ؙ >"spz=Fs|!<)J3ADVu@]S)K l r,IW\h̺H.κz&eV`%0bKjR*Wbj Q)xdׇ_|(*s&`E Üj`k$k?>=Zۇޞ|}{j3@~B|ﯭy>tSN|hgV ReR5 WAVz26Nv JԨ7;7'syy|yzsp/NZk믭cӝIKAN:aC8hMaOA7{5+[LR $`UYHfP@M[!qQڴ-Oa~-5 TBVpxwJd^TDchU8_v»uWg0օ~C7VTZ'k^]-+%x&&599^QbT>&ONy&eW@'LKOֵ~WdE講MHfY d366g]7|' .mTQRvBB Hx9_Ӊ-`0=E<`+jt`ZܚׇnȔ\&ѭTR|ot\8 'g RC91xYC@QA"~?fE% y[z_>s`?shWE.9Х 1zP *WEM)fN6rp57̽gxhS ZhN:hفt!گsHQK楈* 5ɱnTnKjՅj.,F[ A'MkLr1;f=t!#53,Hd]BƗ,ͽdGL9,H;{dXeYLʀ U=;PNp7xUr]Wzn@S<̝~XwzMYEJ!y@B6 vA }Hh9[(ܖS"Bƥ~tĽœxu]ItM&ʩ Mב&$2ϱI >nJݞ0M#1m35\U k*Wf+ ..8` .;zA 7Q"< ? ʼ!2D Q K,_"k *!S&0m) F# ݞ~06wGN77mkv>/#͊s햦(ӪJm^4x$l&gOyK+XA ?~BO>CЯoJN9w7~yuB +5e m?/ǯL&uӃ=9xߺ^~֔xC`HHE֥ݚx54_?]M~iYiџ>y4)7 ˣ֗2`w0[N٭]]N#TGBHh7{6+ @ܬ-OݞQ h_ҎAW= @#yͷK촣q]8}uwzZ!NagHy j]x<驐WZUYJZ6Y)Q>wf̌{j.NZ)gP%Zrj|@Krb^]tH3e#"5||ŴͤoTYX̒[[%E*?\ZdR'%D!6tuș-%câד^Ȼw“=K^w .;Śu .7ӑNhHq"aiABZ[#f<4jO hGewh%KQ3i;_pHp d+HI&C(Z8]xX~6c7Xo3Qqئɸ_I:e/n[o,[ Zd\ʡa@.1S[r.ME-$V=+#<Ϳz!hL|=+G"&)"8[7RXѷk?=Vo!]&vhEno/2ȶ)غƅۿKsڕ m(d1@3N|M[&!kNp d.,jGaok!t{۽tdLX.e-?8Ws}o_+p\nؼ 9ocҿ6PM BCAm܍Wۋ囶58tϾ|#h_@[Qr2J&C{ATԑA| aP0ѢBlTzn~*9Bʄy̟ l?`ˠ޺tY`5ŐR/qapd*(~L 'Q!:s< )*@ؿ&xpo'XK@;)Dh0 Dp#Y£6|-pR@DqDRu rU 'T)^4&1I6l);5ᤗ^E^\AyM Y3"޻4tӱQ%ulLw謀?*$:di1t|du.MWc[?t .^ǂl#O5]c`1=/%c#`T;uYa:mB;-iT.ԔX9?jŌDL2.U8<809MŒGķa^x3I"x5ҭRbڥGD,iA` wEHjba,3^p+\V$llWfC`ZRφ*OU2z/) H /RhNaCH(G5']#Sdlg"j*Y GSRS`Nt1@>Ɛ0TKh9 ~yܞ -&G{6Ma=m*N |B$,f;5$;{?ۈGWiN7%#PNj4YStXfm igMK{%4I]y1>j*G Z:o5|meTª2VQ%W*2˦2Y))g3æ?~y>_ޜkx@t4s9|)@ju\ \籮W&^}}sK??={kÃ8k+ @^5aZEfZf{'ʋ 0ǫ! _}e?l>~?|yxZZ&CJ[H<2'a+c#\SfOshY]Ha<`VdRhx) qHzJJ""k@#eR@;  !]0I9w/P Ϥ| LGWY ـp,`'! lbz`i%[`]x8s6Ik(W=\|QMohgCB&d\@El9 |RA62RL| o'X^3hu7d7ŔwɷyckЊ{oAM?!qFX8Y&xj?܉gw?>AbȚ]&@~ .} W7Aɧtʮa2 踅E֓%_!@/7} ͻ[e@{hؙoGݫHc5hE;a] oų}·,@?Pf_aэ퇠0oS`}}~y(\7j_zķ8gr;tn]w!LW㾕Xp+Q0KdPCjXӠ&]3f@&Q8iBDt/i1x B\264Z:)k ȤdÑ!4fCVWO%DVx2 b6@/kLδA=L"DNuL_%İLBE zɶ5|Q?| nS1WmASI\7=Nh^9["V: _5>pRx4->;l@YM)~%?JЗ' )mSg烶)WS6iAIB,dDC$e[ɥJj2[}N7rV J)S-5˧Gfo?hNd V[Kr )s{扪NNAt^7,*AdY09!Qթvtـw۫.0C"/5iʾmRmI3S7N%Q.#bI*F%5zS$ӭS%Mce]pQΥK*bJ >HhIWnV2<_bK iLUK{#&D&J)&J$~] 5%l' Ur.,9bK}6;P V 7_Sp[`}"r/dYG !k=4M)YK-whr M$5L&1-֯ɒ!tMs`^M'Ý^`F %5mi&[e,)Ixi''gGOf???菏NWu\Nj?\v%K l b+\9y_ۇ?Bs"-~z՗g'y!`χ{_^ ?<G6{q6ӑq:4&UÚ~Mx46_4^4 |:-=^6<ߟ}{j)?T~ޭ|8~<=jBׇ/FK`<;?ݫ^M ^C>he>/@?wJgNK[SNhi`[5De)4py8(^~-- rGRj1ɸ#5-aԖZveV hgmuwǥИZVs쎍IOH[N6D5I[$,4h;k a_3-t(*XU hQ}ksɒjƖgrzT'uWB+Jp\#N&IxUOH7GČ_RM,iKVD_ʥF'0XYWTc`s+ǃ4DTs!{$7 _&T-gbMZ.'<@T.WM (YX@G^ SCmE1S(!2rO% ]S-jiы<,Lqf 3R%75V;UrN^ha>a!DZwGĜlniWʷp/R`7QZ:7tGwÞPsG"mtľ7rK֋* :ǢK  9oh?tHGVDm*!YXHh|]G= 2y Cӟ\k7+?n;Wo^ž!o-F@>k{wIjcM߀Låî;Qm+-XZۅۼ7[wmq7Zn,8o:WoWԽݺe[~+BxUZxZ(tq ۆ:BҐSF8_eJ* n%VQ$ FB←}lq[#v,UPЪARTϤvzɓlE6z@SR@AyD9[ē.HI[aI5dJJ)D4?hؼک565,1mgҳ4KN'lqϣŶHUϏ  ۳lW0K`No/.V9ԗdzAxvt=*s C@ȄKQѐIàshф0OD6+im SeB>QԓUuݡq8}(_^Yޢ|~\????}Ov=}4}p̓ы<}5+̣:2ɝ Wλ|>OA~9Qqf<=9i9nY;WGWwdzӝYYz>4Q/pzQ?^OkggM砯L #}HsG]mZÁy7Gŝv22gM08x| Ŏ;5^;dzRDPJ):ڸ뷲Î6C\4s" M]{c3'dD# B´KѴ8hJ6O5^엉5yo쵹^m~)]e622 G$; SV3u֤uR&Fqqy`0GCW % W/Z C@vTI/؊*9d Hcmh5ֶHN +F e^+OV!'4EeJ,]7xoY Ե]v+\iG; :Tó:Uy'[a -K6)dh^:@VȒ[^g#\Fλ[qMH:ֿC#:9H&e˱cgѹ]H?VsG.znág;eʧ!*ijBVqߢcuW"\Z-> 9,k|hL7ڗm?ƽ[ % '\lv$.h=?Xd˵~# tq]xȪLWPd`9YKǽ<捸B{gfĹ4qAgQFqޭnD`m%BH8Ȕ;xC T% FIʧF9{zqܩE/?~x8pOwOϗ绥iϧRj?G&d|]b =/OZOwu=Ё5%5(1]9:r2.KslBDnpNd¯Ra>S A 6ˌ& 5U)et-3r% 5!1>p@Rqm^d&ohbRfC ur![e -g*)ɢ@]C BE:eSNWr^膑ꔰj.^b`L]j՚Kj<˅ZηulACDijB]-_-noA)qusvf58 hblཆTʥ)>:&NjZӖ;$τ)П- S<*º\DKZf #\Jn[di_^Ct5e(O**yL)ъ֘0\YIc(e ]Eє In \ghrKܞ{YMB4J "񁂚*F6SHsd ՁXF!@NK9gѰ3:eWLQ)h*(xT*[7W燿 |52ձn>7'\Oy} )s9uW"H_! ʿdC^׻˯o.@~{wy;o^] _?}0d'7g'wG!rɎJT' AKOv+/ƀt~/O>wקǝGWO~yjtxV}2Ar``V<觃hz2wZ~KQtt21yY}4i`XMYG9i~CR.갭@Τ1^Ufې-@΃iq6J ؾ]Fmewf}uS&@c?6_ד\|v9@Q-3\"v[4͠F*|9]J ((lG-)*i퍮 QnrHRPf= -\Hl- u07hwAj42lr JEVu,/@c\Hb@5FPUv8qg%X !oIĺ2*f 0^A$dDlE$yOĖB9@(ekzK} =>`B v;,@meLuMDHmJ]kBjg --&M5#Lt9c7jQq #wĚL:u>`J*w߃& oxnC-]y܍LYYHLxh7#[߯޵zka^R, o2ZHw e- c@ Hp-]ڼH~Y;Qr:Zٖu!"XT3c'.ڏ;w<-2 Ճb#{"a@%B"pi$X1[wo܊y⾵w5XyV y6E;4Jmao^Ľu Ctt m-)WԿl0fu8LPK3W7TK…ar2 )1f1HE%: 2P,!aea m{覅=Bڥ7O>>[NjoY+.{wqM&sn9Fu/~}{~-0s%^|'~JE] .: B͝~}yg쿹|~񇇓ǀ=.zOgC}V&ѱqoJ&7/g﯆N;ۋ_~:pӷWg';ggf_;=k<*&EgǻAn wݎ|4ʝLWiPcs=@nWc`y46vA3t{*u}n/tg`34'ݡ ʸ2AҳB@bI)1,o*ݺPȥr"B̊aGAK.̅k6&=mRClGuN֩DuJEV2.pi9l 9ͦԒƻM%ЭKj(YXd5c Gl\'fR<%4U#iF؀|l /נ}bW7k7bI、o!Z&ck@;tj Klj6nTܡd†W @K*V®>8Pg3h[ۼ{#\Z;km҈+^Z,l8vKW2 #N:n/Yx61<ڏA=ݤo=_z6oZ\QraqD +77\֚,$̠N+ƍw Mᖫ˟$|۹z3h_ 9{-ӷ}/^ :mK?{ε;4◩dj@~kKAm*tИw7T~BDdbIl&adhhz$&'Z> |B`$ȁZƯoTHɠ녷wl0 %Pa4f80ơ|>*A Kq|* b^U(Ow'xJy"Ţ*hCUb'~SH*p{-r.%N CsUϓaqMLg{ߟN|dVr\EMbi)g*7.` d/OfwJ5o;u4bZi oWT”9. 5簠LD,hHY!L P(+E9 xV>'ŬzSk:ƴS8 9ZJc6ZBIPU|oZ54`_˓g xޜhkT|5!_0׉@#ޡ=x}ʿk7\!0__|j<ToN8Of{@ӷGo|N>>B{5)5%kqS:d'efwՇg]WgǭW8+Ow|^k>m=*@;= ruqh$tq3ӯ**f#`3ϸFg"9 v ;R۝b[NWs@oJ!V𥓆a1[s[x9,] v4"< /bV(4ȉQ ѥ8<)d<N!* 0>1.ϷIW^ Մ)[G!n*1,(q)"W9Ԟ*+g5Q#fHʸBԍ!h$ʦ269c$ {52UQ|M /[D$1Gpz |<#\IV޻鐵&9E:GVXΡg1]{R5 Dw[ ?B$2o"o˚<H/=uaɸp<6/ }κ*pɬ?2vVjIyq<)J%fܠ)!>N_ZT]-oo%|D0t$>W(SUlĤ-{Ƭth52.@/ʡq-sܗ:fMmHQbhA"霶V!* BCK74Q2K"/B73m,ƤR/츭pBÉ@5F5LBx *k)qU$kU nLr46PͥrX*3N)e#lA׋Z#6Ua?v"EƗ CwMSD&.錿$FrsRk&Y/eZ_UuA+1]͸.Jh X@Pgf»"ToےsPA  Bܳ-mܹql"Lb M͈{PG"I#c?#MfAqgvB1=Ɲk܀}|ӻ}=\M sM; hsݺM{[ !.!,[o2h ۽q *ܵݾuMY neֽk 'qi~ˣgӖdzgj jQ[~0-q!˥Z2O,QO:jmR3~1yR#d/[E!5x Y+⡂Ur)5((nUz) [oDO@m2(yM[ KO۹ H9s2"D!_VwA )ETک1k>S ԌJ(BGZ-얘-K\hJ~x^ɕ,*(`{2*|_&mnPSerM|7"pONd\*(ĄzQeǠLX[Ik Χ| qO t`A&LtOlo=AŽGΚ ,ƼaԽuF+dص1om.ZY߂CH7Ӿapcoҹ@NT)O/ZtlŻ76aOWc;]"E0 @¿h߂Okヘ~C'[ 9?l,-` $^W͵* ŝB"m~ZԽ݅u|# h|ѳw"ǐshAzĽLQ%+G壉0]JD:XP IF6<9aNJ$SVZec`I \4xK ZӄG9PO άg+WA;t INhفNRu5+? YZC/f" @Hՙ̀C|!*j FU3)E%!NDׄo1 pOjGWcvZbz]tv9G: ٪p=#uR>\ҤÎk)H稣JNSDWx;%} I֬M$:HuN0@B$&%gu~ E#ծ G*P\:Dt!&_@خ$\Ŧ\dFO|HUNH|Dc96D$Z*#F*+fÕLaRLI40d@ RIVrsV5]ƇmUbyW A]dhyoFjǻ Ԓy=cpJ^6㓀QG7*5Of)(BYmh^MVQu?"a?񔓣\ȉc}P O’ \HW<xNV &q9*IQȦZI3lpV >]4RbRupByN7s[ٯrە''pY^Q*yZ&_0jYv^^sJjw\>/~t˧_?]Lz=}շw?X˴f+9V>Yl3l]Iwt.0ϸ*NQӓ]ϓ:܏[ߟϏ~}ۋ͗{uț!Yvv9.\N;&zu:)̝ҦsrCsTv7z9*TlUnKE U s4)sTA =O[Gmm8enug34 㮚"Ia6[IqXˉAnlz.)!'E5|h큅5`T.kiz(q:r HO8#+hW| TYDť"]5 TY,^6@qQm \jr †Lj${~~\0 :=}U#e9H'3V>ݫ%Ɩʞ}u*6*X(WMɣ1V!12׸b|E'8w+o*6!w$j,W~.nkdp F]A8 |»@MReڽ{QJ֘Ÿ!\IYs98'wĽ{U̯?!W56ci;Tt#z/Ht|-$x/l ?}d ?\gbΰswUNkQ{s?sDwcq8~t۰X%] cأQ:t!!s}+T :X(_Å+~p,Zs]@;^y«QrVsLQ Q*.Cq*ݫ~ל&ĶGe2rD Q:Kqy0uG\ Ov99). GcUlXKQ 3 ±&sY>ah1DiٴG(@ kڙ P H@cu3^mOX[y%*Jp&-y`-ȱ4nٚص-iD1G|ڡv>(QL8l4r94://_n&5AoO6ˬY6)l`ք';pI/fw!4٤h0^9c2m=d.~o(. p; SKJbE蔅J&maXచMd(v)˙dLr^eQDDhI;]́ĸAK*6"M= ܂41 Q`V"\Z݉9Tius# |ؚl5g:<,ذ!U dD>u:l2 9v`XȈ| py˒)8T1GeVA=oa  ꓣY%+1CKR8 %Zt- nVpSYe73e)u⤥L GӉr=v׬LÝvLVMը] O7gfxa1tϬ%gsnɟXX4 @:|7=v,bgwk՟^FޜM1|8?/pBޝ}zs :A&bӚ~; o.w9W.ƯG/O/O/Fw7[o.'^Ot/wkS`Ƥ4` 96_o^v^>l{ 8?./>_N=ׇ헇oכn|z|y}s1Fn=\lY=\=cZv3Cz&(wW*lNk[IObB2AC &Y5񿳽 e'ӱ=ݚςv-kgqn{?vvOqKrjK.]LM|^KSܦR؏PG^kɷ4,NyQL5¦6'+d|M'&Ia.3VZIRH\9!(}?pڍFFVТ*-3YޛEYOA dͰa<*!|rû#_EG&YwIGӕlj[c)q0i XbǗr YM5봓,Bb-1>Xd« <s '\e6&uͥ|K! Rڿ![E] ?15|ewAĽ7E=xŽ꣠}1QWeY;g޽Sy!Odt1ikTp>#Z՟LٸK" @ w0|!;ʳ0 lzz (aȹ5yU⏞{I?`g>`ɽz߾##ķ0_Y*/p)W2 .\8Y=tA8Djr# ,4Wܶa߲km'2i/xʟIcL1Kc{$_æGdVCFhbLt 0g(ـdh|!GZv*&Q*a͑>HufCd6= aRh<(xKy$*@ǝD&S T *i T.˶lDK~CN;4Q'۪)ڮJ[U]9ҍJ#2THzD׬ ^#݆Ԩ25*mE#av/IQ<UPDÐɄ=^H. UXD4.ԥ0YʓQR>e| tf-æ:v5OYxrd)/q/xF RIPD.dA"8!Ǩ^n<do~s0ȟlVO6kr, Y#G;AiՊR &dw\==޽8)b:f2ḿXN/~t Yg֟Tg}bZoO}R?|X}a4-;]5pBL˯V?*~ޝ~ϗǿ=.˫w!& ֓a^qgOw7'wgW[??;v_/}wW'_N/ף-ˣӽ:A|Xt>\muv^/7+G}g͂nh?)n rە<Îެ;Iq7X;qGC%(RxUXVnhI ,$6Gqls*6+$.rV!QUPccbDhFM*Z'ۅN*Qt.)vY/E֒ CMY;y?ՆW S 륦\T$%F\@̇i5"5q+STHR@J@>Czٖ+B9-~j|rB{85gNM`[WA{.j&XDh.ՄF;rW$f\?øj wxw=H2XHy%?Ev>ns/a»v/l٨#Y?yWEտ] Qs9S);#ܽ{{&Ƴ5$6UN7oʩI/ݚa{œ yD`{6dm)I L4!6]Z <=D.pKdٴ7= ޘvl < B|\HL'{lpkup> `#"v2NY1_ay9Q9!z:hJr%K i M⽇plM'Hx,޽˓Q>zW|]fj|9.\/گ-کԄI_d`4ݖW;ՖI&=Q˝mרZ*d1F\:Læ<r),n0bdNJ|\<ީ\|@ dgçY1b* \U3jd"Ra(xAr\X"euR5]4y3K+^Dfr݊\ԓ)_-GLjJTN0h_ySrZ H(s͊z wj vZEl,aGK#a3*JѕڨWjfYGBa)A3bl"#GjEv˱V9\(iZcIaKVnMBĂ+. vI>W̆KS6:g "oUM=!t2)B&g!2ԄWLS22Tj5Nu\Udޫ<LJqq?۩ Ea6ΨZ/JvKo ߾<^|V E||qm[r6buo]~}{6;;)YgY|blV/ {G!ϾfZ=xߞ}~qyy1qλV;&X @,xu׀/zp?\N>_od|uo營&ۗոrÏכ/Z;g{y6NͤtꨏˣNo4ԭYWOkvWMo5h23)Fd[0Wnfw?jyjд=ۯo0 A`3?jˍ"*]H0E-4n;=yw QꀂL[F P0[&&;$IpƛJxc]%Ad&P/RlqC%Ϋ~x:HF%!"+xؤMa}ZJYkkބB%6kR%=^P† bgj.R@ ؚ)f$q*'duKZxRerBPH$NEVBZzJ}{$ú+zɯ.7:F&IyW'Ybd7OדWە7I/;)Rd.GNդY涪<@vQ?{{0@#ؼ^.!T>Txl$C /+[T)D4* [4.x6U pY:?h>Zn8HAOd,vR=6K^"U)IKyjJJ3˔Ld**+͂Pɺ1N;SxN?۫L4*8\@ₚ)LFwV44~|JF@<)d)OFӒ1`r jJePlsۣ\[ʸvhVfMA:NPּ nJPSEb&Mѭ02d`M-9j`It16{ y$"Ļub%KTA]5E=>[dZ5Q}@,lUlUvQXC"ԕ%6H.gғ~]9*o?tay߸ة.wѤ\fmgPq;_x-n6+w/OLPaұ0X?e{u2ɬr|ËC ?<3^H9FO ?m%> ˣxZ3yseuo}ȵ%w[IrdicO?}@\oǿ8ŗ-@γNi.^^wo֮Fqz\yqйF֭58{s:k {=/qpYPώ{;ЬVHA\T "%2bxo\8ީv RG%wݑ5t|mqON˃fGv6|ђqKffakSe*T1oiC + i*ۣ|2 :e礢+LH@{: p$M/،IڈJNv(R&Yekf]1T7KuLŽ"Oj4ƍS) ?ĽއoJ!R©[&/ G(#"KT*~q?`=e5ZCbbCJmСň>g7XiwQ bqÔo{2)H!äoRaGpg~³F]l̝u 8צӬܟN\*{mlC(tK*# ) StHbR%؈q\2r'0WKzۈB{Eq?JW؄3[D e-9{`LZZo[oP`Q* CEag=ۘ!ԗ0Z&"g~"J"O ~p<ǬɈ,Xs\,<1Jؽ^/sLĿw󀜲Ilq4LFJZ5 HL]Rh9 XBdH\PtCƝD̩Է*ELCIfa>DSXԿ-#lz\L#!!-\SHO{S*Nր:}T 'L%-~ǸH)Cj 04AeP-G jpȂtJ%GZDĦ!k,Pl%.G[__~U^woU&uw9~{>VsY|qڭK8[-rRlVMj+ѣ*7iH(&hJ0Ę5l,nNKòa!iH$] 8Jo\jdpYU,z:j_7A3ȵJP.b3nATT.\-є$:7h]Mir*w'X*`6opf1U2+&rr( oem![enB5k;lb9"0^}COJ\Hrj % ZÌw$LRC:zR"Y=n*ͺhR S.|lcy3^ъ8pvشfQ'i(9=QfBKT9!13 FJ (dI!rUbQns}ڃaw{X9[Z(yhĽQet.=GY&k-s5# Z_٬#Gk|D@iM RTp-lRd#~h$7֮n㭻'[/zggG)G+Nvܫeg_zzo/2`siù_2 `B?:[@W^_ߟ{ۻ)\}Ng3K >:Ʃ?] >9AikK>\}!?T~~ iZ"__/O?>?{f'/G.o]]#pfd}Ӹڪ}~صt|rr|s//uxsΫ.b:<٬z{>笗T?w瓗w@n{_Jfm1\|*% &IUs$~p4j2*18n+.Mqg9߫݁vyP6ْNwJ?/U(lwIL ȴ/+E 9F'l<ryS!SXL, P21rh2ÒFvr_~F @:F3VG<ɦPX Pׄ ^c T3AoBr -X*s?8d"tdUL94NmȄO:Ȫof< c|FF*z”C2[J)SLiθ貘ZgU=QRbRrUR:P# B ZŽ1;G T$`ǢD|\0ZW) =CEÀm:'99⪵O-[c6 E<~;6ȚgA*HV!Ί!0m*/BƬB:3 O:HԳf[x]KAG.NǼq߆߶p,\s\!c\dJ? d/!)FNwc0M %1bij CGC+_yg;F9S:Shsd 0i/tr@U)|HL^f_CNK88$"Y؟F^1NFJظ!u!.@B#saD(|#2A!iiM~pBt)W xNrIg5xZ7&@>FAThZԬ1uYV ?*Z QG lʣa9j]կjfeԏ7ҤUuyJJޜM>_??_>]}p_?[fcf~YGjfY>o~LNib~z}Lfl3=d…/j9 z^nA)gd|xw>z{6ڄ_'_ow?\MΆ[Ǔ꫃{^]i;<^bR}q:8ۼ9 íZJ|LV5QS9LBǏ7*8ީo'&v7ƶr48lbj:TA鬽Tx5_ 6l_%9rpoĀvUƒ #.HWR,7YUPVrS!I[:AABIڢ9x7*ӎnMi謵ϥM)κ+I kxp3yy)Q%!e>V6Æ`h$g#=]W1?x*rDWHWT& 2I`OQ)1ic"ka fv㺄uIMB;9Èc1HJհ}޻>0XO7\@B:|DfM-gDd*NXVatI^GLVza4lv-2q"9"$5[`#Z97]oa:HֆUHԻ7\ $~K!>鏸Wc1^I6~kGux\~IltԍF)\ pY~s6#hVeRXX·g{_Mr#jtlWĸʟmތyjL<qkbNZÊA> N_5lZњ [vP6ACkD,4=tqЪ穂fϫei񁑊y\D &Q̓F&YoRiVvM3^]rI ZG#$冄5ޠ9Ȥ+ER5!զYВ\5Y]e$ktY 58>x TVY昂F-$5Aŧ˃8T{.B[UU*-٪2҃*o[BstASWaC}q9>Og܌uv.F'ݓIe\O&Is_ty|N~|}'߭,OV'/XGV 0:,Vu˴Hy3чowWߞz}t.;ow8KRxu9"rwh_^}|~ttȇo`X^[o6^nɞs}wU{v~vzqA jzn: sw1x?ޜ |s=>mxŸr>*?m<>;O'AjvDŽES1r2lf H(kvt20fW6nlvA;vΉz6+ZV蝾6lr.&HF[yɌv'JY ~DʛSb:‡ݮU39l%#r"Z@9chqUvJt@}*bFRэX`1+eK7kQW2T%G(Z}@*Z|5triG: + .K&2ħ6r^+~6nK |R͒tlavEEEV`\a"vB*x(AP9鰍Ok8~ b x$bl^t2u)D5&au%+~6Z5v/됐s! n3RAv|6})Y'v]9jٷ`_ao5Qk%]601q{u8qXKl\ؽQ&@NtAxijt.p !> p% RLؔ(|%Rg> 75_iQFݫ^!D*")K>r\!rl>KWLWr .,">O2A5P t a;iBL9G4tGIx#ECyȂ.B,B$& ?XK@וR& @ pr^i_ tLRApWoɻ# //>\OԾ???Q Ojg̓~9)7{Ψ&"%+_V+gb0N{@; vCNnR 0igeҫ>R$'G7ٻO 8W&*ԫ*P6I-9&oPMn:7i]! 9kzAl7|AOvYT4hI@qWt՝Q/2=A*RV>^N!jP% ^UrB"p(X-iK*U|.*XH,AgkPХ&E.\Y?ȧYW[M-s(Ο(|j IY1|ͪ 4 $\-lriŊ5- ^MQL- Ω~ Rxۍ٫H/6.wjOO_^|~dvYbzߺmLjӭVϮ~^~@_IV_/O_?!3?|f3e<&,揎i_AiGHf*?]Y/X~ϗP̃^/o>.^v56u^ ޜ __5w_!oφ6??do.|ٽk?m}{>~q2iZ٬s~rй=]l6kjˠE`ǻ_^r6vGzZ]y32omշPg{`l R4KTD⤫@? 2CdyWгv_W ~UY"]z#lj}ԑZQ"Va}ӿԤhɤ$.Q"U24gm҅Hy$zz6ݮ k.DYRhTKJl 'lFHY% (KL9"FŠ}"*FV٘]HMK::$Sil"virBtg7Z%;](mgb'.5w}^!}W֓jJJ&%UŸ.FԵxRo u/5SkL(mIdPe" &#Hd:ךBpUsl42mxWH\l3] UK;ͺ-Y.!O󀜸om*k}qN>2z7@8ӹ<>b̻k8%(ca<@!yko : mޱ4t,m !2D.ȴT tuo5'z,RNL'Q^"p·}+PJFNMU!&2Dx#raT"औeYKs t!|Tګp1ЏFtK\K%<PyE !8104Pg։V-#eo^+ jfYfM&9촹)'?A;!z:bhLU x<r)7> jCM#4H;ĔdR~u1s rߞlWGEI|T8L݋gٮ0~Kkw<٫o7~r6sN7b)PE YV8ߟ=&A;`ۘ͒m?OgK#ϒy~mDOKiYFXKiAZOyp~O{nv>y/|;H>j~D{D?>8ts6{w6p|td8}:p>kpT9nBCd 'wGg?}ty׿jN ^+MIy2|ϴ.k¼]|پ9fI/{{]|æ8)cg֛¤S6 ’Vqa7_Xt.?m&-Vc ~uFuRʅ0ل㳞b>A1fӫV;7t|,jV`C+Ir0U*`wRױk4.l`pRL,4uR>* a1<)!( 4q[}3Q惠w9BƩ!{ .KIYmCr}=r G"=z~_ @EH$퓹X('K9IBjKyB\ڗ_eD((Ćv;HDE!/ `{pw.$-)> /כh:o/gO'Q%{>n # ݖ#9kvaw%A‡]Y=2. 4u. _ǑlXih\Ƨsb.5s!|Sh 8TBorVΜt%j &[Znj9(Жkl̛eA 0\WivMU8CeM=_)3b>APhQlT8@,m~S4t^vzl5K?j.+b;ue *mJ [.%{Y+eo^|QLvD>V3ٌ_C,\Y+,:T! R6D\U!Y\IHYI v|ḊU%YQR9ҷF¢egbE]bJG+)Hb<Im\HKRBd%L-bmRnO )bB. \VRss'7ʶ-.8xiqh&?Hp`Ҷx<\x=|?=I)vlS`)O{nhlp2y8&#|s8z6{<Zw-cy41's0^vp0p6d:= Ûvp rj sg50^U&5~UD/㻃՘yhp{h+G3b7Vwڰ!,_}2Y@wɮ[59ޝh `MMK3ug$o孡3r,Eg}qnP0lLs~1n0$RTN=; J!#2Yfb/ug3N]KbX/r,W *^)*k4;&2ڪJ!bH~ٲ64ѪhaNWqBmZNyrUI\| Vî_kd+w%)K.VX. o ɧܨ` Y?] TKA6r*@)/<"y!ȹ0K®_C_ą[S}tt-^Y1h٨ڟҏ8 B:PdoW.G`ƛL|:HgQo AH9"~n Z6^d<ӍM&cn ^VA&+Ak%rp"l# Pص[ TzWきwo_oB\oBQ _ENYiW\ q@`y:KȄ:T tJ7DhX Pt}L &"6'(8K v<}F}=~x,F .ІWix<՞u{4;Cjv?o}ȿ^||32x1\'ˇ/"2lz"]N˳>m/CNe:K@`w|'ouB)!M01,OsA8quG@$bû=b}w2:\.7y]mߟNBd|?jowN獣y<5-dr/\fz{>oٳw2&}d/7[/w{q8ү[Ogڬ.fG9j@q;=BV5VtQ]tyWlWhYUaWjXCM4d';;,+{jPU%Z虭Qyz5l$ E״TntTɊ+AK9/ү<dUd]Ge9/ڹSBB.`*􊣽 F٫;fSebǕi"! rC$ӫf¬+rl䥐Z̠ M7ёY368yT\?OA;3VO,z4U:HK1YKru|>~*X7x)| J\%.8C&X)gz<'Cpן/(rA?!:_֞/be&xWA,c^3KDhEShgcoPo·ڵܵlճ|뿁I@>1Ϛ¦bdxIzcxp=}ORag<ӟ{WInW~d"|s"e:-+ϞWbtxvs+/[HFz"HQ w ɘn .T2`{lO[ocmwWPjm/ZPRJO,u.vLMX2-~PP."5a*NebΩ()v=_Q-3ר2f%1+^$f4rvѨ0|>\,PkօVCudfq: 0uUr@>dC?6 BVJrB#vבEJ½+U UZ3^qW T3`!s m[p3o5&" gҕw'Fʍ;hܖYbJɦ|.* rQ@2-UR?2խ#<4nFm@ވp]'麞|IFVfJr6ok̤)*GdVruչ[6?݅wj=ouG zp{4xϗ_|zoN㣩!qE:I'X9?2"ؠĆTh#uyמS5 B:3Q1BOG4Þp iD<+ #] /K(;®.,FVќߞpEAwȱt7V|+AFp<Wb-=O\FE?wA &#<@s9yM:!9Cשk#%c$z+/]_db!69^o~[`>ky*Q@w5d/\\.CPmL,) T> & % XBy,rVsp&>%kfE>^UsĞ:8SQXh-qD^S$Z7AvMIO,L1{Rڶol X+A?f4t^%!9ŦZ7d\ڇpB2"e(J1 %b@kh̰-]#<[GKY C&7<"0a2k>y:S=},&L X& gaq`.KG66Rº> N7ÉOIoUy|>z|Zy׾Yܸtz M9^͇rx\ݳԃӲM9mU<4`RpM:GtlCn w1 =/dr>6xUs2hKS¤9jn $RjIX0Î7V3U^a[|Cg!4t8*ם55QEg|5^JI|iU5]iyf~l Hua6m Y*8,$(heZ7LTʕZyGU2RL%)VRJbhjj*R+2c; ~:.^q{ ڒM=KVVդ\ tQWrz-:e gy7)R!Uy G+r(%z/u R*́6lIU%$IrKfUjlIJ ɊlU^Uhh hxoK"8%?tmnJ`Usd+E|IJo.7[mptx2{<;?o~;ptq}o _/m;ρ8OmO`C>$ry<@/%I?E6ܥyKeCrbshwY<$L",-< {htۿ8>?\l>^m=Y'N''gb^nwOyfvϺsޟ ] x`}<|zaw6HTE u߿; վv|.[rǀ Se03U jjtzw2?tbhr?#pV?j}} <_GKGoөERC:8?mu9hnv,ĭ%,@_ncLf)mYZN4Woxu?_\L?;_qxs:9ߟ9K{ۘ|rpt׿]A;Zdzwy9vN&O&wͫ`hեʌytfQ=3buAObn{3[oQy({Luv%=LLǤkxj>*A+Rbhx5%^י^KV"Cજ&mr˺ިUdcp(#9/T’ziZ11J>əUj] 7Ѧi9Ra٦Py6urR#WB* e:K;yڭ1K^$Z2od<.E+vPיZPF4ECQg9LY.ݟ\Z&̧CF8EA+hxcD |$0I*L AG:&%kQ߫Q_DV7٨֪By&$HXdg#@|_$@xE!QLE euq߰6Z`MF% +E(tVWöLkӾ4tk"Q3(C{X抅8P(xRZ+VjD@x{&#)((/yfVfDE#e}૘U83%.'zC( m0Rc5u|nk+kZWB pDQFصP /T:o&"f:JGtԙE]aJ3K2 TO!#q%B@z<"7, \/f#"txU&qkE/-y#DP~+"CaUԏZMyk}pEIQ8 #N4%0cjMU(b]/x0B.)JR #~)!\/1<.@M%'b~4j9 }m@譱ٮM{2&}u1C}*EJbV[갣tL M5ٓ+Blc 68 M'<2)2<2=UjgTt}h+٣qͳ1m.6^xf\l7fMp^4|Vp:nL|M[I[ P4v81e_"U9mz!kMa gS~!φmd^= SvUW$Mx=٬Ȓv&T`gjIY#mYBܪ Pj WFĄX1AJP"|7Ub&ԫZKrs.e%XH+e)H)YJ)Ŵ/fT%i)ͺp_QZ5oT]c5H La׺meLMeBdUOQ[nnM\39/^E TqF,p4\0eQT,cu:o,twdmVnS_Cx؜[_~iґ]@;Tg5S8@W[wftsݻ=NnG~ |;4Yt>ח+{ <'m'K{ȒU%%rONRoBs \ZiN4vC;壯K#n%[N~\nu`d!ޝ/nNx|{<^zn(rӻYxmaѣmFfqvh[wvw޷me,x5~o\ǓfN_9kǓ7qKZlQYR=%- sTaGRNÆJ+RTʨlƫ~G&KExQ?YZNx eA%AO0H;k QAό:ެ:l ~-ctebvgz4jr2㞶ŮNsR>O9|xkdZ3QkjJB֖#L^v * KեK'~:6,cLҝt&st#q9K+dqzyvI1ي:D= a(O 8 8^RʿIE:A)z"bAۆyRhq!p$C =Ij!׊c{Ag}ų&sFлS!gķt_޼NE+YMFw @S|Afdu*NG\4p(MtG"LY}#ɰ?EB4ɰ/-KNpKP,`/q*Măy+qVAwEx\* =H$O߽w.ž7}d\Fā7Ph G$>5:\m5H:@&fu%RB:m* 5YL-;h n/Kp!@.je]:؛79lߔt4Ϲ0j!.r!&V 6vGo˿SlO֨[B8&#xo+r -Ž 8-dT ~[P ?&xb.&sDPCJ{yuhАәq0&ۃެ%l {V?O[sbQ̬?Z>앳^ͣ~2+Uz{86>X7c"sP^(xi]S\"RQ=VK,(P?y_}oZvWmm 5ņ%r4Aut @t%2.j\ ݔ$]v?! S,!$]K%gRUu-'"*4ΪU \ZApr1U=oBIŴ(PR!Y7ExAf`5T5ΠLʰSK-lM『k/yY&z2^]| 7+^jJeo av`dT]@49VM4d|#Ħ伯NLxS3b>LwNj[b>b.ͧ+tu0ٵ~tr k :}<xjNx8?^luwnweVzs0,eϒO%4X5nq蚹nkdH̬_aK蚠q(-qgwyU3F)U&2m,, S'U%ikUN+@Er,W)U*zn)v6VK"+=͑fA -V=3sM-fґdVH#H1 ޢFFչ)[X[J$R,]HP Z!@q0 G$6~KMkov>*Z6JS]fr`еO@RsuꤣN6c~BԻ40 AQ0ڶbo :W..M:τe.9Raw<Wހc|:!'#^g{^Zн/8qĂ@N &#gĀ:3ebd& yBZIvl<"Itւ㖝Iȅ;¹v2pX\%DF6plKSDh!0@?]V"bZ%J5d-& DZ`#j!Kuo:1 p$`6XME)1 :eR~Ma<U46 pRF9>HKR~rʵkVLY(@< MJ{=:LQ%A.ӥSPЊIb)'?F:"LTy&N8Zⲽ80(Ў˓ ۍ(hU`l7(d*AєߝM>l}8wN jzŇNs~wz]lkKyw<|:?oDgt|Vzw-SfC2+dZL]СĪC8" [MЀ:l[r쐈CQ`8n+W~{2ׄܪj6tېjznތR?fVGd؉T@Ҭvi؅WƖ=uBK#K4%wؓ{-YM=߶{:-y2vJ|lI5ZAh W)j!YjT͂"(<*oΕ(īUjv\bdB\.Қ\:o<+HY ZŖɟq ﯯxQ&aJIb)zE6tie=@|@z)O y&Fj `!Ęc^h"h#l'^jVa[i ga;EksPJӎbx4'm s83]ѕIov߃|7_m}wɿ?]oOx<ϧ?}#~{w~_.v__~*w</:Ops\Nbׂ;ߝoyy8-1\n~d<~8}X| ps;;<@p9{lόEd\jP|iӶ=(R]e'=eؖm+,y mV5@ll^SBo%7ɷff %P;h]elM{<8VJ!h59"Im|mL>J YX%{4\ c04h |tf-M-SΒ 1Dxl ̨l6g{])55Ub&,@ͿN O LHʑt9r r6hUԮІD:ꪖrɐ3v\rq)bZ\*4S!b@&1j*dw $7w>(tddڜ"]VcT&BL<%#tƃNS.)!z=7S!_ {cg5[+E& WލߙCuK\"k3Mfcdȕ/pA s-ueQp_;^2q/㠝gbCτ"O{ 9Vd_<MJ  I4" #`P"kyR2 vuxF)~(\6?D"rl32&K5l,LcuTsY2FLK MIe\>".0DA[lMLh"b/?)@/TNʏ䳿BF%Q M )a5@j^ɉd|rv  w KeZ)2ჯ lqpx{`=Ќz89# 3m dzoONFqпX4=8M%Owy: 1!Xtv9?1{PSi! 2S6`S~:Ay,Hǖ=m8:R!YRmg??.59CWZf|Bݩ KO6oT v]+A`Y;BzL63 @#NQmDk*C4ddPVO##";- jB(&<"H?v 1F( ISU"2K(4 y]2<v)Dzmf6[F*Zq-^a7/'e_ri,e G:T6Ff7u :Z!8W#",5C2J~ЫࢺL7Ր=ukTݛՁ"`ݱѫrxv;#St'dzO׻ߟ|{<>:vӿNdeC '}e?=a;~?bA8?1̱ANXVp2YfwNj϶臋Dـ\^?zB?~Ҹqǣ'`۽Ǜ˭M|q{=?^ڹ=Oó]7!;ծEV/Q?;~btָݳ.w:S-QCJ϶[.\N,ra>taogκHĒ:Ũ1]|ndrG0;ՕN)(Qޓ{[Y}AUU=A@`YG31J U)4,dA\D. e(f)NrZ IAމ\.>稨@qTmMh@/fӫuPaS|;`pߟm9UMwɈQt*=zP 9ql'd ͢)%nA8P3)lf_B?JB!C73j?9j/{|o|ibߓ*iWx:[jl%Q\1#Ӻ7Ӭރsl,W2ɹ*#\1Epdtzh^+~ OEׁF~FQB?B}2L\K --{TloKe>)r_C$MPD&),Mb]@PI<~KO!ۢ2S{)2$B)w(dە<+=P'M9KI˕-N1%|٭h6v.@ rv,-& ιWb16}ַxA, *z 7齖 KxJ]m] zMk5޸k:V* B:hχb4|!tX; RLnQ۲ՂoPHo)$o!o6-U't1ZJŐ <&MwhdAI8r~PSKNOͲrֿgC{ٔO^.Mz`k( f|p-.N_gz9:hko<R&XQ)@EbbwA@#͗NvB CZLRLf]n&K6EgͿ] ߙa pwtsܞOnbvw>=?]/"?OǷǣsw2;#[7nݿ^7rrZ?|דxϻïo,pzv6ϢI[YyTT*o~~>ax`MjA NShVoab)iWNg$&MkY_zC<4 (Z t PgQq( k-p8miN(SC= jlTo>ۮ-'!^rz3urUKL6\ZunUHA~ eu;[%4 #l7`VײJ]jzWSlq)lEsx e@5mEq@m#L)M+]JtNbRߩ4~ хbj2ϥ̶!_ RzQr, 9yLmgb }r'Yՙ[g _J ,^3 XADs=.{8=;"AYj%]/`!}ݭ. `i]{3_%T'dbۉ_p$c)y-u96WA˔8XDD|k[-2U-z1%I:[lJrnVˮVBß.1N",a"_RAA6$+)0xio+T]z@/Aǁ"tʆT % G#CibTd^͚ $@6}PJ^$B߅TdD0XϛD:&dq!śSRC*A9j45l !+e)b2xf G&pie Bx7c\%U`ES~M.0udg>_"T}fjv2t]/YqjQ{I5uD@Ӟ:\+M/yܵMj֨6 3Fd5esCoԵ Ӯ\i֍n(<РPР#`C5"-BgU}ѵY@Q՗7Z6Ѯ<% :FK[&؂ A0Xt3=4V:-MC}TZ&SJm~{>"O= E ׿]}|:D^*!esՆX~7q|חoA"!5fcy>vQȟO􃬞vy~z>EvEd=,?ܭq|\<]#|v{:<^Ū~ػ8]dp }+=CӪ{==.;ǃU1~bz2n -zV?WzUq/| jDioѮfA8*gS?_-j[~eВ{ r SN|Ѳo̠F+[Fe42NC|T)Ge8ٹry8z*$96E ibi0lPjUh#t9v'= .ʥ,zWs8|x~ÆԭrHG(&~BĊI(*w< _<%yݐN hhQ22Uo Pg;\ib i/Wː9ߍc-[NlG2;Dz# ɈCdb RHl8hA /T~a BOz]x콏)OqD=dg\$r]J(3Odb0 A J>ULu2( Ѹl2-Br :1sm|&)ALOx/ˑ~JiPElKjgT+eKD*K6>E1ԳUrٓY`!<,bH^VJme^ OnOSe:N%¹bLŪ|8NfpS?XP`h 3D-HlB"@5L0I,\&iTЅ}\n*e\X(7xp ( {F=Ő <- ʬOn`2\)h%TWסCV'L޴?6&]ݺfX ^0o_T(h ܆s$d2~.C{|<,[mf3Pn{ɤ86 Eaվ׺6|:8VG޴ZɬZ3vq<FǦG\j-m=>Έ qW=ɗvXjߒYZdU*ilF#8 ;l9N巨6bKgw, 8n@ Tj޵mÑrM;7Nj6aAk1 f}tu=GC@"gB+0(lr>ۄۓhxsY5UP89ש "66tPXkde I; zGjOkJ@:>-@6V(2!q f43k6@=iDO/ʸ[ʠ)jB`F14)Wɪޠ& `5B:YwpL1+ѱUʰ. [|/3q:OV}(HfLR-܎D4јMV݅T-gwD:aMe uWf9y*:C82+x)HIJSiTjPtxdD;mWr[Q |?pBy*%[h0\|8oe*@|1@G?|FaӨ<+˹V>9%K?x.o#dy,P6YodhTӓ󽟳QJb?Ӆ8[J{m p0YŴKDzY2/e [ #"$lS Cʙd6{XuYEH dqnῖ/l9ERx7+q ֳxp z(7^ \P<6-s'`Ή(ArDxJZ<)%cja awլ |H!}sjqdAЫU T>頔|T`%s(29M(2[ ]#qnìD ǁ吂mUUPs^]ܨetyy1_GwTN^S.w<hw}p\MoVfcǡt{:FaV<Ϊg]>j590<mZ2*+:%i JLG5b6l/w$a1WmUJ v^h&x++J2 AѼyYc=k,> g=VZ 4 ͪȢЩ]2¥ nS'[]U7M+BjG[Hڜ :t$H*m@X54b69 +#FJtih`$ڶW= Ԩi+ԫ*QNJUOQDJ m jޚY}1 G}KCVqlQ-OVh]O1 ȴɚ)Cr_-};メ~s l? !`C؆ w|nɀSHy]=>lEG +.wgfz{uԻAd>N՛%q._|8~Gv]p} eNΤ6>]O_/F=9ּOw ~SPWx#`uQzY݆\'6f(yVEJy_ѳf.W#Ri2H$6 3˞Q~S5aK_vb`_Mikzޥ(^(LbYGh:\(uŴ!d|vբ!MuP-5AaАmUi~3Rf 2-Ā*#8bj qeA ৘z_s$tș;;*D(e@0\He&I*tv˻rfu(\v8 r%։_.SsTq {*_J'4B'm!A`rzG.'}v;M+~gR6}"f؎Pȕ>l*ڜJ4Z(e&Elbx8WJ@g2ddZy#3bj{r:' :9jE*Gcë31%wߕ^""s9S.QY\īVʨocԤm<_vж_A&n__.|y6/8INO s>$~o= ,Ȱ_,B&|9 >#7DhdjS쿛f,=<p~_=߯o?}|>|Y{ˣzpx}<9_owٓh.{v-s08|~pǠn=||{>9ʆI8z/puP?ӦiysָWxgc{D|PikUt2>!PCrpКC]VU[c4A$P 4hö9C2zr;'źˑAXaCL7.-&+Upmb)+3|JG)9@;9e#edE2CL:=.bfXu*h=D'>4|"_JA Dug!e{_JB в@@o9L>!W:_≑M!WJ˙x>}h&̀3djgB:=)TOlgb{ۅ^6#0 QƕPWLZb_<9O+ɕXQWU {,AdKduX ^,WJU9hUXW WVdFv.<\1=` oﭜLaAp"$jM#@K .r6w~C;˘BL&$dUr!}.N̠FFUC,@ymb4ּ4OP^ˆLL6`F(Q (CAQM2 "*ew 3-I%д4> icȳ$V#GeL -o8 "Bs{N0ly t HfAmү@"%\6^Ib>9wl[.\Z)kzy4.捇/t̻uhb\,ӫ _MPP(ttر/x,W<'cd|[@5ʃf)rqTsx LKtz<Ѐ41Q4(p  Sa/'xxyؙuh:)ꗨNl cu6N~_N M=u dq?:vi}68 ];MY7Zu#yc^xC 6HoM~u5x<]#S^|G mli6 q0P,mQӑ!3NɤuA76>c{-Mg i-ܬh6:d)E*q-Cfs8mWq\U7MK4 qVtg?:^pR*i9vU'3um"rt:6&E.:B~̚ҺAr򏷳E_Wϖo ׇ㯯Ws:<__."9nT9#b؆\B"Q&7xͽ%FC.,<#{Ά@ޞOD2m7ۄ{Xz_"/yttzy8_O@>"z^_,nΦWhdrChgpx[>F8,.w'Ç(\-׋aa{;8n,䃨-gM) : -9@c6Nfz!1Vl 1:ɧ!S](C&xB9 T[r.fS6p;1_n)W$#hq .|hkQuJ]}[)|c,I(ȨS` 5_ !)(dnA#\d~z[aBs'kZБoVQ,Q(A8^%NfхNbEߴ= u$1iR+WƷPΰ^Þ􂺧%& E30!lX+s._&k,D[fdۅ6G'qNDj&BNyb tڕ Wޢjꤡ_Λ'c}a=pk k=|5ٗ樋G$<Ӛ\ 8ՐaOn_?>:,33aRґesvC#^Qz"Q%vP9[H6cR/$g |J=ŗ/kcy<9{[?$ftA>o'|vs:> \n/ϷO7K2jx::a z:r1z8,|>]{ڱz ˍ@rʢo߮7΢Ыټ|5V2*:{3͐a,,rIK/ LЊoawTxP͠6'B3+mpF&? j"gܳM{9AԜʸmgoBsdMI9tF Sút< B5Fa5nnT!d7{L; A'6혮۞AS>Y{uђӺ@*LR,MU6A n(UbI^ XJYe+TG`!\kGCZ+yU\1QpArZb)U.  AS\Q9l[P=V T{BXx*+*ep3WL2I*drzչD%v]G.B} r`t>%w !T9C$@\HtBaR"xT.ǗDRq* qsI*ETr$n.kԥBr7$JCBz#~,HGJ˕4-wR uTDTVıy?Bib?p C3l!TB6&c^.+r7ZQYVA U-RU9pڞ)L[rAI][)AzFQ]ʖb5[*y Kt1Myj"7RRHE5 a !^FMJBvAJ,W9C\R\yw1iuvʬ s2'E zP 2j3-j&2)]P&? Ց-5|DyE(mNdӝfp!W|Bfo@򝆎/qJ=ɵj"t|jOd @09"γr!횪2Os\ਧj*UfUck"!X\,9trvw4x<^"dѶ/G0trw>A/O'_^ |a2ٰ s*Pk5"7yP?Gt>}8gsMiKhmc!Pvo#SGysEYi6cYs{N?_桞=ڟ twt|~:p0ϊq9|\/Ϧ٤vq>,߬<_o,x:׳}<:_w'=J#[tdN:s}}XEK?G2 ?o7ᨥOz֜5TCv̒o)K/m@lp:0jUNfdGE- *`@if|X$~>hkTej߬-zp#,ꞚsTef]JVhEkO{PՕ+IH'==B WM"H%g2{e2;T刏 d_&k}pT*SHY\MV _(!ԞʕTYW*@B1TJRBbҸB+[,x2(eJ|A xJ~h |UXJf<ƕ˙O2TT,2T&OqJ1K3*KR(|J6rtUƑ+*FEnqܢYRR*CTӨ'X@ C沱dP$Is}]AX*a{ j"ȑ!~% $2|1%R2eA/T!nQ)45|%>Kʹ=(LZsFx6̾ @=v&Y],* (.Õ- )"#Prh`P dB (%@Et.6 6΂Up9E.kjehw ZDQ>Qnjش*瓺"+T\ S`KY]Ŕcr C'JŘ$䫞"EJQ Gg&OW^6BLk d>瓡j u9z@t60DӗgA_/VIםu݇zZiatZG4t\v9imOpbaU69jqpw:ްʫs4v}g^.3guгA>IK?PFiRj.X*3YMƎ NPES. ZΨ㠷!,WRQiWvNa5iXz? /]zt<ޯj#m(=!pp ou=2c/G!hoVwլD,,y9XKbya.~t$SO&8ժFШ3ɛ!-peq'F5P]Gsl)}OFv-Q$ӯ8I#$!sGזZ OLr5X3;6f:>E=4mS: &^ 00[u\92ɵ{np]Y"y؈ zMB[Aq=_7x<[ Og3;1g9TInvrv}rDsDT_F<(ᅘN7 E#egј91m([ʧ;Dg]tOFwG׋A||2z$br1m}5&c&`>Nθe\$Kj//fUϾ'+w]lz6&^IS/*wt-ռePڞij㡷>vۖqN-B۪i22hCiPXK]J}t8 _I;Z7G:Χ %fP5JHXd\;BsJ!樕J>8Ok6=+Yyrӕ46Bj\20K@iO_ -(V[Udy@!J*ŢHO@;i8*C@_Bz:\)c) ,J!Y9iPG̐KA9jth` .3xBJ"*PR'Bz|U]+eL)#T1(BXΣET@+"S*x"ڒ Ɗt<@S@ oNr6!T1'R4i> A*e ׉d5 R'o8€I@D@NKm,Ӈ1W&ZQhޏXh荬2TH -ǀ@$ˣ1iW"EI ̅ҦZϴjc(4W ۴W:vݒy{ Y<((*+Mi]#f=++);z:lL)t|106d"6d5ɠ1E*6weaiS3p5r.t5CR"$VU99x@2=.D  o|ֵqmMKMsP+C5ԋ6/4v9VN  /`]ˡym5t6ytB9%Ӿ;LZf*m*Ś>݈!]H|-oU&y:QױyGri<7.:>OnWtrvYkӎ}ZMUtEtZnL]ͺch,`&?OǓǓrrx>?_..2hO_>^}t)z{>jC_ޮo# ?~ }|-񛅇OLX &x} |~S^#q{2nP ? xԭ!y>`ǻ#,$ّ5p )1᜙3w-LPB%m_wI v6׳m6~}9lî d]UHd 3jȣX'*[W &+mcB!S' |2},QG4:lUXڰb0҇19d0ҧ?p:k\e꤫aUz` $6` l'@'OhV>u;COzU7KfsG~ ~ey*3 #ɰ@V &TImS;W GMWt%R%t*L(5HgPUa{.QN(HӃķf;a` ZNvr-UF A; r ,uPrkzw>89k'8wkeD ]ȵ6Tc>a`=2[J1Q"ΉDj\kJ9>Х*̶?r"}crmISZ=-Q>@N"UKmpFl +}eZJd}i*BS;0$ϝ~f\/q-9,4/xMQŶ&wBW|jk&855͢byj뼡g2s)0OMntl}WSCYZ"8=Ȯt8J!^Bݻܤ!UhƮO49_wj1GHkb/ >i)\M#B2KzR,2%0T|*H|!uyMO^%ӧPjBi7ꙻ7˴a&r+,Q<3f:IXLƕclE{ԁ"PYÌxZ"}2L1fݐrPh[Ijϳ$*i^us SD7&}odF4pJx|uH"Þ jMB5nb.&d@Y R75]MζQlÂ^"e4[ 9L[: ( 4UnXX%L&~PkɲA`!y[JplA8H |lSĉDZ #HIVdFC,HеJ1qog]E# -sxir;Kò{_f>OBy@ r~]x/E~>^FfW=,on^{BkDoӮ=NIn.^G}]:l-"I Er/;&%qSk6 I#B8"DY%yD&$1#hRnڼoHd:`BXQt=^w*uWnG?օ+[œxZkNkl;%;JQA5ک}AĎ֏ƶvS`u(VD8C'bXh[OŜG"SGy U= v}ܩdS2L=KkW?wjl :Zڼ,}%CzSuUԠzt LחB|(xNV u9!1҅+q򙪞ԏ?⣏G>`[9T/6Ga\ؑ67\!]Fvz>J5 YrXNhئ^ڵvhFYŕy$,}6:i@څĊ[2n7;F:UU!DMRRG>dZ%YRjS^jՎO>x6ST,C@p؞~FmR{Hhy3S<}uH +`7'x jh<8c C5O&krӏ(Zlh'pdvz&8t#^d!*ްlj<?}p˱h<1C&ND57*xac\,siߟ(qϑ Bz1r9Y/Yx>&Ji,o$A[h""ӧ1rbnh*c-&QV`+SL@l f,/)6>mCv U}bm4 _6o}:/Wrvj ƕbيvXǭcKlTS Sj(4Y8gZ [;%u qN>)F+In/f0U];hM(P]lu.\ړ 5Daڧa1ΥԬ_…⮟~-8trjH像L,uI\n=Q9b2+LqUS [jTiS=f 2P5<)P~cSziF]?iUiVS;ەdXVH+ .O*RuHs:<ڡ+GL*lkEBr-3VsBQ UC:c#Ф2S2_1xƹ,)]C/TSRuK+\@bFj{ʴ\Lr< eMWOڵ2jTG4P1$WQ1$Wvڮf̄o,0 ҋdig HMMHld-즇PL&uN?aSepULT&$Ys]nJ::F)sTCr'Ү۲.32jVLQO.`7Yh4*Ek$o>3Q*jɝ,Pg1 }) PUZA; !P%Wg`<9΢l޳3?ޙ$Zdб+x&#ЧF Fz[FX IOUFvy]s7+FA$M2]eNB=ê)BAWA)û2 H/L>Yއ᪄pa O»uoQ9if,)u3&]wZ &c1꒸n6Aۃ;: tJjJ<:'_iNYxROɱv#٘]4Hc3K-C(!(m7M8+]gPI OGD1'!mc)rв Qۀǔr (Ux{zbc8*KmwftPͥSs)>{yY\"Z7K2j~=o ?w/oEYt6e׏eKmemDbyo@q(a{<ݢɰwZt2B Ɏ(A; MزUЪ`An~" ƝF)$bW7?OX~h;f]+ܰzTS[LV>#!ԉǠ{tr z mQ`pMu7Oȧr?LItio`2(EfdSeuuiǐZS v%Gf#S2MIxҪ q0@WƑ7`>[JV)L?½tʟ7!U=~*}DF>>QcY~R`TǵOB*V]:d*GTqk18% y<-Bi~b۟t \ōw]\#.:yw4N:WBSW LF!e]s>JRC$L.76nOR RznjVTN>T|U>1 <:Nդvqs*ҵz1U+"W"[ .4At(TYCa<:YR('Ar qb0GYlR{9BOf]Eڙ)4M@ -դ@bG$Ӏ,r4ܜd,3.jir9Y'y>ϦLv?F2}eXï 92Rې jjqgQ5P*DZ^L"^0Er{//e'fr Z/wKpl6Eǒ]G)C"IL\W{x@3 >TQ4y6^w&:"09ϑMPf!}Oh 2/]c/~Ϗ]چ)BL%)tC̀S'4 56 TQz6oq?,@g@z.Vëi^[y:<HyzrxÀwy'"? wEm=ξXI<̏~u\ؾ\컺 }@uC?~%kx~0;;o6{Z<\3~]Ξb bzu+?۪zڙ= qmo7tLJHh oj>pW#o3/磧oW { ~EWy$bTPwm4#'S # ڸ& g9KO/=ESFjHhof)b||FCx>܎inp>uo%GcˆP%c?-QЏ&b)x6O'T,_uxR٦DUMk6ĺ@B+Ml"GUlSVyҬf{g$^t ]%4=ծD*A(\%$dI-sjKS;,@ VYkcQ23"xVi[#l&uT$k0%׀Jvȵka|Ԯf?#`[UmjL+jd P"t@ %y Ým< oV")U(ECĪWꄪRҞ +GҁD$rpGM*t949TA;v1XUwSV` [F jTeO#GvDVBmHtmJU+S5p1 w%Azk }+TyL3vrz(0uؘ Pcihl2D" ,4%lZWgSX2=U *[vj.]SeUdƉ2 RI6n vhP0VCb Gmb!+q4A;m4:k\N"Xg+.m=M7P4={;7C/XY :\e M|Ȱ=o9Jy:&^!ΰn1 z?Kn#%2DUuiu1 sT˒\G ӱWA;#*$= cˎ#pm1- bHTԄ8B04PC &z C9>)LEmŖDMST2eI-SRCBtVſ:Kqٖ๸Zk$B}kR>Vg=tvѻ$|9ƯayMA;jy>yVaOӻ(ޟ\xؓgpGCﯗ_=Y8T!^|>g_h{H |G&X|/oe_3?I翿^/Cn|R _t~-Eφp!q|[?]w#y7z>:=l_n?װ\:Z\X4Ҽ\M_ug<[,g(?I7RxtRk$[,Ta$D&Բy̜$dh4*+ҧ*W5;b[v0v~{X_,a<E+g UOm|%7#3I2chh "E W95Drlj\7vo"Km 6d^ځˀhzPھ%Z*Ө\5t Ωv)d4P!PetМSʷ az HCڑHTU!`D| aim\D4HR t[apxz JCO4"ttt%1M @* TįlFU=4*:mUClNS^VT.زc)oڡxVKxk5H̉cϊ) Vۀ"ꉌ;#iNɴ%$tI$[8u UHHhqvX,#^bЛ|#9L넧tul^=9Ud@&6))l8=! FkA BnEo53ҮqYk| Ѫ~4cih.ClCNE&?W~t$,!Nk"jVk#2& TEJAtyA=qtvMN8 Aføx&'2S;UJHe<Wjjb$n@~lk-zg4'q?ξ=lf][*g~7/7z\aaItJ:n405##X\Wi{j{@Kgշ6I4M#KsȄ?Zurweq4ڐ[°6 1Wɚg*mGA&A8꺮_@t*[^$} cf݇?:XXD8:Ƕd5U$Ͽ[ "ɉO <@4kRv82؀nSPf^"690cB9P{IQObt3k:p9{Br6}= !0q;y>=&I2l"f7Lh߁-7yxds-?~t;Y+9)nלr^`O{Ώ}w/֮S96υsxoϷ9y ׫4} Oæ|6vlp=n=&9`@SO m.v[k4JEt1au.z9œT.ƙ Df2I5}ŚcRcC[H|*Bt\=2kVB ՈokC!52Dx*zNnl{ DZZXvȠȓiBYġ܏DOkJV'&ۮbːa)$ U^Vzl2T`ginX]ux"2h0;ǡMVA8LxqRUkIl S4)xtlLO*Qrp4~pF&vS,[rGfjJ܂ڀC6ߩd~l%M-%RUiW!ث0Ie*!RDFof 9nWOG@QՠQz?#3MaYjW)S9p`+1U4LõK*(FpޖS&*ՋdW >-kBީh` ?Wh[P@A2[zB hGb( )8GxL MCnUZNkj#Y]2Ӭ6K#!Y%n=<ߎP[%Lu r"O2br´+Q/,/w(sMIt-i^F SpʁY,h @V2WgN;(]?QP;}$ @"FǥQA9"l72Pg(ؑPEWnߜ }pVL۶{{>ޔ*Sj;Pp=[UajZ?\LF~mOE׎tjsgIO[D]i\±ûbZp$t]eZ)Ps#&mI0\LSZS/ Lǒ\wTl/gx n/ ?g4`wO.'Ѥ&6|YWy[t6/LIcjgr1~Y~||YPXI].lumީWLq:z~ȗ/w]^ !R~kןw=W9ܻ~G֮pޝEl0oqr9͞&/+omzbU?gˡ;*'yhna5gd/Wf/ݦ7& }_m&訛S~=dz`i?5(x&5`F,5|;<qBCeZQQf-F$z^. 9zFƖ,z6ǑQL<R҃XvcGwNu}Z;9E‡|eeP:tڕ < L:qy9IŦ&4#W6GU 2,US$4h+lMB;mh*v"J *m(S$6Jk@M h G#ޒu#R5Qٖ.P")ы8<3K3ɜ걣SЪ*G 2qm\[mv  2mMdVScZ;smM\L+!P;UUֹʐuq;hxVOM_4T`_>a'*O8g2'rGifc3Ě}LCn1޹b<2!CIO'킂 x ]Euj$~J8Ib\"]" ;8m07n/L12C:T-h L|;\m]aݐ%?,e Ipmq٧z#u1Q9ɠt&RN\LC5z0JC)T')$v(^Mb|eA uG@aFYvw>>[fY+ Sb1H5ԳEw֓%6l;W(};M|:(if=,/YSX?r;BMZx5 Hptǒ07N >~  9in,i=z;I dk#:,rt PI ,vV|Gf?1]BƩO}o5 W_u ׋T]灲#ngY<EAdAƋY @)ܵ,`#L/F9I"*,NjGV䩑@$:$'e6i PjFwafP(9ӯ/3p߭w|mG1hv9<TC0 !W{*rAx^.~!o;|y~S+~YX~Dop7=J;W`wyz/;ށ睠^߁A-RKt"dhr1{_L`q7v3?/h(2$ 8\O$]&Kf,Fjl[6#C_p5p7O\ =p/'CG}'5faQ&\D2y]k=T\aHǹ;khp()fۿ;?Iu4}\`7W#o1: 2H؋l&s^[SD̿&oU;SMd [I*A86,F~bG$ LQ>7ڵSX2IH C3ƞ 7˧8*k d~:SKabWU5m#q5TVb>XC7N`)SyAJDȀ&h(e0ߩaW=s ( p?,A!SuUZkWNmFڶ&ۮp* kɧ\: 4#'3$6\#=b' m2A`+(R YH_# I-2g>YiZ -Gky&ڌ2&@OA,45PH!Y\|K| ՐwGT#fH'[$e[A >H{@Eť8ڰl!GYn@%l4@2ƚSX|·B)0|LNl/5x̡䮧w=f]{7oֽE~6M I}?2Y\պ?̓ ؂o@9vSm28(rϜ4d.d<~MlȨiau73DbAiv'I7Q2%۶#)ϝ D<Qhd IpO#Of#Њ|3,ې@5Yb;Fy}Onھ@E\LwqKy|z1#s3ߌ7bOz"ŷ'"{7o8]퍣.OA+| 'E?yQ!C?&޽9/ "z?5לS:OϮ}ls-< 5 /xJ7ev{\@տ\̳Z"yиښ%z1D ҟo>n$Voi4˭j> 59yumCų7 \>$aB_ݺ=Ň1\9FMAj9UX}z]Ŝo%zit57]OvlT)ր(Ӯ;~u,X ]̗']ȷKdO+ b6t#qi"pnA;\0Uϗն7{S6WF Cgm&.)CfRЛI}-% Zu i/R N`Yut Kl] DJmءB"5NWT4` T )Z*X PQ(FqnwPƷE0s̨͒LBwv£/zG偔e"$tHgCFKbML)= -*O])^b&ûؚ*u7A=4ʷmt#N=Zt2O| e':lUy";90Χ ffx}xy5GGll=x=i36U~;|5_AYSG&3l =!RY zxr[Pv?Ǜ8J FRO0ZѷbƮ9dIEwT vdfU. Qa"yڷ0ɚX(#d"AfS-4#JdX41iC5٢MBXPeFf/s"]U6Zn24-M਺&1e1Y a7gV8,!\y{y0o G|GmElžvs2T,ybUCmf F$NɊK(wHO8~Z\mQ8ʬ<}X=8_t.br-bϖׇ-I @0D9ߠ-z>ԷoreЋ4%]# v%'pۍr_Y Ë)'NBƥ&/r~ݝFz2{\.r],XaD_mx T8;{24 &^2t4~ҏf3,_Qn|Yz_._^.4z^-{i5d=6JE?{gjߝ.6F?&5XabL!a w> /1,P"v:@ ^0g Y-SxrF^ο^/_r rv'|~x֓aDhu0!$wױ{O[O! }9KpϗU0Cay 0{y ꝣ~_:Ԋ}G{x1$Ht%Dgif;ra{_y=ml@sv\`sk\߯^.'_gb׫7NMX=dh裡o2|/&p4^nG^ Y<)%g}}fR_+ hg،viVgw85vx @$ ,&/`I Q.]UUb3q}6%)#wҳBnwۍpU2!ֻ*Tnh4 5iv2G8Lɪ۳QAnwi {qmegz/%JR&({fOb ' :扭If=GעM&P.9*B^Ap)86<q)ÐF_T!,P'e- gU8Uyz\4?@΢m鑉`Ρ*"/a]gqHSc$KCgW&+<{Z8eEV(IhE*ZcYuT0mʞ٦raԅWn94/Sdۤ mR[`(4ey00tsu~q,O156EpFZrY{tulSd&5dx 8@' )M]EfBJ5ۖ\pP&r|DӪ44GM{̓vlVR DoKCdzq*;c6y^avH>~tUI8qMBr|y~ڶI Oa!&o^4tl6~ ЭrO\  !ry$(=Gdq84l&1zǾ1bΧ_~AAAO A狥K_~tCO4%^гwn~h&OUqlb)qnj~I'<0Ud2dx=^s'cpioiUf{4Q44qy[` KcFv H]qN3bm8r/ǻ@`_:@ӥmthc~e'UD?]$E@0:1Z\ҍLnׄU]f+waz8Vyޖ/BD|9?@m=:= )r;@_H<@2䃋*MUVEN-[`vBpyeiT[EB#\'bԱeבM'OrH!9ȑy$ hɢe<&æ#9&sʉE5L-4"cqZkJJ梀Ƞe&'ڇ MQFPT=426f3/Mi2'm%:rD`jJmW١ e,94"{TOxӆcX^6<{Z_ l qgZ%~aylA>.d}{>^l h A;f B T ZصhA?&k&)&,B7 !3ڟ$);}ʶMv~CD > `ZKΛgC=>׽#/s۔[pB d?Fڡ-=Vi n,Ƈ@mۡay-~c|z-=mmȂdX:EbXP2~P_yfe(="j'.AĆBr# q e7/4{d1U MK\aQUT'jd#'޷i=nү?2ӳ"T{J)wT:GI[4ɐ ]jxG= s!3 *^#O111N53ANlͤB8҄;v0#~d8,qWx8s#2<<!s^dcHƯ/~CTT]2?77_~tUa2Pt# |5SDC"7O eHCI:Xv! d B@ԹG{~q] iYsoȚI<)ܔ9Gwme^FU!v$z@p"WIkeR~S'2a"V+eIpYrÉqhg]7y]n𼮞վpz\Y2Oeo*|; <ۗ?/ o^IŒ!"0ٵCaK uގ"ޓ%h>A2 fxckH xo=~"` ڼ1tyc3XF;iY)V?>͈h[Ũ& x ?nufegy e`cSǮ:nR&sg&9pOUj_>ږeeWj1 ""`c%D1 4@* 9\uf1YoRrpY'rO8.$I^nb]}ԡ)t}}~Yr]Spz-K[z^/kwY;tt&ixnx`#hʧadk-7yj`2&U ׺mЬR Jh"C_~:&,N-Az<4>A@dX[aLVpkO"Ic6$IfԽnq>|3 @G߂IK;+@δ ڱ^@'vJ-2[u/ȳ<~>x8x8Q8D!iM>KGʓ+]-5i2V!mu(L``KiznA>&s¿3j/2?gTARʋ'MIzҥ]/3&eb倸l+qzEEd'jm2o|̣ u`޵,O!Yp.du;܅h3n|~F&:ٚ,N_UKӂM.;jOPL@;0o5CGӑUq@^ӎ0!IKŸ~^i n9*3U:--`Qx&u^UAH]$VU i6[i'YVA۳.+FwH9וd]~/h{qׇy'AaDS&~sS!}t |ͩL" yB“(dӔ1@ ѐEd6v& K\ ؆滪L\eBGC5xXHUۡ:Ќ\4H,bkY:5]sBc hXx}΢-y\ g#\6a=g8>mSϡI@>8Kq4ϖ7KTr'arhu\;P:nȁrڿ<\o?fO/p64;էQttA6C',÷8} }Ribļu!Uq ΄C=D׭2sFצL{Pf+K)s]jw$Z:4Dc/14"qQ@dIwrSF!P[:Z`oDzH'Sx #LSך%=Ԇ`+*Mfe"&s@~ܛ CѾ ҽ<uϰ IW*DƆ&~qLɳ6t" ϽUY~w#qБtRSL#K',8#0ǐhhZ )Dșq0u9>>Xqa]X/] =MܹxZ堂,^>9YԉZؠAo@¶ Ujdu奮#m4C 0A>=ahM>mV޾K_F_, YL|ZH;O_*􉖙-4Qx\]]\'xߘZҀfxX(#缭6iϡ9u&<Ng̠a)_b Y͓ d["mr8"A NpdXr G-R Y Al!@7sNeilxBh̺" l(2,M:Q FƐ!Dc꛴€.v.SCb< 6"l*rI (/񏭏U>M@C*%zv@IwiEDg6E~t]j@^Pvk-?Ҥ:AV+ͷpD{)O&}u۫+7.sqok|ؿ!4m H߽}nқ8wDn0N=wT[vBNZ?]9 v+*CAӞ*$/U Φ+ 䦧ˠJБ!Pf6tVlH JVySk~SBi&V">dהdi"K@UAREyWI@-Am v63K-(5yZeRG:-YldU ˮ|R$OE6u-@'U =>>O4::wbЂdABW'qyvdo+qp~=VɾCmT23_yN&ڔ,eY7ΠsEbA4]nfk8=]SbgVFy<9 <=/NQGp ( aퟗϗS |\KBMWG)إHU7y<ޣD/.:Ak?^B\Нjc(* J,UAV#2 uI X2=R^%E4+.wnI@Nl d-uNkrM`&$P>E&CMu[ڮ }ֳr9A0#Lݯ0@H|t'O{ B𺵽U ]{W2~{FHdY̍ y70Ruy.RF:%2^ӻw!k4ؙ܊;d 0#A,ȐǸ%n!>E>Ŵh'A Chmk[KA:ЉpP.@̗J p#zJ)9\Odžw(HgһuȮCD\'92'=% OAV~|=^=OrdQl+cȡOŁ3Qp,װP5X1 , F.4:ZVh$mGMh0VAZ(DFu4",5USI~ζ"ؑGcpMٳ<@:I ȒkhDB;uJ\WujY2+ 2#m0A3Mb{Y|*λy[?n˞ 8sfUpZz\/M" Mda]#f8?A87BH ? WiH0[x]tKk9D}rq7HHv m~`|J==cd:4DLIgڋW+\}83aOsa8zq_y^wlo+Mk8{\2 ڌ P*iA'mՄy 8yeF/R#6{ڕi4 *"Y搲wםka 5eJCGj7ʞ\TB(E9 !! 9Q?92_tkT9/ |9!sBA;t8[nhK[x`kS _Ãvm52_kaV K޿Hqp҄!"s-  p˻>RTPnuc5h^g? px.Z3d[ NkjOԀ8!{, :U߹)ҠYP`HP -Bۻo, r6YZy ڰ0M#2MGÿdS>ÍHH}M UhسUƞa@օEѐnbݛY\Vh*m7l4Ga g]!ksm u\@5 Ԗ<Ő2 f!gSxD\quښ4L-{` hb|QufD4*M'eS@Rz[YyM;--q[Q~>4>`yuݺ _翞~=ahb,hW{B  9`3ﶮPD [Q-zPtuC./72}Mzh6eN o9jʼyJZ#^v3pn$ߡ?lE:j> yĞ -!JlKC澂+6O+u]@0,5:8OHC *|aQG~m`DeC % / GzlG@a+(chٞH_S:PM!ߖϾ[-?/Բ6?3]Xynh\9oo<ާvF.꼫_yqvi~6W'F36=O H/9AOiCr[ PgSze i3^⊀YnćԌi g|"Wr) =f=*;҇]y96>k!Y-FSny}c>m{OtнN 4hᔎ@=fl3 =;V{5\MEǓ, F[ >.JSn"[ |!ѸQfkAlZh%cK }*ș!\m݄p/P7Iu 5Jw!:w=mrxr >\Ufv`7[ W|OC{<$L.(cw ZE.YB6!i(75i"iBx4'QUT u]lCM7zۧMZx!0vM͙Fޞ@ !'l-GA~ "C$ry7L`vOg|Ona?|.qU<_³l2|Aru$!Z]fY[fhQ.ͻ..zh§m:~J*@S>/ۇM\∳̦Stn۴DTP+<kq85Nt9@/8CG/5G,Vבi v=]2Q9iJJ41ИL?z3MlB/]B@8] r-)MTPsMB8ڴxr-֛0 DECn] =ul3$Zg& eh|Ϛ 1 GDvvpDVCA :t%q$dȵ1{h3+gJ'`kJwЪBGwtI27)iu}GnSt5`s;xM6pă3ŞG + 2tvC7W>,dqORBVxyŎ$"& ؖ'_qAܯR,E/O;r|(>u@%ƼQRp,a3KjEdM $ !pw&ICκq!cCa~y{X҂ s˭+SyA|"KBe|\@஫leqY,`u.˸J%Ȇ2u'2AAfe=/7A&Uq<(s?K$[.<3w^Nj:Y6 y03+C cLe7@F"DD*qK@#YV&퍆\}˱T4NI\ ̟e޶I6u=,EZ%J1/l,؃6f^xn09/[@EB ۚZӢ_Me7?.v]N$X>Y.JHy6~IIL;+ӎɠ r9[&1Ri2t]Zq9)?AyOyzsЬU袓\aXw 11ѐP#giozƐacB+Lz~&}UP*H Ş2*z*t. $n}$G e@ݎjcMNS `Ber"A>1 ?hi@vծ7mDe2ADϬL)6@< P㡸QZoji4H"Eb(HQp|࠯Ю:Uvh>a ! ]Y*Rs<hC;DNCcbQ-ziiYA&r3Ű' cSk&z=E p<&lD9Eh4i*~Al i7H B"m6 xx"J>T33' &Tr5 ;g B'hu 6hAۺLd-m4*5 Q&j)<$haل,SVFEGoyYzUd4 l )9NN8,yBMƒny7Y⩣yj{Xmk]zχaMэv)ƦLO`+uP|42a4(%6saJAkcG^a[KZ9,3wr(H^Xi"y\7ӾZ 逊"~ yʶ!GH& Pj qTdSIu7)lV:)A>'x̳@Ug^<\ϲE$UfA9$vnS.2.CAAMGZ0vZ4)Z~ٸE$#rJf"|: EJО][EF;+&YUɦIDZbvEX5v)G/(@Ϧ[B̟7i-_~U>nx==y?wqE]3I2}G蔠@jd$q/;wyč`}}^g_O-mDlk"N@oJ}P;&5莐2e+́j8 e"63c \Ƈ.RZ=LǍzfZVǗ}a1ϜYf-J#9×t*6>~=oП ?/?m^a~юGk:Ej{̞5?ThG>ditZ|=o9sB-Jy=mpo :eKT嘍6Z'2g- hz&M<p Y}!d+c [[;v y H2e!f2#n, @ǚD_ Z94"iڂ:+]m ZnZZ,6frs.x4qbAhNR(HrRϔP̩.PXW,b=𢕒09:-A;Dq*d_R9607-9ɇgFP'(59[퇮ĎxF䚥?:y\g4T؉ʱ4AmI`8i@ v|zG DNP̠N6T FR0Bb&MUy0 gs0 lT<teDhR;Y0La"~Z/Lвq`3* בҳ#hlJd:YiErOeH< }^>M=KFfKH*"ک"CX!tEJC|DNr&C9,p.ahv i,}W<8%)]s t\@U'ЏBӫe>ryidĪ`M[%5gKtfy !I>U$l&Gw9r_vi IrUE`$UG,@ĭ=4peȗeb5Y%yRnedma]UyXdb^U%~EYPnio,A$dVQABղmZeFVz.3¸jst(hPYӇ/O|^Ceܷ nx 5f1'4H'zCw'A/2*֌իp}[u!f X֧?-Z :͘9ۤp #js ApV2a8Jn[crGnYc3_ f"C@;wΣ9< CF>jǙGn||CѺq ݁rf\qNeD{|>PۛCߟOAO˧HGItL Kmh0:E$&B )A|*y"w\k仓v6z2A" <Ӈ??Gel%sQK&sNֹꈸ?E`Do zdCArܯ2&AwE7vT1re\n:u0ɖt,C~#A>y9u&a28%?$KrزGnV@-g| ȷ_PHvug3ѧȩ$LoZ^?we |F`RWCD706㮎`}\rMi{Xsx̛-2iW=C:Vujo~22آ։̇rK"`NAYBFj8J=˚2񬁱xBw,^Hq=H,@&Vg#s2xTe^Ix?xVU̽OmM+?+_A!┎ nWs=-[8la]<ϫ˦>l]y^<,̠mqgUN)3u,s(> A'rdnp:ocMuGcr!|Bw $ΈU,brsuH#>{w\hu;1Yӥx@pxwwYC_?>IJO4>? 1qN_aL,0:Y ddO g8z -G |^xLd83}AxXNpgw6ycZx:; 5dio?x>>}`_>&2OXa<}Lǃ9|wɚw)ZcF‹8ֈ2^z(k>k4 H7A$|,z*)uY95m,L^ 橅7ܫ3 mW& b&`*j@^fϱ;z9?)N36e{j-'9mz]Fi%ƌ3g&3tT{8욠fz"z? t |;;><şeH;̟yЕ@ ~daX Dى7˃yZ=B/P@iig$d0<&-<ɴEg(t,#CYI 간^*>iRm )^ȟUP3D%*j `QD5i$:R`dzHU/҈1,$6[-R!.-yt5TPE^QQ6 ys<'vk}lhCWjd}`0b5/|>#[xUĞ>6ePdy8P~?%Y,>GȨMq̈7}Ƣ""Um)20 8bn ╰b:;Ew>2\Tsd>LᎥfFS5רK9ԓo?r'T$iJe *BP G7 zdGp2 䙺4DH`<5IZew2JgjNO}7{pspG62qx?@Y2{ 1LX8ҟn?DZχ< [-Id;cG.1 Fօw?ΧCߵkOg11Y!-djuklGlq#L' ` !(.- M5@yw贃asf2l1Dw2EW2%ͨz.uװ/ wK^*qk *a׋(XLz>;dtLك}B>]˶yFV%/?A-Zfؓ2t\㧓4Ǿ5GSO1ʏex$̫ Ol !lyyY,x:iWy' xK{?eCrb[2r%R5} /X9ҖQW vZ5%p&'_S(q*.2W*N| r֋ğY?u'7m2oL7Y2{-Ao+Gpe~B^Ug `v7AIr6j+C9I@Ff{6dNq;1t@Sr|#ەd>~$ك,cb 1(-` D>Ƞxulo"IndU{" &҈ ʖ 4ءp @xODc݊7PNƌLzv=}b#di QOB>_djm ό~[E.m# ۜ˾a,#Ϫط)ͫ|qZNVk['VyH$,lRo]ū*m˨7TboƍǢH[[%rݕms2\b/ 'Y(x ռ77[l{բr0kX~ڈίtN7߷3+Hm#úylvG5a! ]Gs6p}̏rmn2$>yk+;Isurcf⼩!ާuuYh@bm>moeW?.|q,C"wU, J9L6= hnP=IP]ElጾhYȐFkH :^5Y0ug$N&洭dvm_'tÿq;CL҂nAG%7}HOfKvC8rVLXu}AFTVϙ=X;}Ŷ3Xf2=ɑµdG0 `ŒAU7PhqBQM{eѻdn)ݰw/E ;V}L@_boԀv{ ne {4>C:>wMgt3? ϟgdOlkNoo>!p =;$&a~>gCsuesGɞ!-]^;3w.xVb U{ӳ%LWt!C^ZV*IQ]7U&Q_!Ie#XN%.LRȋʫH,lx1Pr- g&E[.۶;Gso[uHz6^,!"{@!,@ΛJ5n0ƺQ6 qW% 2xn 1+ʦW/Yn%mMeGVZ$?t_0F5O2fcW|n?/ _m|tDۂFtȪc?ʣIY7[$\R@qd:ND)"a -uN{\p'3gL)R-G)+WNBeX3ͤyl-%Y>ZQI~&kϭ qR רX ܅}ÁOFn>Kܣ=Pa Ѓ|nD.t,)_S j@ 4@/$CPcap#?[ִr\4w}fл,(OGeAi>!XAѤdxn4uPδ8Cf#% |({2\ǚN7 dqhw5 Kxp4hoEeG:ShПC(J:0O VxL## (B._F5|e\a *`G9ydcy(Vs2w*9l|!E;ěhDb}KOU]AW3B6gS CqXUb:&e.uF؝*,s9r8hpscsٖ\'JM}1h 9RP2D>u`RoJgXG7YP'n;kgIv_Oۍ&Ons?rX?~`׋찪7,r\і/1;]7iԙGu5uk2iWyڑ=ZlG%@*e6dNppZ4yc ޣ/X9]3}[Ť{h*:4O.on#Zd,A2**e~THӺ:鰧[aV-b3 $Z9ڱJC&uV ilz{hIFY群B2W7EB,ЈNj,9Otat haM{,U]b3MF2$K}./~_*u暾 BC>PP > p8wj*kDֈ^k=܈ˉv_ mh nv51r݁ܜ $+ *=ur9oH pZ$" g6 {4ᰌ@íz.axo*Pqd>Ɉx+u $å%Txj!xXf++vXEʧj2ZLdǦ<5jY>fhaJ4^t8-lBO9dVob(B Dk V2Z ݅%W}6 gno5>j5yr@c4 ,rK"p!ܫy>n| 6T~]bo̊Y~04HÍc~S.`Ct\oWfY%yU沭].)OsofMx/N?_ )_ڷiDVkh]IHX\6Ūu+T#SwG%?9-=n0red=|hw˜>mƫ:x>6.a]V0hmp3ߺVM55 l\=3kZkEYDwGqyԜeij冝Y6~ElIĎ;y插=L\~#* !u PDRr8;2.Qv,+ZeF&/֡eW Lƌ:f XYX  Ф)ٮ 4̆3%w>v$z[w>qCVfbDF\}InT=Oc~XM,1ڈN"FL.;9JP -¿2AfL`vww8JycNn ; eV=!þFwjƘ{N}ђn;uf=P`mo-SXD4ɛU<8&3 ڂXx HiL)`La#C:b)<h(fL. vļ#K2Mtvl讑nMWy,:Lw/6ELWMH9 ٱ:QC b2u|I):?Ƀ'!ܴŋd%/<v˂ t(P#VVoЊQ\ wbrDAչfcnڿxF=d뛌4PDḣ) ڡ0h:Aj[@EܑH"v7l1TFDFPhNᾸ @σaUJ|Q+sc A E2C*m[x=a񭡴O^US(b'p1R[*p'<;HyܠNSqnU$~i@5,*#rEo'$`bcc: %b<2 S7-KdEn,<+ʖEo)Q7WQ)Eϝ`Y ԑp2::ݷvmhVz]"b- m Gby*붹q |ivfi߉7,y%D+=l_t|o1LW9m{:xίVSYÿ'xuokl<.mY79/̃Eƒ Oll_e~l?KV$u϶~֛:23׼[p+ov]9'r_5ym< HZtns4ﭛH{>v?(}'E=/dpF l2k^O)EةSDo; MqS;)__Q,/i  gbȵ"UU*cdx~: qC[$\W#S6"ԣ{FbLð'% 1@ڞYВɲ;)ɘl-f(~b輓գ4qA?3:]wCD7924O\G'{% +W{ClOU0 ȡ+ ,Toևg(0>df!đ|,EKReV%mY:V0 1 dҶYPdb3jwפRB:ؐ!cftNP5ىҟC%dS'bń<"OY=tE)ܣCd^NOH"U<@E[H0uElxXkB_HaEBiw[V4TU9zfiRc{X (J},;2EȟnyOxs;º:\C>EfƚPLks13HdP<*6Gr~auƪSܥ)B gd o6+*4(4#or2$Yc3aA;}F0uȳ_n(o$fy Ejٚ-z J,ڼl%Zpfv%ޣ?xow:[9m.ooQQJJ{-OixFMKuE.|] 6(b hs_͋0Omk:e[uٓzKwmƧG)6d&yϻ˾YElŷPi k'سeڃi$:#E|rpeˡٵ] -/PO V1BUu= RqPeHl24Iqx"^ŐM<A30{Clܳ0pG[@s#G:LI 1ݼrӑ,}C$e ys@d'\dbX/KZS&H@tl <OimWI7[J4p) % B$5D$|0e':yvB( |==C'a<2Е(5׵I#+!]Egztf Gj1A$1LaGN1i^GKufh@B%>-?/Ov $.twBpK\?2{U 7iOGIHI h *%aOQ&9Z+#L_-89dr.O(ŋ'N={iL˝H .' N~5.dU[HY3:w9Ҹ$bJvL&I0X.3u6bA`:oe,Ұź^,7(/v[mn]鰆a 8oyeR.?{yOlO_[y xZjZ6wt2sr8~kҎ;椃.~t5t-PNŷyJWKuwҤiy8:exZ;*>mU[龣חYo}zh74~$qȯR2板pZE2]Uú얗mW?y|ϧ=LA9s;mSaFI kG)SA]M=Ufu%qw\.Ͷˬ-ywp֚sPt쾟^S̋8my2_m6UOpzo+k #c{**zؙ{ְ]cW>;.#;}e?6y"dmۖ]S&*Fm+.'#=cDtsxpa,M,"NQ$.}knRnĺ Τ1os.gOzDM5ː%`l'Ј0T"k`}%_}(flu@HAp,9:LGH"r)`6K٘Xg^Ӫ+twbFvDA '^QW_OFD=:cG`fn%^1 ط9ZQ&up`6I"@HN&;-R7DHd drm@{qJCOlScX !HC&RUlQk< 3F}ZOfT~eo?D,6=]g$mI!8 r@8r/Ʊk]{67dM2E5GRmG,#H}*a7(uec؂G-Br8ĮyY׺QV8'A7gEuy6'ÔF5YЉߪ1 >ۍC>M~4΃td@}As'aYq4HKPה|\AAAJaA\A-J8UY~Ox4#m?~!Be::wvB&7FL,C7x&sDYҟT41l ӍHa3ʗ`k7,486ڗ]+62x>J˱ @L(dAGEZy(N@:DK Ғ0 eՌM"$RQUEL9b9"T\&SWIy@9BMGe|ނ=0A"."2c&"4O !baVIS ~:m= L {?[ d0-$iew-?i]ݷ~uMrpgT BۢK/yu^& h< k$i[m2rvK`@k?Diຘ7Ps,˄!F㺡DR OؖH(hBAvD ꠋ5r\R  %,TEyi΍x*I4W亞M[G.1 (I%VȘ_3}J}..4j3#&X\5pwn{̵)+͔iX0sc2Нe9tJE^8Y̬ &$SD3l%`0)3WGO5EvTНv 6Z4ܑ8ƚΧ5ĸ$Z20M/!1b*i(&$w'r~ )v(29u0c5eضDǹ87)MhV{\@eEy0}j"N4#O!=<ygxlBL'CWd6l tG]]:SB%??3C}\M%ޚiiՒ(%ř%ïr3yF!e$a?5[#pۖ| h @4#PfENl k5R:})ߩZ\ŦE1(U3*9qU8>!yʂVL=V3~$CDI5̤i`Y!iȿedN6(Y:҇O21*7`z )-iĨ<ɏqS?(t]|)dGm! WfxLR]DD|,t-aɿ X|cj0L[VTv5ĢDy03 }?M7Wz3a\oz3vF< EzZS r\IddD.%_G p8~}8;pse?uPgoVhsCe|;-Obצv|N! A}^ M>*}W(Iwa-ztX_ZrWM~". "1uLgn {:ENzJE}gC;u::"5MRi]`QʸJ.-PN:R$22OD2Axb%iEbMk%a9Bm 0C g|FȃpVQZ#mSؚ'HhaBeO%>م`YOS?Kܲ 9IB5 Bsf;f'|q Qo:qTej1zXsX_Jce}Y"R%SHB$Z5P ´RI$V3B[ĩBbnf)ߢZ>@eYfjm//RL6;ldϊJ 2F"7ɸBmxkc㚓z EU.Nh' wuB0e)Մۧ3C{^购`پI0t)>ұ=!i^E@Dd }`}r\_vK>@: T!TEűv͏y zVߎݒ77&{Qb$ CJwe׼ۓ5=z\X"@o;H_[f ~xjK#3eIr8ݣ$q'YMscGH@ %Qb !cl80Qȡ9cM&#?; ub*Op@ҙjԨ2eK t1Y`G$Ҽ=g ʑqORGk>B&VPSDi\ 047`F%y{ɵV#8@]f+uu OPXQ1IKB;8Iݙ Rkp3Ls_ZEHrɒtC/uӠ.c̀*d23t26RipYIYGz)밓m7P`4 t|Rߖ۱bMЎVHe sIH0-[$1::}qq\6?,SJd"KDL4c!y6.$#4"`hf:}`#~uwUl]hkBIkMU_=s8j*+$6BStuzqPITUīFn%e߉KrqJ=&u59#ө~`7Nƨ?3Vm.fv.yОvua(L5`IWe۬%D95Oc3J;GNj*y1Qm[ψ]WKNB9<6UmزWΙs%O+J^գ#ɌpG#1nn.%w̟+)Ic5 HY50夶vRJWd@W5FVV5/sS@/` i|5SqEq_pxDV7K|ܯxب0kc+!_bG Pxi1+LB9 ky;Oz WyHWSϕXv 8=-{~oC=\sz2;;^N)u?7b!#26d.qCI~?mDIV`د~RuZGu!xU}.%Z&_T靌KKS@ES\HL`@"qM0b\|u#H(1^,@$Ct@‹B.IkxZru+r-,bAk; U*_iנp}ă<8ŤSK>ÿ^hjǖ8VLy M۶l9Ϩk+!"9(⻉|ؔYd\ @Ղ>A Qv12yf<\b6f02&D^]]7i>W0a-H Տ8]t́Uŷ9@̠cKI!b |ZNu {C?mˌ@, 9'GOI8\u$4(M}s>R>OEfuBxe2,+}-C#Ry9?‚%; :T* ޣnOvLIEN%^S^u{IKiӶs^ 얈΢xDib'u&Msћ⠭ 3B}M)cUb-uڒ&کU9<ut4&e*x_H" `txp'p4oC~zk(zg֝ۢ(?HZ:5"Tn&&930 S h_g\F~,}hikC9#hǿ8ש<kN@2{mf"t:W0b 9`N^@FHd;OͮcM^7sژJ\z5yc֮8S(&(+fMa8?b\rN Pbo[КVPjt(`$2BUs1%f6,\BZS1,7#3nX7 ̐3>b\`̆(E0g&%g`89eLe:D Rn0}z4~GǖCA1Jity~LS{N tq $r#AE35/sXȄOkF8y¡paԙ'N<j tXU{ۻ㡌!x7҂*߀Ǩ>YFS->Ncp6| Np#(fؿd^P >'oj:7^Xdj|{u蟩#Y) A)6GY@Y†Ό4UJ 6^!bY[f`Ք:ef ΋SЛEQDu?)$}okm㱍?iDGJT~UM^+F?H<;&RЊtEux+ԫMn?g&ǑT#'MlƄt 1vg;dK\ se>, %ťuKߠ)RMfWi)B ,3F=5G3=T[șl5lhVεߙޞ|ޞj1\N`88@Y_%h_;CGZ*d8|a|Ae#:^헀b(w o rI<)fqj5ÿ҈ Nh%hmiҾ)?);߽; 7Hޞ!#?2CCz*OQEA!ڰG,4~-MgTNU~OcHO:1^H;|;~zu\LOr6i+*x<N,X{z)BQ*` 5­\7ĿA/?甒|@b*4>'A _'eMB#WiN3z'bqV՜̼< [WP 23ŭ{RXcbhn=: lp-Q2/L R|(q:80\#}9bIJ|/?ZbU8 D\-hq;}{עa͂1xM$ )t?Lz>׊cT+E&䱄,.k Z]űڎ9 tOB̫ q2ڽͩ| j(/>ݺo -_ I8"Ú%.ƀtɷn |(vtNN][5X0wT Z c[nXWk8!pQ;B5-qi Ǻ:D3z;=Ó}*Ot<o>Bj1A=-眘 ~05`3A~eD3X)ḡMT x\tQWnjTMt݀CMzS+Ƹ \Pg;xC_990Oŏo$F@>HVH|Wydf4|ZLAY{Ypyw oO|gNw>`~ ^n گϢoSOl2-u?}߲lۮG|:;*^<5rY^ l:7vbc-97\iKls՟&AF5q\7fbq=/`cĨ^ ;sy>ѓamԠHQ +ˈm_ Pޘ7fRbfyol54bg07 H\'57UF ٬~{bġWxcƎym" *g){9'EL$?yTCiwkz`TbS|Z=⟵g#"9oQѷP93,FFp>b5Ûwjۮ v`VվRZrUuYJy:YN7QJft3CD&3P=j~Qܭ[蟽C=;caiO`FO羟Q: 2*c-ȼ'`N$ae4 ͐r~,Zkt_uc3.v#) &[4sOt Xh| |fi5}Ps !3ݱ09^H3Y|['הELO*:7R ~+=BNrO-lI#.hHn8Ci31Q/!SIOI '\7eC;`6ڛHzo&cͩR7~y}f2;15雡kЗ߼RHxɜ=b'cLpc7(jA P;,s-IjD2E5t(xѓ>>[Dܷ'+]^cӘ [2]9ʗe qx= cG`U2&̠eI04?So=g2.zz>z&x/OOO?^^ocfA'/ (4/gf=#PI*%z5]-'Sm~0; P;'~1$ 4 AhC05BSx<̒\ L=iߐ/JXX*36evjσp)@gDV@ZgNEFSh0 Ϙķ`r`C=2 roFĚT5w'uˑ?3LQT e\]@cOtRH[ vK@kzc0#|U 9y( {!RP{wjl515 <(9z2#0ezM]G7KհڭZ+?z6^NvĘ٘͢>Z.gz0 gBtQ4;b!pKWlrx\9Y:8gr9,U`K0Ow`0(w%K. :m[=@J{sVz4Z6* {oJs[{5ŧ~i N?_ʪggF- {g чrjs \"\*0=el_'|B 3Pݒ3&VFDJOT /u*9Cv ,HSiւsie4Ыf9 !Ù˶- uj5Hrµ/ ׳a!#T(qtac2g[@;8s:f[Pn^>ױ+l#[|ΔvBCtlw](OCs&h=p"_1z #,3h<4OcM/1&9 r (~`=E:~ùsHًjhut"#B:L>\i@u/+X3Ib÷o-ԴgeO/JW ā?'Pr1kӏ A~9? '~K\y}Vp,5'd:S1֘h VzBp01HR<NDhy1w+\&md"3<#!=Dfq*̱.(a"(g?[elI>Z3:1M ,4Y(3fdPrR)(aĉ3,:cepxB#hm 9V/*}S%Ee?'hn07SxC_"VG/ğֲ%0`!u!0 b @BSNMW3)gX@mG)s~|>zPol%o<*LKWؖKV@˸z^~0]y1O@>!)eFٮ,vh!yI7av|'ெf#y,TLc.tH86A; :8-%鈛16|&3 [?t2/2f :9F+8/+X%PX׷WQPEJ c1n8yVKcf3ɽ/q';W8k &lh,&hXF72ZC3FO'P^&@ׇAxFkFFF[%0&5'`sJ:b'OY0$5>N8Ez8Jpų4Ta¬1xIh?[QBze b}@ SeNL%eb7d Qg AAVf ]!ܘ@8 e!5CI9( "id`fj(Tf_Tb piРsc]`5{sFD~ sɕ =ݵTcW݄|{ffz7pp(sfz9J`YESe=.D\:L,)b4=SK9cs2Ʌ1<:\D3mєs2Wa[Kj(9Fa:ٳ3撨P|څX gihG'8)3 1s4CW0DŻkA3#{ =ybg!vD//kĤ=9Q%tq]Lm%/љo"ԦrHl3x)(!`'M~ysF%ŇQU>v*w4x( ';.F7ўp3qKSp 6O# ,1zzy~L#&3<:' >C<{5^qɈ@%ۮV W6@hJpS/B0iH-SqQC+~R ܂A@ӣSfx+~!*."R 儫{__ ~ ̗̑BF|XJ #)dȡh5I2tM'U_'E# I0ESI%H#/Jr !@d`6,@R3j,t[75&14-iG8ٓ7Exos W .H :|8ahDt(U=Bd$zŖ !F1Pm 3|4=_Rl^Z=mOW \/'[C6\nWJDɷ.d&?)PB߲ ] \[Zdk[38}LdRtЋƭ]q͟R"gv6 h7i[b&d*YNlŠ\B+MtȇԹ@KӬVam! + 00DURěB=wY'3e Ôg1{07<9!PyJl\3RHe$76)G$L21Kq Fhyur/yoIu@XԤhTS|mjJ,XWi##|@/v3@@`bRbro-'0|5r1 سMdQۓpK`fSȓcFt=jpf %#Y|E/L]|ASKH k8 =l 'D(y 儥ҕC=_>tS.`GRGA|6"GH 0Yf(ӈz8%TΖI5n/ˬP\oKD156]^Y<,v7d'(~Dh"x[P!S ~l <Ȼ⠠= R!p5_*ӦopQ3=. NaL*U5% zg([x|( lષ8WoN Go$0 jA9Vs aYyX~Zpbv.m<ꊶpwxO2hv9lEѤ3!%(Er<}]U-H)JSA9Xڰ$R٥9pO؉^ iOK4}[~Zuv]8.3ҧﵐN>x'6YT@>N̸"l<\L@!m>?v P*IzC]/qjM0 X/=\Zd&_L(!QWX ?LfX8}Brp~FX;00!58qb6Fos}VBR. O#Z8K9y 0I/uiwc_e I_2; Bq%nbFWC0<ŞDkstx8@ol0akjnY-1\7۬ISCeI #3%mP a$"5 n|j'W&f8/lRAd3unVo$oبӖ\y&9ʭ]ZހRfDoAgk)`*ۓxF(Md30ƗΨUnh{r6i39X:$bI=R٭}u=|~/Bx҂GoAaJ{%M 4pQr|=۹Y ſ֧5-Ԋ g+- | .Ii<5q ^y:v,,M˙"NθNӋ=&ʻ {͞U _Dss H`,m0n1< 3T Z0u6\_T:f&0oddJU)Lj^+VWk9ڨb\)ⷉQ~(t`![GyJHj5m]D(" 2UFxY3xe&ϐ-@@mfl0sjQ`]7R(#n,qi040E;FO:(Sl TG) ˠh4!jHvx)ek^ôM/-&b8Nb:YpB&AbKiˏĉlг_v3.qfkT M-jp+ %hȮ p] @ DllmH0 9 7M2 IgJ|rM[MGm;CW9 krܱ+]6KJ!-A~[f trˡrKf|>*P_Uf'Als ٨ 9~f+GQ ]twlǰ9.黊9u .[n$}+|Bw;nWlH>;*]å=Vy#5>6:y#WO^2᭽Y 8s{|Jy̾c =90vJCM4-k&Ď4h`P]/j.q3B0%SDγR ȷày!DƸ+5 4ַ+V+zhۮ'ĒjʼnE?^M8QAYV nB;~t( }>ktUS[J3u&NǨpg%8$}E3c2M75!F dSF[1zm @7ԌҵR! wb[@Qw`KvZe_cS[hp qWfZny V𡴇~ajx@WP;UmpZyYӺdzKɺU:L sH8wͭDicI%lh#a_k=H nndt oq+mSIyh6!1[9\ \dfRКjZ+*CQo;>أ0feityr13Ms2`'$(R7T >ք!9{mk*G +ʤEaI1!G1pUkoyx,z rFJIQQ3dΤ_bc^]XQ>SXI3b.gB-@яY9GѠſ6!yCVLWvK̓sc^7L#69Z4Bـp?O=UeÐ$ )Ѩ=;LQ*u^;Q!n_[2InfZմ(6nciά lr*da' g[w=&ZLAϕYڢC@MSTmM& )j_x6dx\6!8QFRiN, K0D5"^̈y#rQ< ZQ-;3g@hgYtO 7>uEc 3S7fU[F c*+CTHƯn Hrx Mw'xxt8hôPMs|#ېj~{%;u=B'ɼB!' Lln k)m,HU#'@ jl0/s\e҂6'x)ѥs>L3nQnA95EKShE"& ۖtH}݊~(H ԸlY'iG7G.Lc܇ <*".Y9 U" yiVKPA >aн3ZJaфƩEb@kWp a ,GO$کJPb!:yL>h5-xxÙbM $ *plI`/;:u->1bl|+TJ~7DqB@l* UǶ8bM$޴Q #s SF;vb7!vG{UhĹiư@S#Mf CQ-[ZvB ٌ:lqT!N0:j3y=|Z#7E/MFW#Y7 s96j@VX.ji}!!K͜lg:?L90\5"N~ >OGMj]0Ks+e{PI[T<. uW'NCGNR^!? |H' pҏO˧|| ]}fEcw|Dj vl0~2|)%h@+T-O/GD 0əAnIzS`#AB#G<xi+C t |`t'hϏn|G)%@y``jg*łF3 ,w>HXsxmĺcΡR2sq>Q=@ݰ2L~zu(m0 gf9By^oK%vl&gGAy.uhC <0ϸzclD>s*rWۍ8%3Ŕ^ - q4 6h\YBS׾*u? C:@-¬9kQPvvQD9 uٝ'<xd2sZ/ZΡwg^558 92v/0']y4% [=ZKСxΠ52a`0;W֘r|`T@2ttK77o@/--[fnCtt.\4\Qr|acA%;jQy.nt P%=# @lCg '̩X @[#hCf "]tmm_h"H?ZjNYQ4Dco!NɄ: 2h5`f Ny>6$nde0<`-$aE-Q'9p*@.a}3rsPpwM&Nӊ:8UWJ5Êuu?;yTat#>9qs7nh'>K7Uw}Z<'xI DJ|d7 lPV>!jӐU+?-j%m9,\X-gh琠8 ! :t'@i1 ((NrG[r"J~o IBOoѧw鮜*!c[ jʥRC*eM+ Wsc}=q:؍Pj;J3|>I=A\9>JŹ Ux~9+4*yX(Lx̹wa RB -4FvAy6Yk˯qdډ0Ě.Gsڛ{ {H_#ɇlqhL\ xrؽ_Q:&X@L1N5éaGBCΎ:i+jZ23X!9#(hRÄߔgb$NYR;v|`d*enVX-^+7B+Yr䪦حf>3ʄf"@NU(3̖MuO+ $,mOÞϮmvi+zK6jjQV%url6x)XwWQϹ m-h?fNN["~dU5ڋ+Xy 3pv_ty vy -=?rLƂ6'_ƕRr]n).Ûӱ$K[bQ+vݸcgE3|#u8[% JTg_fiJcou! BB5\iw#ibZ(=`khxPkkA X,hrA2 9Yl4F "t5wSDNe ~Җ9DfQj >i+zq59Jq7Fc(U9 nnƱGO!:ǿ՛ǏsꎿQ?Ui7AWG=}{߻OZՎ_w »;}F>ÝC4i㧎`D@_u*M%W23[ig,NX[(U.J!3&u{0ȥqzbw(JHAv'?)~i,%A AY†dW+z.[ɳK&@k#Z]%hRFc3&DlO@)Q&!<& E,k[9 ]8{" Vf ʽ~MͰf_nl1~.9ō6Unf"o7^dg=*fwU$"Yn,?| BZP̰~,ec*Ey.t5lfQby&;’Um&0=*Өe-/J]hώ#II"qpg+ qJyV Z9e! |RethwGl<?B$Cǎ&⯓v <r Dh~erLxrHxD aa׆v9J K[9:$cmy <üc`*?\.jvah#lN)V*lLיDfBXsh429 y 'Sy%0G%HaA~0nrt ;D@;d_rtu>bbTsKG>4TyDyDoԡ#k)WMN9~mruBNG uzs %hAGmD׿"?Y:BGn v=z8˧?恑ÍN箳HS'@ϩ;"^ oWj$o'`߳F=^`y82 tF/A&O h`$3AR0% X#Φ<o.Q6=92gAT']P{yV܎hGHEnt3؋،fHZ j 1˖Tɷc-M#"eM$kGRa͚gڂ6/ʀ۸߅w{9VF/ی;KNVJO8FR7(Zp$]Z7qB`F,V΃52_C'G=1l1|D[uq†w5 1=c.{ѷcV`GULQΌZC@Jq"9(v!9&͠7b]w6pr;aTmgڏ}n\!|0MhǾ+L*­` B{s٥4L :pV7xcGQ]WX'q#ۦ;+rMߜP5w24O  ܣq @絕_‘`+j)&dds:ai4qFFHS h2+ @TC4`N9""O.%B P'EFE]OGJ'^:4hǨi0ឣZc:O3l++pࠊDO:1•~ ]79e(j~s)ߔN%n{_?ck$si;Þ+7KHP>^4 F(|c]].'0 sl$Cǯre Ԗ_xl(D#- XxԹ5,#DVjskU`v[asr@& %J4UsGWQ$br15!' H:t 6ݙKB q[͠[ڶtϼ2[MM-oR~05;/s2*4YA7hT*Xېaa6iO!q"854\umCC+=xOL ݚ+X8zw2-gyUߖwKGwgG.Q"]LN)YdrKBm A% orPP_g$qDL-_VNU{]~|K[UC 8CՊe%m^7|w&ːo#H}BӋ1ŷ+w!yފ"hU@YpL]CbZZWmųЭQHԴI@s:VC,e(݅]ʝB^ʻTs_~? ŨeuPhJRإbu ɖM8:~EJ-G9S$YE{n;o+r~g~[<~~G$']Bo~7QjWu~}*VzFGk%"7KF3x(jhCP8\7$XAYQY;لg!0&/h m {GeFgADTUI%\|z?_`NR *i):-OlmiDgNpvP,甜W%TMT[j6oeg2qi!dκ1e8іIi5,f;(x j2Pu\_Rwƛ-OXU6[M;u$~ȑHD;].a8/#F:vG22%z  ӣ=o1Or7ɢ W(pa7;=&`F!qY ?U~)Qx\ >HT9 GmlbD0k=x1@)d*yv80Un'+Ϊ0coZ5śnE'"fdҖ-t{+t'ǒ*5[jno\QF B8 $ix 0~i{v:clILppdܨC9RxjhԵt;(pWom7hh-A['ՔJ& _o9@.zm]n <}[mXOAw!!;@8iNye~'ˑ{.=mhIR[ =E]D춒)Fˠ'q^b sgR} +.,4?dڔZA.UЌy/,'w8B?AP*GVmx0(@KXjܖn"nN!u8CkRUnb6<7$^66|a€랧0A7*f`T>* \Ν_ |RFh(cFO;UZ @ƏjiǝDg*($cKc߮Q\6r["O.?%4&% oMdo\~%"PH%xCNB(S8r p(G$&JMጐJ~߳#Y ~~]O %!~oX>((DEn,,OlC䭋\&pIp$3FTw{}7tM㑈NPqM >}qܢ??ݩ9I2͢ 0 !6!!0p’v$o)L< z9 UzfH]vVTRa(I GTi<6cI䎮A~]Q\W_J9r2`C4 p(Vcu'Eh-+mn(a@~SmiذeA&CR'*ulEޒkbeduuN;"=D\Ҷk!݅̿FVICULJԤ:ɰƦzUaJa䅸?oP!M\;oV),He_ڟc'<ܕM;xn ;CUmH&e8*SeLul.JWJ$p40V/T=TF3 K__(lQV@>b/As"Bހ')/?룚r)KmEqGNii\JlJɊJqH)ze?>cz4(%UVvS;D_ Wu RNÑD*IE!]2 %ǢE0װ%\c 3qT\/'"O*8<`${ fENq[)5<%yx83 |J2j ,ܣ?m J$Qk+P'꧴ QЌG m{*r)w75%E&ql"^ё;m+[ˬ6fT|uR~{}#ş-o!m1Zm0PxZf!lEҚlQ'x4>ࢱ&< +=)l렰3VzBMU (:)vcv zhup`Ԝ@D ˙9qd8:@u@7@=-W‹]?#0S5?TM(uĕ:[wըi=zewlݚ[a$dG?XEa`( n%lUGsNw4K(߻CkX,ĤeF109mESh.az"*kRMb{f!rD=J&$dT\(}{qStȹCГ+ZP U8v{{ܟ~K^X,K/^Z;I0<h)hHǞ+|b 1UZ:CH_9JM _S ctl6f"O"+|{.vkK!wҘstm*USXڱ; UaPG*d4]i={.?@ml%HFu=:!ctN"XK_ZVm:UZ8:sZ4A;3O]$ QW~.K;ogf$Eb(<lu ʚDFϊNçfwD uq-mުeө/@Q{+2EB1@$KÞWLԦ|2GZ*ع>]Q BS1 !]^$~7 ׫}x>oG|kYhȤ?%QY*)Ay r}BwPrȚдW˛yU&@c$[X1tC?BEr§xdf/7#JN cK=ׄĸHVXBZP )uQgo=rV|V~տ]/,xSg?w󿣜L_?q|̓fajajpRJP$H p+hZ8c1:rep0{ Hdw[yhat4Iwzt1m#hyg $+K;j@b.2V# 7u^WM%܂r,$EigdPImnQ-k EO~Az~7vUB`r xykKSZ[ЕYRDh݌߼ͻG'\eG}^Cc/< ;? J <2F,ҳR}З ~04Jj6J^xsBVz>OK E잍!)C'v0pmƳ@,2Nig!>EȑZ-bga2~ ӏm%a5c95$/Wqc22M<3W@bmT9S r\:.oR.\/ߝۿiW+njEgK#f _ݓsҟ<<^2'>mdJ"%/[d,P,`Pc,_-$-^Ry;{0IIvN0 NqF*Zj֧8p.@믘X;FZ_ݲfI\$ 7>L澺IIߞ% j>|ٓiz;;M!:p=v,b|kIz-Lk׋"Z;XDb:40+xܚMT8.uR:E|%bg8Fru$BIjź0B1XP86iݽڄQ 9Z&įz-Kܓ>ܟT|u} _-cLE3DeoKx'ba^/^WgYalM2sGr{u\o .6m5 ],9mu䄢u\_}mwry(_Bc{*_*C,/ٿz9!/\|u_IxY-MF]h;O^]%a ;$1GdόXM4O:ApGT ܹw<]p2an\W}PwUp/$4ZHaimK6=A1.uqP2jcS$mվ$ h/WoGR >  ,ūk4T{xPaoWRbmdK1D|j?(p uhpWfƚacXВM|<$5WW-!vSk)M>rhop>k&oR&7ڕzt%;{9m`:ޮھ(?dI|Wy\cbk,#n}ngI!*K0Pk}h<=σ!%:(Sقw"hF=:e0"z!\q ?/%mym#\71i%|{Jj܏\g)?Rfyx y(Nr JO"I^xGsHVS1ϛ;7[Zuh_S%~oѝ$[`$u }81~ziwJ688AaYUKСQ{:&bmh+μ.˭oK%H(i4 ">$픑zHtx Zƥ}IpgRh&eQ`Zf{?r[5E~h[;jz'Vt] x M.b=\3;T.Ӏ4mVU3?ҕO]ؓ]8(Թa0v!0]]ԺI5CRO.S|Rfi}>]- w/߶ۉK `Gð'"+qr, cShH][5n}zX !<>w_R~n?JNA㜺Y.nwQߔQl89\)>t9ל%e0t aE}׫iлS|WWtǛӯ֝{;iUd]z~n(a|+dOuFɥ@6=sb?=f%ݨSnR+!6^R˴Bܞwz}WVӅ7ҷ1A$ f.{}Ol7! .xڿardƿ;BϝR{忿9-?)+)+`yPG>Dfo1)$nЙ`hFk.= +. w,z@ [9( AQޔi_|gO7 <'?|N)onѓ; /7ih~ڭ{;r|/w};ٴt)ܨ=?Eݭ蚻 ܼvi/K=n绎ōKRi?t8;"KM63sÌas IJ:+mm,˻׏MlY{?[@8*GgG/%ut1;J{T]4Zg\trògZͅYJZ7#VX> >> endobj 10 0 obj << /D [6 0 R /XYZ 36 597.056 null] >> endobj 11 0 obj << /D [6 0 R /XYZ 36 583.1083 null] >> endobj 8 0 obj << /Font << /F19 14 0 R /F20 17 0 R /F22 20 0 R >> /XObject << /Im1 7 0 R >> /ProcSet [ /PDF /Text /ImageC ] >> endobj 27 0 obj << /Length 601 /Filter /FlateDecode >> stream xڥTKO@WіتF-!7D%k;1IDB};]`X_dR vg5ҁ9ʈDΝn< TC)EV”]\fI7R'yk"GPjIXb{B*.:pZ>U *vSE^HA(=Tӛ|T^ME|evVmh5+"j_r&4,8<4 "6\R]rm<[P:"ł󒃄A xP4ZjspHdӷgФGT;Z:{)r|c>|z-K4y3yXQ7S:]::xawUnV 4Mfd6C=ͅ iNs%K't%Po "CZ ._'.1_mUS>L؇h̹qA.U6ThcԑϮƝtqpY,̛Yvz;n; &)^+ȅd{[ƺ=Чѱnxh/|g|Vendstream endobj 26 0 obj << /Type /Page /Contents 27 0 R /Resources 25 0 R /MediaBox [0 0 792 612] /Parent 22 0 R /Annots [ 29 0 R ] >> endobj 24 0 obj << /Type /XObject /Subtype /Image /Width 1593 /Height 612 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 255 30 0 R] /Length 24021 /Filter /FlateDecode >> stream x XSg2 Vu4(ڞ ({:P:@@j9>=^Gby3VTnX;jw=XPZY߷VBr O!Z׏m7߲O|m-<<̑s[MUܛb9g&V 3vn~ěRgKM_! ly)'*m޵9S6.1xqexMzv(9s)-ӦLRQ)Z*!VWqqk6rM-y2xŋT0L579۝=NSĨTj"&:|Bt"m=jyihEjSFREFt:NөF6X+F#68rT"R5(A[$M߀^"IjOSǨT kZ]Lo xSӭBZKA'XmQNud(ULZo9Z;xlpaZQGk6mh)q#t&D68S=Q :bN%VhFUk%T,E<pM%ks^\꠸JrbǤŽ}Ӵi+!?Kx262V)1F^"-.WҿbUIgrXQUWgY7=nhhuBz5u[&,&܃[QQ:"d5hF%Hώkx24km"EȉjM.amR:o8s~j#n4ZBQǪՈNHH^qxw' ^I|7QH( |pKTPmIp{/ET_Sfb*BmB֌ѨcϤoTt,Ҥ%Ӌ)fI1L:.Fө(VUU-1ݜNv3zr;)h`o\QjъcbTтϡHZ&aH54Up=fWRbBMzÕj5ʨhؓaAa")51U֨5tec$9U_fn$aNOMVR?۶j o5NH|:1$i)RR)T*:!!:)Zhiy?bz:V۳wV{T@5\Bf1ݼM\UJKy(iF֩1¤k'}!WfRXt_LoW(. ΆZ3Z3zvZ][Xh6mR^&Si5ruoS).*\pMQl56~Q:AMoOQn[&-TE xF=&5=A[xL&oJh>$/Vn[RyZPRjY ~Q t9JJ6F_k7-r$!.<:F85 ݍupRHeOi4 aJ ukr֬ZF2JUQ&ZVǧпnK ּ? x_dpaj*yOZnt"F& Z˥&)77=[CnnkP*ZmPYXCF%ϣuU*T9R7/RfQo'J=QHrZ:F10fD'-4)TxE93zfN'(Fnn!Ϡ#/ߐk0ѮQ*5JVWUt|i*]*F$KKw)UrUC)m̒'b4z*F=(.=U{DQ&FnTQ!PBbTyAT·իrVWsQJeTNTBDZ| 9h(*=)=~^j2:81|s¦ěIfc uL\ܔV׼Ic]~A'(ŹQQʒmuMMu7XUU9J^"D}aV5&~3fzlƟk"zE⎘fIpר 4HKVm"vKyuyyy\=эuy[Wߢ):}EE}U:*J(v'$\A~]Q&vT|LZYf*@s7Hz{fX;"o?N9~a=osܞJЍhmܬKֵ4GfVNI:A5rK5>O"]_m0_JBЭ,os75ZZH27-<sݻEm>N1)Y&lo(1{VsخNۤ=Q; h<'ǠTV(:djQjuZZj},D5233,ZD 3k[+7vݲ,']o"DL7eLqgobnjr& 8n7l{LVqŦ}<ZJJ0V^ + b'6ƼpwW.\$4[BDdʻ<8>O-x_\KN"UZ#ELIsQ͟ ՠUzDݍPN&N1=ZCMg_"kLdk)2r%;d2"lNBA5KHjrrOeY, + B.-ƮӉ-> '|_ȗ棋: ON4sܛ)R)1(Kn(@eu[o1JE*($2f;3md43㈱Yl4.p!(99_Jyޜ%7A'|&XL?R~MB7-So ϯ2M +VF="iv%,Fz([~WBE{MF8- K36^aS#'HŹy۹\5'AIF.pꀼOLO,=)ݽe oDςJX)ZBUD:-5W326"<\R$! :LPYYxR / [Ą 7,JJJ$.An󦽆<19.'@KBʎ Ba*xd5UZ~^xqq]h\V]tO@t-љwHN2Bpi:גijܔWc5J`c]评{ѐYK dLW䢺t I遢ְl>qwi8-`]RX`#N wXK:6䉗9RÉ^q9Lii1p_1L&vUF$x?.K6bKJVa-/_og0JN4kT* Ʋ%v6X"LЉRU9Jk \DGB/sWg JMD"H WzNݡ̀]|eYɍ/z%2fU6au7$x6UdgBq"K)Bb̰]N9lOu5|ųx)HV`+Ňz59}} *y?exss+ny ϗ5S!҅hҶ0h+2 niR ?i%ٙ Opf/Z%>Mm2,Y ں\j>?3MEb|эH JJnn'NMG"R%Jԍe[ 9mOє [e/pbDzYFfDlTէ"Xۨ#to躰 z`x.bÏn]qMyrN\꼘 /HID Zp8J4pE$ [-:N k*{Qi2WsT8d22=4u+tOG*#œx>/36zbtI. Kq6Hm9hث(JYH􅕑r(4ZѸOu:)f8>Ha6g8F!UՍYMOʨgNcH(/8}!b?qI]4_tĐF$+RFk bS4|$ Z"KSɅ#똨Gi8u2-](ƩNKYF9Xҥ?լ-9Y&8r'N :>ZNNxK_!MV j^館V,]dgnqE ,tsƭ/w0H(1p'Eo Ị̇/1ܐ8u,PO;=*Jz$+ &p\q?9.L_ZG`"#gNGf%al'-]lͰk8we~O\u+X]i V/.'n,vr*p+OX 2`W[NZV(+%qbYU+ Kfggӱi˲DwTZ*qqixte$1$-_daӃ4C&)E Sb ,ay>'$J@8](y"?((8;dT…Yٶ*" ǜ 1 bN)e>VR[RCy7-6?B .QX&i6=Wȥ4Ia+榨(l<ƬғxJZ9)n< CU:r/-rZ}fB3ԫd(^z| ƥjhNi؊Xb!Z8Sb)U8t[i"#I(aN5"ڱeq%c-ysױKK"1#rt:,Hķ7"#ۓ2nTG7nreeXtctz9~H! sܵ?Km܌̢#5zgmzz&YkDDFNHKINN[dg\b䬰eT/F Y(S+ hxn3.^tED=5Oeu}kD8)s}hB,QE'3GHApH)e}a?/ {/GLZr,Ӡ6Cl4㚍Lp:TXHtVjkdd.ͶLq -Uɡ,;rhq4S)4dJ^gd"&:侴@F dTUAY/0*Xh5En֙PUqsf e1ba^fqآʪ;JkJA"xum"hR+:O\kQF _U5to(X^βX8u7T74kUϒTc p]5NZOSGNI8e)Z֩*5Ǭt2Z՗c&&6MA \C2j(H^~,ʑ_z]qTѺbAfJIYiM.mBڜLwSDކty'S 5vNYb?v1x$d UVvtXۑ$gnLW O+<}T.=[b`\PW zCPԭ}O|k^>}ךݥ<3/Mp7bY%s.q1v,n1#z)UJTwSU e PRbX(JR5Q¥FX~+q:]F& <m-őEG,^pT{T'3.m_ÚxFM``[OsY8tdӲg}D gt"EnRN|uڵoq,CLO&V"?s+\(DINNMJ~7eݻOH]^,rp,\6ppO.l^ c17*U4[rJR**CaۈKQs6cJD=՛:Jru7'h ѩ#~H%f`0'ӱv2$A'(;ithyx8)CG}HVꕣ0sfLIsv}%|S]Ѷm[-ڤ&NI>G )b^fSGJW܎OGr#⣣UpGG) s%Zs>07fVR_{&amšgxo”[p+ '<NJq4#AOnPm0뢃11ߡ0̥^ +mG(BZ]T~qqAQH%&%v[5+aaWG,&6o.IXq7˥o>)51х:eκbHBJ\*Mp*yӖ[##uEź(݆UErtS:1es:PZ*,r҂AR NHU2pQdn*:xJ4%>FY-) HkmMjLM[֎RTOMz EE$%yscRZד!֕³3(=r}38ygdvʚ I BFGk`d#w7mxx떘1*.XEn-T֬Z[[)}vhȋ<_hz< >26ǩk-]a[\j}Z5*ZQXP(Q\EQQ:Fvsr ʄidYYhǛt׽wXrI˖壟,hWl.'fKq(H*FQi5(m.*F=(x&aڽnF)))fǥNNI6]5|p0G82f.plUesoGhUn⡈ ·Ft:B)!{H;/%9eNj8i䈀M}L r؁Ѭv͉ѨTh@ը5xNKOWMIM2'-} 3gMd,qsp0Ht3l_*FVc\lr,sŧvZ촘911juBK2bЈSRRfHwG3" 4n{}r+@bY0;kEiQ т#q,YNu\jjZjj\\:mJzܠ)iqRSsMPu3.%mǻT$:)ìYU m]LkRF j? r H͢_'َ#ap?hKctFn,sp|#g~ޛsg%z)kaI7HSd'3T2GʁD8 GUEeթSrƲ3ߜ_B_,ڽU#Ce.]a7OI?rm)ҲRk+R}{wU8 AJ 4\>V&ҥ}}ߪa p[K8y)ix|Ei]KJRQnvfKRā(ȟĻ7&]5).0U}D뙋bQV"2o+ژ'7\M)$,\qt 8h4mʾqpZ0p]ϘYHJ Em^uxD|“RSƲ\q>l -ĒᬲbpuV\a=;3HҌ%+]Cr% ;ItD@9X"PUj-]QKӠѸpuB"]oY]#Nr$:2Pe/X3x:c%"Եd tt1ߵc,Z]Zj?kj颣bϠpV7RhXe-a;_\*a1׍pa@,tfTEk3 A k~T|ت2Q֢ux jpڌ 3 aF(o'Ƶ*q>z8&c{}N3MfpZ޿S^Q^]-Ff$V&3k>:aaF5#Z܏- ㋖515L奘ow6i5ޏ4#EFs\GuEYLh7 ,\6h3>ϊ̌aFx3MsNꈷ2bȌ>tUpCz6+d6lI WvGaF7#?l4?r #XO{Q9f}tm9lDf0GK6?;щrލMl: x$9`F0#<_b=0f*A0XX68ć/`B9yK|o5#Ÿ2wF?8fpm6މ#03GifĸԌ$yqEv';?f仟;rS2eU & x$ҟCaF)UzNqGwF]n\nӺ{U9Vό*MM ;uBO8R[<TÌKͨppl܁s0>qN&U4h!NztnG~=L,337s>G|s'G4;Qgr5pÌ`F/B8##g} ẟoF.2#{ᨀ6j[VZ>գEogNÌ:gFqtAC!vI(Vf>ᨰ3m9GOX}ˌ*eFm8`F m9" ӡQ93zsbFf0mC,Gx0=쪪**Ƶ YU2̨©vjF3鸟9qL(}h8Bkai7/,nQacF2Oۅ>t~ mg_|`{U˯px8`Ff$vW؏ XLvvh4RhdsV3ѐ!fKeF5#fvЇS\{i9ԁp`h#3 ftQ3= Oۤ&ﻃaFx;cD]ɻ(vfT3Y8XJƦcwÌ:Hnď1'l~Ìr4Veᇝ$9֌,`@]Άu m laP~m 0# Nr.J9nbF/A>)"3Aw"B ^nFlWQ9g];0#^mKӶ0hۂz?{Fބf$Ѝ]yNwڶb ngF.J3^U>{|&>)Œ:hFrI]T)O; 4̨(fr=xյX%[Q&it(v0̨#3;C] ݄]iFJhq# 3Q3afT3͸bhCع݇X fpŁn>,h0ی:!0#<if܉HDaFvfT3{`ߊedfv+̨c[pSQ&KXy>aFL̨3/Hn~20!J3nu3m+Oezƛ1_zw،^ mmF!0#<âp?zZJEo犯gn4#y;;χWqt3 /aC6#bICaFx a7E+p\O`"4DiFALFsн"3%f68` b7{(aLČnˏ7Xd:<K8ԣC{gaig<.f8_tWi(@x뵊p \qU6GjF"CN1aZg/b{ՁZך|"zhc}6?cbP W=*iFOm(^袣!\15.4#fƮWxՄxU0f k<_86w;tCvI*w:V:13B6pUnN*\&\';v: 8efx,8#\|M*Ϣ>qzJx| \:*\peWyʟq3p9.N^Ei@.*wU7I + U TW3Np8 4-5T0,Iž~~)ezdp݂ZPC#o߀@Q8` f . W1쩧bYFV?̮];wڵ}]I7n 02և?߯GT. W o^p%"1#]۷%xuxpL8caO uDUo2$$$,̗ 7&^?}zRRLOڼ}ڴ{$, ;0bԨ1=;eB7&a.{lvUIJaa:]n)pܲsOҴ͛CplDDh"#Y?T!\p`}G޼1>~WŽӧO6mcz|i `%ӦMcǦ{v{PpX'L;*pݑ= ։꾨tcZ]+>̙ǩ6z@2 qt # }.r;v ؕu!l_ѻ+",, @.\x+u@I8>DFsܦV~G |6rnQ8v xpb4#GLe_AHYrۦy.r:>>)iǎM ;vl4(Tbm}Di@ c+)/lV`Z7cIH/|VOL$}r,;;`DD>91~̮]btp 5ح7,!* =\D S"pLl O7FЍfЫ[bp=={CZ>-;ssU~;I__j4ǦOOHwI6jT߾W/k H80O/$QIZUa-@ptA86HJߑ4z__@q3oO e21ӧM$bÄF&m0} +bFщKߟlЯg~?HKـp@Ypx(p& Ꮞ>|㈁>>2F&RCo(poB#^~ؿԩ|m\Űa}W#2I_TпQ!!%+bF&r7o6-}7M uV!?l}y _±Ga~~1SJعs)G10$T "p66Z7zYP c ??\+`Op#xcћ7`%aw @ kbwسsSk.'k&M%x`8 ^w/Nzϝpa `e+pFc>=1!,±yǎ{6.Yc]M '+W}7+ϕ=8tŗ±ml641Ne;bWҞo N>&nsuU2ߟa~?f.^\HSƎߟ>Qٙ3 ,D.U(   79gOR;g[v$GܹA}{Aɮ qO. yyO?:sfWpox6 򉈠K4u|Ŧ?DHH;*,…~jnܹSsA2|G?x}^9D婧/}#o kKx>cp,xB8><$sdX Ge4s8~g*Jvo>> #c%MfsW^?Ξ=xO}ڧfs/".]#/F 8gUA8&p'!~C8u=YQ8T8W~GC*9\|݀p4 ߗ_/Cqٳ_}Uy\><0fϞpA,mnd37x'Of3gĖM #3\/u).5rF8.X]]s .e [5_ hQ-yٷh]R o oeٳ/};,Du5_:o+Dr~tπtяH8t ?$g[ Wb׆$X? WH4 N*cI_!] 0$8%H\9|?Kߑ["^}z0r!Džf6W;=_2~|Z: * cYQ8IqXYI86UᰏR W^㩿! DžW~k0}IeR.Hș3p< ZQ5~r14%:6 UUֳgXXUo' ?™j*[p&?5J8 G$:#ЯY1+T6=p-ӷûpgQ$Ĕ#G NOˎ kJU_e匭d0㸆;wKWΝg.,ko0hp>}@~APA8,8{d6ۚ\pU_ "xeO]8w ?\HW_<9" FYrKmߙ3._rUxTv,{̾}^O6iOϞdzСEa/ [9ma0p឵\_?Wp 1D9:9# c-%̜y+uUA8̗h>V6L~p1|S!7F&V҅_'W~828s-zʨ>L矷íqrQUP;| 4cFX n+wtCgZ8&hho7C ^}ALc=ɓ/mzlUUU6DDc@ЫT8 2&axz 2X`q8qdX!o1;wg H&/Nу5aa\e +g)xx NgpO"æ$Ws3 !Hnj$ᨹzuƌWgp$ ОqiȉwBuxpz@BU)PQ>o`DXX^aa ?(gZ^&g;oچW^XihxwǙ3.L>.ߔֲOdIǟ GD '7Ͽw@}13BIx~={?@%% qªgr\CW.^ mhT8pWc_ )GocCB®ݦkLfΝ,7i˵wjjIL}m5|s<}{*+?: {!#kJ"Dpk aaQ A`CA8V.]"~C/9ʳ4PUIGáW NjrciRp\It7 [[i:d"鯍lGv~83:{wﮯ\ +m\_UGڍ'[pQ|Lr 'Uݽvq|WjYDv'G6 锣\`||lGp?O3j`8Ϟm4ֳp ×ӓqZ:Ji8>QF|󰿼[6߈>0xC:##IƵpD޻W,R-ìc?g ۏqUڏO,Kƣ?T6q<1e7~(|1IF̶noo ,׫f=ΚTxpDQ92hܺFcpl_|0llo OQm4٣89"7+[e;Z[qb1\?妞>*徾҅ p*Q|t^rW^_zÑ][uO GJJ$:RC7mf&,M_[]իRznslyngow7CC'jbr:_Վ܀-ʊp8>7zRGk_l/Џ*õZwf8D8~OŰ#8D٤#r> stream x      *3*$(( 10+&6-07((&9&>779)(=&44,P.+ZF \ 6I.M+4n,f!*P@&LoO3F9L"K+2o3o&5TYBYLJIJ)HW)YT*RZ;_P-LR8jgNq Llldlk-Qk8ht0fMC WHWKMT EG?XR9Xj gUjWycqq qf(]U4EEEYXYPHTRGyVjJvEzrStkLekmOkkl```wxvmsixGF X j3R1fhNWJphRvrTk($dQhvSLcN(;( ,PuXJolČ+,'7/ 02 46&:H5H'H2h I1J1flw{:HWN{GwY|TgeuPnT]6(QqjjYdNTRLheo^*(K{d8!SFȥֱƯ(@j(/4    *3Kj̸̑Ęǐûέޙ׺дŒRendstream endobj 29 0 obj << /Type /Annot /Border[0]/H/I/C[1 0 0] /Rect [749.0261 10.4147 756.9963 24.3624] /Subtype /Link /A << /S /GoTo /D (UNDEFINED) >> >> endobj 28 0 obj << /D [26 0 R /XYZ 36 597.056 null] >> endobj 25 0 obj << /Font << /F19 14 0 R /F20 17 0 R >> /XObject << /Im2 24 0 R >> /ProcSet [ /PDF /Text /ImageC /ImageI ] >> endobj 34 0 obj << /Length 682 /Filter /FlateDecode >> stream xڥUO0_DZ}ǡ1҆6 Z4@Il'm(VBUe"dh md , [4 a pFQASJ^Y{=pKԎ{o |̮,J=ApdLj-85H"E GC7m{-m[JNVaʎJT2gZK(WCA&zy Mq]g'ډȝqlcQKmES8LR]-qގA`ϩ6{$rQ9ХOF =:[ Rlr\:$" h£ J(A` 73zeZe5[B%.\Ullϧ/UNCiMMGRաtAOpSϗu:,IjУl;@z FH`! x|nH{g m8{7?u7eh\=|JI4$^յ-l'|u[V4b®ǰ.i*#4,?j7р-)R*zReGI8 o JOJ*\log0 .>oM/h*O./$ͬW|ok7 ŵ4hR=#^pmlji Cu7^o9> endobj 31 0 obj << /Type /XObject /Subtype /Image /Width 1593 /Height 612 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 255 36 0 R] /Length 28403 /Filter /FlateDecode >> stream x\SVT[n%ZubEVxJkt~8݃Nua6?/n{E>i7[تε?^Ns|>$9 zV ;u90`;[}uk[8έ;8Cb9??U+C bۤϑ-ss6';'Gk˙mw6+.s9)AϽ 1n\& J;{+,dr-gFѦZlZ &Uolf̱]n-<!^dGbV|)xn~,fYvoբzE ]ANL~Gu_s@ 'mfzVg٬S1sUN\f*1h)ojRXVm~HsVюLK@y2 N3mh6u1-;[R9z^cXRRuJiŒbRL)z:'cW(h׍6Q**{7kpyӘ9zdՖ͖lj4&yT]eҌk^6-|b5iSZ`mZ7o'@td qضJzJg+}Ŷ26{nhMf@oaxS㾚p3 'd~" CX4jV6Ucs'S 2Ŗ=Oj֎թ ¡x,* ^D<ԣ M:ɈZX-"ǻo ZZZ,.."1"Ϡq*a]iA醂RG9@#HFZ5Zv_z[ :A۬ B \Ĭlk8OXxb=*bƜtyG6\\Y$F*S5]ښZ($oKñQ[-){>?m`6陶9bp;xC.&1oΑuc&1[m%"mWEEEBэHJʍKK96Oѧ%޹aK LjUg,KϜ`W.X(KL)=;K,sʊGcU:A7tV1l'+*w{948,t4x+5'NͺGj\µfNtv{L+ز'xjm'hBOC*~*#zIZS8Gw~qEjlhU-!+9*9녿Z6xDf̟͉M T6ܷo_}l(]fl3O<{^z.Gkxg65+ij(=X_HNƠVZ$k:.h̺>gFvv+8 @H͸f劼e\ETŠ'{&A8Kѩ㠌&gfx]=WW(dDwDɣ>U%y|u%F֚W9D]R2,h͂=/洫v_*]5kpGļ-ԙ8⎽l28=UnCIZC6Q E(9LC;iԉz- F%񞫚ݕN¡+OA,t. 0,_rj)#Wu^p4Iܾ&irr@+věl#VKo?F $o-4"%H߽HXHPx:{"x3ؐo,J,sś_H#7`4ss\."+v8JwII NL}0ŋ4*SQ\Q,.%HcwyeeVP$G9'0J<3DqKuGq԰V*D)nVǫ6V㬸J-g2<ZjPd.`a$W#VtÞvIАo|4*ŵ.Q7{27A˜*E2\Q&}m|pZdt:cRNRT\1'^[Q"Gy̱a0cq&Њx7 )Fе0g˯nX(O_C=NsRjIE/Po! 9'^)e<ˊ'%6}3ՠI< c/r: Nù6)ȹ^Ip*pD`Wqt| m!FFS/j){8 p3.QMHBFQ-T؟h9;g.G &0%Ţ+єtNiME PUJA6]/r; 4\xZ%D60H#.@ /հ'kA2KDh\|W7p5gd&%z<Ĕ2 Z9-QXAiNl(..TI"w`[^d!5 TéLe:3͛Hr50q*fԽ_rj!(I8\KŨv|V]~_KP β)m9Uk"EE =RC9aFR*m]/..8J) Q4:ޢ_*4ʍmB2ʵH,#bn|g T/>B9j}Ms$1tǶ5)J1=q?{oP>:JTb:[E'bf]x]qQa73)CDx%cef}BZG.M`q@ -uNKT w7!~F8 ws{?H9sB$F\zĉpܠ@楻_Té* YRFfe0yTjY*1Φ=VJF^Ks߮ EI8N~BQM sO/'HUR lvTq乘T9 l8 6hnhYtS<]DSZΩI-U+MOʄu*ڭFiG}dU`i:jbo>/Uݠ+#xM.)m&CQbY(":ŕ΂Ҫ1]Aˊ `p\tA#sU cK<AGER:Uc n4.^Dt7hJ ̏ГMR$ZV8 Ɩo:.jRHEISDR%0bӟj]yya j<'Orn[.Xx2rNn4444nqnt+x2o滜LsgRN?WV:B[(u) :T}ߜ!߽B#p9S5+DIh q4|TI{n,^k#;KGJp}:R:K"$6bƒQ:N!K]qwl RN=*T *^ai3T!pp\&+ IB2hʐϜ%F*I:r2 )D6UrD WJcxi:ԧ2P p8~>8%*Au.uu1P+.Wcm##ma"_ǧ3SP mtH0),w8QUKgY~C:RRQPRF Y(~Ñ!_ް&ɘ=geP I^8Vxg.ۿQ[+.\74rכXtڦ7QШFˮ62YǍQ  KRK7XRQ{?+ Z[MWM&Svfߥ_c綬W1jnl>._ޫku2B96F1T: 7Zϭ5BYL+0dfgJYD?LʬUfk΂=}-Y*-YtZ E6*_'rNUM&n,):3gth-VAbfey8ŭts?6ZJ֕]f4sb[9Ir շ-;YQ>} <޳OzK Vy@n$ @&{a7xCX,w[ʋ=EB:MAYeq-L.,ۉl[`17t .oO hkkIg{0\Hӽ;jp:o:*€uJ5*hgIIFc)-,h gf+ݿld3ߗ E =9\# yyWݿz-=o99)GSS|^fYR(FIayIeyijԱrU|Ēs9L[6 5OJ̣2{•1St ldcAy`Wg]bs&f[uZZGQ\W⭧Rs2AoZ94̽Œ;ϙo4 j4cNf'sXܯȫۋ=|2+{ٍEvuNVvSoguZơVk4"G7ZLJm5QzY,6jXec'df.X_~pt|b˗,:ҝR\Wcu-~uޤi4jMr8ozX[Xx-&KբטjdJ~00?oE#uK8j,ѣ*)RR֬&KKXO-ԥe.}kpfDZM e{IlҔ٭YsS*,:*,]tVуRfђKx{2tg?aisl:N MYps< d[ .y\[sŮ]S[l5MYȖǗxY1DXF y`r}T]j|ZKGҲVNgC95%MyU@*m]+KSʬ%diey?rH{>߃6lzjoJͶt: jlvh “5JG'4oim9M/]QQQt^kZL9Is.)?gF'%qit/( 5$ccKy!u)tA7jiESPR!C7k8ώCbfXR-[J8Jf_ubƌxNx>//?8w8-yKzr+x:N-H٪S47Dئv㹳`M ekj5C$#'sjyX+n4)/t@ǬHF{ɪ=p=5N'EyrMffNdFm[vVܫ d&Ţi|tCiaeậ9sYc฻waЉk g}U ?p4VY>zdg u0kZsbI i O-\xYX,,MMժVbhloH/)tj̬^7jj:7njg97atG@]݊V,o;ޫ,u&͎M:sYj.V>IM5=n2Y,eee~Wmfded˜173sn&G2o'Ej`Ht@Gao7+$߾PUoރYUAA>+Wuf VFmjnܿUm̬ٙ }JoyK}LHt@X+A‘o_Uۃ#@w+%@gtZA; Z5[&ժ7iQo^ g^R{ϧ:,΅Cpܖϳ.U8@9VD⪝5C?/sN':h5N0v„̬̬y_]7.uΫ1oi@hū̀=m8pp՘m49lcqNV&QOt,`^7qnx4p?ދr"˱&2<&% |A>I22nS}̢ӫ|kn B90xH$r} +bz>Yz{NA6ffͥr: >FiF(d Ht@g&%PuBH#x^D̶r"lbcH^0K`/T$tt@z{U.omϭʌƷce$dcjz:qr'KmK$!UHt@(˫gHnr\UPKj$uľ/Hc8gsܢL{~z)m2VR!dK ]JA)W--&ӪjkruϿ8v UD v\-ZlY.:eJ6Xl+ +ܣʑD;cW\hHӱ#WpO/ldXI<;|cYdpA*ZDcͪ#AtGr!hTI:Wl;I;sNLJجD 3>t*vn \,?VJT/^s;9؟e^ǜyu?7}L5g.NsHt@Wx~QÑo?-r ; 1XE]5յՋrDȇQ|dKt@9 X0X=/Ƨ o2N]v_ldBHt@u*IjW-_3Е# ZYNM%:: ЍbPc_ %U-_g_惁kdk@U\BuN%Ǘ,_%Rɲ$v>a=^w8r4,'R^ HF2ʨ6сe .U.O׹.ע5/$)wDb_ntUy/Pr^oϓ9 'IU5޺*͜8.oX8Ht@gWb%^6^$W/>/#?tKbxItL*`0=>i=Ժzec:_p>h f/Z60[n9^-n_y;#Ht"l9ZGe;j֒Ɇչy+ Aݴ0$twDFJ\ynuո(* "nt6U%'kZ6, 2b_d`9}xp^rU@#ӔO &gv8`blvwFn'wd;:1Nu +xXgϥ~F^ފkjP0w2VWY0+c{m"-Ÿfk;|!rv]"Qp A2>uGMADq taCJ[F\@N%ƍ/HP>8/'Co'wv*yX+^Tyw6y8rթ6rCDɱ6:cl3oF׌t6q;vT;{5dދ-8xg'1*&n"p!H.FV2PP3z (B47E>Qaw^ٰaوg8qD yUC7n6?VoYAF܋cmmnK_rJN̈*=4#ks/Iߺu-""1iJ[L~a ?]3RN#f]A~Lu""|e:0)#f@}X3B+V#|sKE׋eKQA±?yNk:`Ppm\l|尨i3RN:Pߡ11#]? ͫ/}j#p?'Ʋo!DȗK-$_q>fp F.R}aD0C<6vfTwtrDqj7q:}O,]u\:}u3^[+0SNő2QfV+=1Z)ώoX%LŪvJOo@]M<^='˪x4,NڪXRIsrEq/Ϟ?T1mF̔PDX!RQGR-;B6}B @UPn]o?5`u 3(AS)?wB ڟǥeϹu ä^Ff3\Y:v;  _v.[s0{?DI ̨bRl>mZl鲷:qo*;kC3u1nK+8޿LpEaF2o{we?>U_~[%@83%WV"O4G[z AmFoNoER,Utַcm2J-}:'wޜ"C`cME!.o_|૦巿o|돟 +s{e?pו'Zf3x}kdܥ8I=ޙp2˖I:VqM;p fw*RhF QT! ZRy䂖Kī?SA9+Ĝ8su K~C}(܌=h}Ąf+{/]Fo~/$㑇ceqUHQҟ+ҁݲaAF"?:J\e (zy`ʷC, ;/Pᇟ{5>XSK/SHGsX·|029`F%?IqIKG^*q9ukDW|ҁdG294s`(fC=_# [-[pq—Q8# ocTN;^elߩ,_YV<w|@I c3]۷t?yW8T'r%G$-K}SGDG`߇À" ̨G5{aFQ9F6T?!z_'9ojm.?~}9;} 15 3"}Q3怕?J w̟8u{gZYcmJ+)s6"˼@ {O*OE_'<=8x/ :;ԯ_pK#ϱ+ċ8^ч|֫fcا0(hrfCQeAyo /ȄcZs#+]jo{V>csL D(M0h Syq,m7FJ8"8|C _5y5oH12Wcӧ"n": 3=0hRysOYQ7r q\߈ye+٩o bx_o^WU=<_[:Vu#Gr;=jܾN_ 4 @؃̌0(eagGACP~xEr_J_u 4r-0/ްe LȗڧB ~r8D7ftpꤸoNhj} UTI'W:}aȪgwC!}{oq)ymweVqe㿦^yovtqzS2޿f⦣ǏnR%GfFHboŒ:JQ! ǡRյata kEġC3Y5̰{SQ>r3p=XN \{?Jwxܵ]ΎwF>G;od Cx&ayWʇ|C3B=Ƿ 9@c43vʅ6,x]/1u fĦuJ ly fR} oF5_.FZ8^y@A8k6]4vkxiSa>C~Kf45 Gx3B=¿Yjͯ `S8̧ &-c&=~tRDqWiJbcޛѤ΄#]==y:qRRdBlpp0!SOy ) s>P܃ Ό/ / B ,p#Ns=N' PUƙ*9aSPGzǠ܂. GR}# Wv̿imc큏wD_TDG㘸! iFۛQpԇ7#R8\ I82B G ,BY-eOSۗOILJ1JY 9AfrgF =q *Q]miV*SPձ0±8֋N(UnouFyj*&vf,L˄7wWnouv=vKyD0;>N;HFyk FQ}whJ8ǵ}5{ࢗA9~{x/o>,wm_LKYqG#G^Yp~"ü&ダ$X8:0#\$ xg &ɲ*}:Te KaEcVՠ7~\D+u|:n' p >z*!?ƚخ 4#c،<5ܷq{Dvmן)t Ì1x7/Np&78lYrdG7P*Gt_{|2UjbY 0#_[ڌ3:̨r<  `"C<&8);3zWCxܧO06drt$${80ޛjbR8~4q7!Tt{,a 1t$LDRfkaF1-/<~?W].k15"?DRf3_@\8yX㝵9+$*옅fGlp@ᐍ8 ǿ+>T!6 3BU$! QnD#G;9AG #7b[8^R*IVWw-9Y9!"mK`Ì`Fp&UO>`plƕ"B njŞpq<,vc6ErB nj}eFu0Xo*o8F\hgɯOՕ"8`F_"UU{]4r3歈0hۂ©wǷPs 9~uٮMdP{F%tQ>2#1=l\_pdǫaq^w{° f3ÌbY;9{Up ,ԌϊQ0#%%qxg<߆[ةpMpbȧ4i<21sf-0T%qHוr&<p,3"UElfYx'(vi晙x|8-܃n _{e0ŒbA ppWP;yٙcG_32H`ȅqBq*q]h{iGИf>e>泀whctؠ0XHQ33Y_9\8Uv~uiuC^?AA3445B*GcDHir3`m{1p8J\8q날$g522-̨wfTlF Qz 8bd17Q x }V){ MF5+G}!P[ RJ8ﶛc Oz7X7qQ_ #00Œb{rq928*T3CyhŧI=qz|4f.8BUr3G3s %`rЛu^OE8݌FD)PoT!H3qu /0#!0&hmr'iq"!~ʳt r;ޱq]On6Ng J3fTe8 3l(8dȿK9B&9Ww"q/M;h}Wj`:SxCF'%>;3 Pz0A?=#bUU(#Dq }:Xzr7|s!^5_l#lF 2f4دiCG:K8-- {#Pwf$ 1 Sfԑz`$hgq/ enȹr9ɡ޽Sַo;u`2f Ѳ_u~#=yB 'w:DcȨ8Qt+ :F'B( И74:`s,8 UeU3k33'yAqKx!eʰa鴠G"\oBG3j:t&!AcvoɒߕUWUvW4lgpxB;pX4pwߤ@ٮ33g~:f{)Qe Tcf_`re \BW2O8yٮ q8gEroD1ZES㟽Ԋˍ寜Q \ez(e6?5sC^)WEw{U ac)_}Np U}p1 "阅 e4p 1\ŋO)1jUA@ͮR$( &!!iqqc}4)ixbC[7.Ǝ%f}9>!!tGRG OJ||LQ R{qUABUqOBUmܸP ފJ>:FC=D8dRb w͡1" *z#HPJafXӷnSw_zmSUCMT(̘h4*CMPL,\WCQ8QIVՒ1#}k=҅f7b -ޘfP( ḉ*\ࣇU=?aaVk=2fJ/g͚CJF(`0Cf=5kVF \-g%IJzMw UHUnK (z֭26"Yl65=)U(A?MtcVf<Gp_Fk  :ݮ7nh# >-jXz!wPN :Þ}Ҳ'} eVkZ ?\_{E*\`Up %cƌ KFѪ2T7W')C}V1ݠS9?zk:YKp:"dh!Crb7 ctV,}{(֭c )J$+"~ȉ?'4T1aU]U($&1 C#e UD.H!*qCGO7Vx fl " fD8l۶mSmY۶JWUo~۬Yc|ȐRHAno`{l'ƍl\Lt 0:R8,Bj۶z Hןm+}UmM6+ݒR(bx"O'xP  W|(,˘5+͠T0Ju+ K J']2J!yvu0ߡR'aIN5k|Z>9YJ6thb.`*\Z8엖!T&'oMߺ$3ҷ٘'Y)B}`_xX$fٌFHL6s (}THpUR%$TFިPTN8d>M!ChC& aؾݚ^Uj֌ 2j;pv᪴a5fvQCdHK$CP )P}dDP,;cV[u@wA8pU/Ip3L\o|A_!jɠHKI"#+;SRh;}v~F'%(`PJ ]`*d↾0dx `H*¡|"e_TeJIaCT8pRY+7|)2`8'%`Z jֱc\_}KUtRa:4g"S}zU y_ D`MOcJ#-e b ݇>}'q>1񥾽:!jFҌiUU65˘1dH\@? m: {?'\ y"^#99#Mop77 DCWem|_nuU^P+1xR%'[3VVhg?2<^T @ Di~?  II>9iHLc+[͖n/, Y\8 <{zsV3JeTҨQ'1d?BywS|{Z8^o Cum6"D8HϷ UU) }{lݳ?=pT͚h"&eԥgΞ9OyP|PpS>9t ?9NS*S(ҶGle -+`ܷ38D0I߻wXYmONjtۧAI X1N (Lۻj߂}pٶض'øo/g읟8dxJ0"} M"n3Q''N~4EBp>Ɂ,tㆎK:z4P$ Q)麍`qQ f褤I*OQ~ͦ͝={?}2d(:7O~E\}1@A9 !DO)8J a0$ q*nljy/yA8x%cFR2CGA^o Xc!''$Lɓ'wߝ8TdĩSQ?z8]Kzh><^|ϝ9`Ȕ_2y2  S(0|*P6gt?FcɂlL5"Ig$I=8Y)S ?sQE&Sp/NxI"L?p@_@d?S^jk?͛$s_~o Ǔ1ʷ__G~0{w7^OJJH`Ç?w7mwg%`YҴBb`,%Zd:ᣏX Vjdi.EA&â()kfV;Q/XJKdx`"<<? ~ٸe P7[EMS7Öpd򡰄ï{s!i޹(mԹl.ujɹ嵞C8 ͔dU8Q}SlF=hF[8Lstjh@>E %ڬ7Fs1;[.뻪#g*˸PXV7ꐑdrP`<%.ks9Zv]1BEQGG%1Z9u\w>'3FrΠ6uGӱf#$x"b;EwSj^tu꬛fZp*t9\$Zk$#m3O 2 Jew!dZ J%A4F]ڨVU}Λ,vlZSd]~lj -џYCÿץ'?y|).{% -CdR(\p4ʊeݭf4͝PenFѼi:㒎eӊ&{p˷#y~pvlO {UUss|47:5=՜VPh;uw|7QVL9~QrX, ;G/t 8,g ܞp|ٔJ(Z j4N]}>UCquɽp$z&:XrxdUnNysr<-ʕ|T*ׯȢ/je]7_ԝv5%) Y{us o*#5ח 5U۳λlv7&qáfNFj<xaFwJeaR9UdFk^4EٵjVrjo(HG'co8a{17leyjj@5%nӜp7V(Z%HG _9|pgE›Fc2}Ùc@;]oWan]5?Y㱓G/1@pw Ǣ@(@/V&Tu4?_YxH>&A۶H6NDD oMԽc4Wm6U]X(O&&4')6dX<O$!o<m\xҥ_</xUOfܧŷ!7[VX~d$J8T4rٔJ"=lOr8_e 鴢, 㲄W'twaK7.?OsCFFdB8ĶMzr(.$v-ݵ$Vj3O{zHDUc>_Lֶۑx<{  ;ҹf T0bZ&߹UUm |OUzz2sSnSx:0W"K8d\١R)Nëu9S2-+-DLsmQ::f{ݖ eb\MUGrH0TMs],a^\K.yI8 Cn!u1煣c\p^8 'u7io>8޳" `VpaI8`LQ^p[U2V4`ѓ?L~@W/*cv2 #5F#Ða8~GZ20ឈJUcjdUQj=woaT:7>ǯ]g?v޽!Idt\ʋa5dsñd0()0b>'zzuh%vFZvM~+3↣wm{H dA8`eph$Kp=qs< "{Pg[rJUxnE۲[h4ɸSֱNg˅4]ƣ"ppJ{±X#N8VW^2ao#'lV[eCnj3r\mmnK8Vɿ.W& 8P8,O(d,ۦW*N'#KK|Pu:Ӵ#VVN7rlZN:v$.S/ꦹ2N!d#/7G7~pK$4m{ߕczW8tZ:+X^yhJT,\ڔ%3 SpA8Bovp,6e9Oh#3h"]6Ýܰx7U^xɩ33ŏo}{p8s1u mڲ}HfK(gòC7ˊzވT7}Giqp+p{xӭl^./, ==++w²e3i<\6Z-7"kx7^EɍV=w> e|~xpO`;/ᐷpLzؐ[QrK*-P%Lq9% Ν;Cr9y WL8l3ƽَbA&eaN6FLt7 魸Gé)y0tTӺ9LN8t\u'3†^!zQ&eaYeyLz{jzkncO3!k90}F!߫V5mbqCzJnWLB5{Qj##M2:lϪ76_sQh'qU[Ucsjd'؛W |M8b~chHVVu1a;{aw:wph^ra\-ĵl{M'cL2"6up0V5Lټ̡csssi++ٜD SS~&M]fsG T渻ZLdWu]..;_=l?#xiFg:x]pcv(lU M[㊢G٩\.~ jw3昪ɶ^1Cq] hX̶o|<'V*\X[vیFmR5m&[AOQު*]9^ʣ8h8/3$d̠>0;^4ms3=7jF^DoĿMJ8גt,=p+g`@Uf?Ub4~9 LV{Xo;4oսƺ%[%n2_ƃ<= hiiqqn8p|~#mommZ0i5мOF;LVaMy;>ea8x =YdÑRi 4ue#~>d Oo|?Dev[F&N6w~aC>s5bqq; d2RV8'F"C ^{pSnQ=T_e_#cs04SRIOm`m]7{Sb6spX{W\|{B=tgd8~ps&8H82ɨc+CcZW ފLv 7ɅKW]7'5Vj_}r;f6+w^pp7fRao'FTulmؖVٸw/S' 9ڵ`P"'9$ E㢼Xʲ sBx㆜jcNzoeYN8 E;q+׮]˫3MliU_"p䩾`(v*fg?\Xoֶ ]^ ^|ڙ3rktٝXMx:.MSP$\cqvzzw89,{u+kN8ֆy3_@Q^K"p2J)͔ _[k-Xy:ܶM9*6}76$; wAOʆeݼI8‘qPM>tT*|>'}Iem#po6u98p_#uEQbqb"N$V{ڸU7nT'eU]h77o6p2)Lj|x>讴VNI^ %pb1=c> stream x   & (*, (2'$7(10+&6-07('&9&>779*):-3K,,W6I.M+![-MI+OkGWTO3F9O!K+2gwno3n&5YLQEV)VP.NR8jgNq Lklk-Qq3gVGPJ MK*PN q djj_ZNQieo^**1LzDh5FSΪ +@jP+  *3Kjȑ̷ęɐûɼб޺дܡYendstream endobj 35 0 obj << /Type /Annot /Border[0]/H/I/C[1 0 0] /Rect [749.0261 10.4147 756.9963 24.3624] /Subtype /Link /A << /S /GoTo /D (UNDEFINED) >> >> endobj 32 0 obj << /Font << /F19 14 0 R /F20 17 0 R >> /XObject << /Im3 31 0 R >> /ProcSet [ /PDF /Text /ImageC /ImageI ] >> endobj 40 0 obj << /Length 782 /Filter /FlateDecode >> stream xڥVKo0 Whb][5إi$Xbn~d'NP("$slj4D{K6q0*HRI2+pDw>dmђsIxqʥv$g{#pKrwR\'A++LUq2O :,M~"BPi7V\ȼ *ܶiU2|}*H3x"R8`Dk#]zA,4iUfbJ8𾪹6J!ZL1mwQ@:*΅8oNΞBmys傱/ҹ2 jȧ $:y *ƼT[;lt$CwɁv* vLZE윎l35.iRrT@Q#[+AhЮ"N2Ӻ ˲D tx GX({!:7܅ڡkDqѸy@fa\>cUc8!lӪDd9gpKj0{n;{;sܑFuH-R:hM$\٬y=EMh*~' #oK wH3fZp^Jȹ{okN" Pd8]82N,`4 -[\*n ʲ aM+]L'xqkqO11 ;̘ui_3iZxs+>MV+ߐendstream endobj 39 0 obj << /Type /Page /Contents 40 0 R /Resources 38 0 R /MediaBox [0 0 792 612] /Parent 22 0 R /Annots [ 41 0 R ] >> endobj 37 0 obj << /Type /XObject /Subtype /Image /Width 1593 /Height 612 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 255 42 0 R] /Length 41716 /Filter /FlateDecode >> stream x{PShFvwH lN= 3^ݔvHݝR%PV;cկ*8s󣺽S*ZGJ@MVg=ϳJHW[ ڕg=dq:GhИW0(tD, GBcF^C!h,*vŨ+&/yt12E &3U` Gsn(FNs[SmNW*LTP8L "(2(j*E-#V ܁T&SAJ3bakj}T{z3< heS0X sv0:f,Fo*U.J!V>:n$1V*K19((ʨ19L D'xkg3*Rsfnln!5VAtdC⽣`' ƨh=G#<Ҁt}  Oڔ)Yتn+=7#hmVv@`ljV`"BRVTOLFPeolm.0 dbS$AyэArnS~pf cTۚ#4:p o [yMMEU)!18>a%o%:ޠ^ӤnkE3X2lNqfjLSyP}𩸌C% ̠ZHIQ&T&o +3>/& ; ŘTt9AbmEL.ДWG!C#! }Wqw|]Md&020Jp;Zm,w}CGqT#zQٹ7Y8Npļpxzq.n8Xj}k7b$?_ճ֊G/56ԁy9H@[xKR:np𗻇ޮc}wgνׯ_MFznaD%}8NJ _oԪׯ@4:u9G [Z6K}3 /4[˽ݜ/ĸXh}C~M$H(M"rΡ5=zțݼ${ /}p|X&o]K%w(Abo "FNU'*Ș5Ox@8$ D7:p "b8zM'ԊAbdo[v-lqtr }nO ?p#j |"G (X=љP .?n8{I  nۋ7P`bu (Ir;{-_lgCwpE`_#;p G \ΐ%R97_ա:8 QC\88_٘ǡI`u|ƍQpļp q1}C!fF=XO?4"1;wRso=pHB8z}}(Ch ׼`cQ3C5 U__W7$+l uMx! pNtܻwt;Ge};p=whppHC8N*TGq X8y)Wu7n)ͺeρsM vx/͛Ȼk׮ٰZw8޽Æ: kwc=~$uFA8b\8㽎Iu|M~8Ww/w} Ukp[C͛pHD86ʱors4Alg:u(j:z~$H,IUĄ~޾!M?q|MnyO t;HzwHC7kqIO%dƍ'K`ůd?yv=><ļoppEpc?UGﰺeˑv:o:::ĆY lnMb͖}5o-ʀOVr. L!;;3pڗ_X?[XdjcuhX^ȬqF8HtIx-qN` [ٙz/@y ڐ&UVjjWk4T!` I{s"> {z]ޭ;6]}f߇rჃn!ݽ_D?CD8HֻGR8zz hdofd>`g^fiudzFԌl(k TCfy\ı(>9^?# w}7 w$ccPx[/y0AZ־Ld"EK5SS5Ð:ߐnX{5AbėDR'§ hwzQKpu @#Y#1URjhj MŢ-LTX*lWɳSd 2I I]n0df.|os_7R%z:n`VoC;5c7t8qwU1ʭ Ka5zRNN)t*Z˜ d#Գ)iՙ`%K#ޘVûu8qck< 6I_WWc}]V7팬p .C{'z`QlI8կ-5/j8_~{jzmV[7+9ELUtzMrZʼHmM4;y|lrptuݸАqq' cL]~Ψrx"+'gt/wsG/,u&`w3A(_T#Ц.nF}K5(ϊy%^S*5?uJ:y>/t\DiR7_w9؄(R+:a_7ȍ8TwwᎬp X!,m"XFnF}I3/Sc_0 =Sq=L+5<[X]r$+JMbIZC˝E8FʣD8p2u×D5B#&:&ǚYUAb#k@:5ոboĊtl@jTYj&uyjuneiD.gaRLVTjȵ(c߸-u~vW6qGU+.6CG.A#/+G?ȯR5W۾PH-]RR:R2;YV"CUϴ P8܎!?{xR}9.> ɸ1{0 N8U@EPؠS3QSkt~m`"ޭjCǙwklZJT(A.JC}ӏuls֮};֭ǭF;w~<'o;6Ls·.Dzpt{R5TNWʪKS1 IZ̏G 32}TJmRk5jfzq|̈b ~?/aD?|t)b!P_oGߨVCpW[`4 Ͻ5pwFitm64EբV dneUVسY d^*J%2~j?X86zY&@/&c_w|T/{ނV%L+Z5U|_ZjoUVҍ/Oxw{ 2[<֪R Uch֠#vFU06SՌT.Y; 6;%("ׅ>*o"Es>a֥B@k4!svK9<#tOiZj-Yw e63,ZU*ŢRt5O@oQR ;QbcNW )#ΞB~+ɼj+!?P銵xc M3ۯ~..jF=?u~W "~yʫt*u_yk`OTKQ,QaQ)uu)*mt^TtTfy^n"15WHG@Lf ]asđ4j3xWHE7ԛohHR7X2x8Lbw4ϛ?_851>Vȁ*h"VFkNeryR:*vc,ڔT/2 ɧf-f"Bh 1 #'$yh&Z?ᐊn V4S<@\qpғvuIB4hKWjsc6#LK=@,9j[5i72F:TY:R83 %mSV5ř Yݏ-*(u%RաZ_ِn7S<@&{iw:mRġZC<砢=9Dz$Kl_+m-cOZqRGU*u嫳3f1C( |k(BX: >`28N srQ we%WQg;{F=hZh9Q*ۣJb2vM0RRi*h {VЬņ噙n^ $faTEqT(jv8%7UȮ,.N? vb,Q9Y;J*]L\Xկ֫5竿IlxZZfWu5$]5]G5VBjm{ju,hrCzz*Whjs¼)TTށLJ.G1A8 x{!#p։QF5d4Ad'`dd?ŊTc̍y8#(ͰlRf--Vۧ=H2)UַPq2 b!N! 2"O/6 ?ҮlyFǞՁUd G^.e*dbscT7ÙjFaar:8v}}+׀er4rIUkh+`'tw#Cw'b3sZP>z2X {djf~KI㾝<&{B^Cރve0"Q$g&. Fd2Kr=KsC:96o@pZ ^,IEvYgQnm{SmQ4NK0 udXh18` %gge].91VRm<=ye)UzjC Rc}(ӟ[9b]:45FSU EN.lNx7v (j7ZJ (tjQU͍f Lk]r l&)qetUYպ nw Ϩ !R+'JVaLqHf,.2 }U[ۄ2:=!6[vkς&0j;UkZ\gH5xF~B&[C?po?Sn.ZZBC0o<׊N Y$ȴ9hŷOhi(qZIjt;*P+Qq (|{n=#bt*yB$fwm-X >Rm-.-h]*^++=!:hیpGjp5V ƴ"b,^T<#TώYE5Z݋PX;H[u6iBr>ĕ~n yk ,1#2+X@#Zs MFXqItkt4 jmmۖKnᠹqGN @v\/b[(UYPj)e:NG/Fb$4aG^}Vdq!Vkq?dCC첮،px.uC|L8)6z sAZ`2qrQ`lgop܎oyZ͛ܰ K 9#5ܰZˬ#RIIǑVUNgLeXl\0Q 7.e p, k XBa**Sof5d±sT=@S^-p#])lGtzhBSbK-rRĹI"KuWZF \ban-)k0Lr4#Cjjdn\A Vsȑ !O*RHoB6M?$Pݖqq梉 jڰsl֪RKME_rʪYng*yy~ӁnEΒ$bݐ RJ8$ǿ̶}+?&@fǤ WRRt%ٙ2Yz 4 A떓Jl~Iਗ਼(A8(6telH\m6k<xəqq2M2OcF 6^6a#8{$rzW7d`D(0q(otoFSnS٠ sڸ5FLkYO9l(GĹ~?lUZU#cӠHOW$ZةX=w#+j=',rsjr.e7K}uc),qG0R08P|#7돗TKۊ0.,&PCtۍ m(jgKL9*;۵hl#A`rn!41HRt"HX؛8UJlΐ=(hHLZ]q 6YbXIK_:3@(8׀TXB1Qem͋DE\.TCd4 ۾ WP$-pr8 ztJuzeRzZ%x1WaO@:6;`E`䙈*\堚LM 1&Rq=ko?p)PrYBJBD,22w-f]YXi,{PFd:CFzzu71FPd<;|\B ]!ұ@@gnmn~nFQrAQ ̹ <`/ q ;#'HB!HB[9c .Qt'&̦y,srQ\LnROMrͿVW? dƒnb÷LvQ5`p*܆L3ۘg62\+XkrnD2r=N /R_{BØJ[n\GU*e;JRmݏvPmRF]uڃz1?b|n$@:Ɓ% Q6՜_XTZ|AeU6JMU[TJER Ԣ"FX9Pê UMJuupywEmZ[:3=6=½%*F"ϯB`o3_g?5Lڶ0ch8}u74zUREtG\H ϛ.5DG,X2aVUU12SSªcF&ҏRU?vue|?nvɅ%wKc[ko"DGǯ  lSejr~Z_.UA&4DNO'[t(VZ2s`mqK2FZ-meum2V3ĭ]^[xYQSCNdc݌.[ CPgl\M.Y5NaV@UEDqT4{*nBeT=\|Tpißu6M["1-/0צFS~+5~hmonώ#~weSKKUʅqaQlt7[nѪ[RZYYR*-U~Z[Ն5՟i3W>mذƈU~+`A{Sj&0i%]80^iP[*F8Tx?גwRUZYfQ*+**S*-6ԍMw ŋzmvz6622tԫIk0ednPmy&1Wr͔9V.Da" ZwtUV>VWoxc>RHUvc թFjgg_'\On+-S` C0v́sy~Thhz1S\82SjʢTTVK%q z@RmF5pGGC+J 8N %{O!,s"Iqr rdn䛶L>9] RjJT&2&Gzր G̱jY~Φt*Q\b*@?u&F_0Ե+uyqU8(ʠ֪%֪*k9qWyѻ2O.nmz`wMժ*陙XY>:qI뒵__{+(EF^2(hkj ꁺ57y^W>.mNfOOeCE|Ue(j߂i{{wU}2gWܷjJuZmVG "񏬛> O8r#t0u5# H=큄cAѲ#\ Q?.1pkתg/֪jRZT-\Þ׉p@/tVܖiZ}UG'%&&]∟qAo 8N**z!'?oGuUi^Vg.WQf_NUQYΜk"]΃*=yjf۝lՕ̟̌ D'/nq\aǾ_ǿ! |8.ٱ !eKRZsW!oE V(D4Iۃ֫SsCճժ2k~OFNּL5eVV^6(v1nt~ /}/G*E<sCmӹmJ9hF8 |ҢظzVWeֲ5{jjjQj^ěmN[$/Ujm6Ie?p<21"l4XSGdrt8|8B0̉3|!q鸼zd3W#`QVVd֟ՖQ:NSW|RV4g̼ٸݜAlW}MBD*P@TcSBnsnA@‰ uY̟S@1/яjU֦KQ9e*%EP9xg YYT wA=|7[E :t_8:!&@a œ7ͅ 'NUtZ&ES)u1Q=LG<1E\2܆F BT hrXXhns7:tL/G_h5jx5XSkz}jP/ͫ{+S!O++O}:5P8Z!r@8nbm_b#C_7ddN_Vk4ũW,`AzFzFᢅA;"X& Fpz$o WќPGǙ[+zEo(>53ӐYjJLHGn$w 2C {Χç\<'Z։Cp`v9i]H&7W+iD f֏Oz Gz^mm=14IQيث3`uO5}b&F$G167khtLߍ&''mvf|)柬I[17z(SG1US {/"YA?ݠpT8eT`t<ɱk"3 NyQH@ SvpX^Xh[R|)xmVHܘi sD"18j[[qN2q|0TtDw~ۇd|6mfbT9 L&74FMtb08{9EŽV%f6&^)gdlq&1GqE{+t:ygcP$j)*Smyqyє<[TW88buS`Cg,\d,\2L9MR6HFGl:R.z` pN|TWhFRւBS޿^LQj4gr,@_P&3//OZWq{j)!+Ԩ E5X3dUQ%D#~!, 1o"5 s(a]63D*,*~F#Z ef$r#I̥vT@'&Me,rHEy+OႨ^30pȥZDsެ*";=xhi] :ꃴp16'S2F|UxSO#*:{<;݇o\sc+Ṟͪj} o3c %Na0&@tsUrY&S:VU|Uf66N@]3(@kcG⨇D2Y0G6 5W7 tsLm՞3#䵨Ox %`t|gap|[.%-n2 3P>LG݌~06 .)l$9yge6=Et4GuJtǚ`![^?#ύ"pՙ-Fpδmt<7E}M8$@Sb0H)`Vh;ca^>7n/*yF>}C{Ø6rQȊ`!C)Lx;v(.ꚹ+:)O}%,ؠO8 K`9$I1u5hox@404lCDJR zr_8@ 90\ӍepQkN0V=90̈́t$A?7@9NAʠJ'H~@q%hnM04c8i"xc*XtzW;-6pӳmLH#P\χ~1c#`x47I0N 5%V!rp )Q O=f&{F.K@2hodxE}4|xnz &: r  `pH L"4{޺K_hSHᰏL3]--gyXMb'E3˲ x9 >1JVV8e${$*h# |F1/p>|Xxs#p̢1{O WCMJ2 {ZoN8#V8-u2H##5#b&-A-?#71ZRbRe8Qڒ-# #g CXUu-[]-;N%wQKpBNƀdPK0Sed'ˈvdu:{ {#8}&뿡{hijP8>?J:-%W2IoHbQ'^/@^Fb2b['v;Y[]Ǚ qb'(7k?yڑ\$V;7X$ W,M\5e$2bXQ$ ;\#`'2b y{MDOeN-9e+qzŗPX2Lk;'q {߶"{cN1fL ?e_-a("ANe⍩ WʗhZǰ xn\:4ߙ=#>E~هN8`<㏴Kca!άe$:5+F3^\FT9 E-9!11þ&{LU@:_jRsžDT`nHx)^e,#XR}tѿߎv"p a(Q㪂\)`1,jkhVy|l0F3c{'( I\xVC?ˉ?^8D 0v-# GN9sHx-476~m#O~s]CMzvtL?ZƁXʓaaeV Z uf876q,B?jX@:n瀽e\C91a8D{8Q2ꆣ+/ՇnN>7VI>}:,!oU/6j 0: ~}Z81bї[7;;w=-"%%%8[_5yLQӞK^/,=62ubspUNpxSF2r2\ŅCUUR1~dz?zpTLoɔ3׻>u4UsKuWm1™KDCPx>d{"XDu19}[.˅ 㷌 *Fl^W$o`Q"frG/ɾN8S@OS>g|#8g$#pcg[x3QNO/&pJ>ٿU(>BSOHSq*b`Mbn6T? F8; 1srn ahS'y}#ZF`6xlgtOr,4'E^`cpJqINx?4\+22zQ scX@#_)>b1gnMr;߇S̈p8[W()sV2)~ E#qh@"N Uq ZC|6S~$VeDw6XF $[@ƳΊR6q]ZE S¬Htz,E--ٽ^*NW">/ )3ϼӬ?VbcМ.qVǏp'ȧˆ6PKz gCx{?pv 2cv@9%WaM$[(&7꣏GYp!b!߇ Ia3Ȝmpaz$,PPǛ$8q6 2.}w:߮uleSb%ă,ڲE-K Bcp hxI7֭ T#~ƿ$퇃=#qb5qpc t8xgq49J>'!\BUWC8Ed]ra[Fas 2M/V '3YU[ƱCgu1i욄.Ux ,F'x[8xWct%"ARU$/<061*v!##8 v'n!tʝT98υS AXFe;1q$j b,==nI6|hhP@SlXF_FrXF1*[u?p% #ܴ*nNEEoZ&?a YOEێ@.#YDE "Xoٸ'p$z+˽#x_s² F"GlHx^%03ZDxk`Śpxp`>d 0: !H|xȄwZ&~evjgbP\ioϪzYo*a|^+rȯ3A6L|c +.k* JL|9e͑iUj2Ņ9W8ιͪGOQm܆"0qx;asp.4&sEUלg![78>W8Ĵc'r<|r ́g<קģs9; yL,"u]F~b#0\psAbYڱ/‘$8㗌gsA ufH!졖*žjj^#2wB6T$9xiL/K;x*-XgxsTP;2#2WTs9"3b鸌r$5$DŽ0LT8^mGa`7%2)h7#bm`&S2jB.#FX  .2b2Ÿ8+ٸqo 8Ӫ☼o\&z(Dx;˖L1#clˈoWp8sxUuWuQq"x:E|T`oSFѹłp.E(lWCLZC88^ 0#op%;>37Zx 1c,#>q8EO^+4%H<7 <8i:э#0T1t\>(&?*\ Q±MEşL8W]LCg_uH`_Gǡ@{zEٻK3>-sھH&J.,A 䜡:2{52Wt;.Y{dgso8A!=ϕe s"d: ҍAE6"2j,#'-*2W:.& AѴ*`*a[X)46"mMW4 5-#u>WÔ9.'C8Ec%MƎpB ! 1E 71ƗP[ӖQCF âJpƦ\|},ae$5鸟xK %^U iExB?l &dΈeҰXFOȳ|$M;IvBRL[FG쓻`$\US?,A!Y1c#q# k.o_AMXFqe,xg>?VXӊ0ޮLg]U|∛wF8;D!'t2D? Ĕ-#qHUu@a_$@`]cz t1@c,#$:߉A\Uة;#n&zT2O!C- G8B6bU 9YgMֵϕ8f\~H89 G@!t#,gGj ʶf2je@8>"*.Jq!keDdP{<3i&iaIi)F8J`S =>Pd( aIARYp8 *~!_juaM|AGupOЏǽ1 Q2qe1׊2ԍ}Bp(qXF;_#o:n\L(%sko3yG7фd`*d,W=O>y8ΓOFlIsa$/#Hĩ3@68LLt Ǩ #O=1Ћ"Lυ#<3;ehF 'gΜXЪ3@.nr_idds"w*,#\e’A)v `<SOk 1# hdppg2J8=+ɱg  W 8c~'ꧾ'rؕ~ dT2 /`k:]۞zA4q@矖' iXFm4Hr< |C"2^7:> `qL ΁9BbW/cVXFMc$š@FqL8|cs"2j*:H 8|Z {,ව36~ &KMb2bu$()XF3~MoC_գW@O OSa> -BB\NJLLi;g,#,]_W_X qz rm4c.e;sN_JxXFuW<q&p|| ,Q"nK!;爝ﲹ`[0Hl}2Ls.,mqp=eeq||,YrDT;/A@S²1$GưZƸ\p%.埰qt\( M, ¢%;:yO9iڜ` x>))12XJϗ}aL(ob,p|9 8-Qn>.dUM-da"бPۍ߷lBH6e<@Wgq!P8e#;l*ǧ޹<6zeNJ]F߉bO' ,>S,~˖WG;C ǂ矆ANS G[%ab\Fpu!M<vj,D818dqZQb6Ih@j8SMvߐ_\qP. /90Iyh^ bqq2iArJcj|ꍎcq:A.H]' /=K?eHl0yF4'q@Yp ϭꄣ> >G#A/'  q-Gp% ;(ގ#3‡K6Vzinӫ C`|ڮ~{Y1ėCk%feA* \0ƺ/áy [B(J- v @lj>mWyį9WA7ಸy!Rh.HpZAX)_㳳wpp.*F3jV,` PH\mD5uzy9蛕)禽,+MpWEѹB|2?rxset:J 2yUC3m/э}= 6 ӫ?#5&6UQx,i F 0۳~#KJJwxneiY&]͈gVDJ8ddc'>s #֏vX?~ j<*\0]\3lmKNY SW |&U1Ȅp Dlh xzM;C ⮂bXPLp 7"p6bk@8*"\Hce5@ƌb^+phILvժ\H㩧z~UhWpЫdP LC.}y"iJ*fp2n^aS~]a5@]=\;y'2\ >' [>B&HIHTzP$˟o<&0Ai+ŀQkp'@NˑU|AS nh YI:2$nI7̯n~#$OnbؒI}LD|@ * i`AU?7@$*{4{ôDG bP{P550j$ s{dXzRNt5;ݻjPmVnIӟ쎐i>kI 08ccIl@'NKB?=sx} OqQ*xp[y#rURgF Rx3Uxy̼Y+جrUF~Ro<2㫋p ^2/ 72G48xyUi;qiU/0 D#o^/ 7|-|GgTWH=E#o v<~^vn:o<_̮zQ/ 7<]udBnƎ|#P]u|dDn:<c A$qF!1 7ZA6{EQZf̀oU\o0pP]]}n70p>040PuF)QTi2UUUU DEDؖUOTcL E ^non6`{btZu;osγ-WiIJ,7W77?$q\$qTsz%%_]3qܹѐUbHqAgW82&gmU&m N4:4Pf'SEUmh`hƮ.9Sm婓R+Ḓ򒲜GcFƎk u;q CqO*uoIن9n5z2GjJ^Gy9y#+q0J[s춎;2CTvm9t3m6UvwSGԡPtco쫶V+D}el!KpcHLs7p(@qHuCT9z6qg4^4xAAm~OMЩHEͦ34Pͺq +B<Vbj d O.W^V_Qe5U gF1Y)(2j4& I rA>{ꁁA[_s@3c|[W"[rq(*NMFcRa4(8AR5r+&U@_uv#Wqb2k>E,s`vŜ4Js盜L.AnwANHϪid Jee@ 2gvяp e#<5irEpϮ*=$Y ^>}=L8Ik ꢭY*{adϟuw?M%Q|^ٳ+Pܾ*'PYk2UWW_h":~Kݻ[Ǝ^ TjUŨ2qLy6쯸RKU<48l4]7 @IwsAWh(,<ᘚzLE܁c\j:sqmv۫Ly2ú7~q'9w.ePF@NjmdPzg8ӖN'/9UdVURvYTfXF*OJ  G x盙8([2%֪ VM^Js&kzڌ{گ#A=A;8|޹˿d!T>\A m&k !AcG­(+ꍧM&)*jn7oB*+t4p41?(4j((nҾAm8 8ı)^QEEq83[EIqp6&&>ཾ,h#q  v|#qijc\0OiJI/%&Ȣ&S_e4X!5LӴÓQ819[[W[e8~cX"Ǝ^NLj'~7G6?.4#/O M>b_d`g~ec o8(#rHr Kw盬##33R`4Iq 7Ggp!qAq*TJNPzaЀn ]dHUC%mmmFP&ǩ@`+b<>?33^71QSc,.oLz}0q'{ ]x2&_MM$ ᦦ ݻ9^~r O[XH~⚿V`( `P(&|>oAAlg9bbaA\Q%SSt'h#;7<]Nxi Uww#|nNr"9EBRE8XX+.P<8HLfí웈)jo9  8.3,555g- g- ة97^PPR'G 1OSZYJ򡐘Yo155;[siK>}3A~Bxcj*; xk,5ٳ&pT44 %Lv"Q|5gUPONR>-1<\_+u:8ʁ#Z@>{szrrfaXͅRݭʿ{s]Z!N=70Ukim8w}Ԕg`P9^ZIY{H|8ҵZ"f^<8v/VffLIqx< ?KC76z<&&ky-bj3RkV99廂V7|KyJ0y.O2r@C(渳1CrFwy>R΍یǷ(ġ"qXL:h%kR799>W8y|,ż ⸛80vLb]9@yI~wN{ &'gfޑw #ص{[Rxn؎#MԔ-7L sr׿:Au>*!߿VªH& Q^@␐1Mg&ƈLy %e$87B{tVSNy#3r\HX|RӗUjG:wyYF3<,FqJiwna~1TnNގ<mC8aaaKamRLAG<~AHUƎd*]!YΏ _2i7%cn޺(!ܹ]BrUj " -Uq^੯c86"A6#k8@nQV8ژ ^^**ǗZs|w*9E7!yXlh(.~a?xR8lF8 8@#6qCNO{AHffn^QvCaa1CRWCZZY!^մ{A-UjB"aiU55w/I~q#hxLp` J%CN˳KHW)8a7UPL*U$1ce𕗗' a<_:уc_)ԧAg4&&'' UT{H' rXtԸNMO/Ǟ:Rݹ#  ~qLNMTP!p$ U,?.>;hX@T;//x7o!<WAod?o>"y>/„-t?oPTHs4QR,BUK%g_kxaA Ņ%%%%ef=~'ő6~??G1v$91hb☘ǰ,MVk%Cd`HI 6R>ik],{V+ÞZ-((``(/)+/AQs9.~q41YGb@툒8ziogi8>yG<֭R6\ޙMn! HQnii18F8;_Ocq4rI Alsd4Mo3qL}^l=#C8wU*AӰZkk.XXX=*,g``xFQ(8NW_VSw~ViT$;⡞I@%.^ZmDySw7Oܝ\4څ#:c2lHS  =gZQ/WoL,8,=Kx#+$8@%kMeMLw}OO!&YPdBYűډY/p&8a#0CH&A<7*jx.,-UQ V9qca 3V6*UjAU*ijf7oң3Ps_``W) VWS#H\l rq vyˋDb;W?%-{z"9p~#f7Hpl|1qW<: $vX5j4j;hvݲ6 qEwk\DFb$ %3.#űţ*sCxovvfJp噸  FOb\.F9A(U(hd'~SIccc-)|lnc,/-iQ(ꐵqPrUW.Ê$GvqL0qHސ1pAmM]io  (p*eyRȼ" $2q2qD_i{c@q$r]}! sx\ nJyoO}$i ' ͛ T+i# r_kiXkJHIk<㡐ZE dRSҾ_.6H}^/VTIzCq唗Imаy1zKup$FN`]ZM$q#m J' mLx<j4Yt;pǥ%ۈ $I$~~ pBe=S+m/ǸhQ~?%V#XR>PA)qʄhDZV^yuccSP2"[*틯A_"xhZ' YS#7Km6i*qZm$&>p\\%DrpU* c\`O%rv7A77&r|["M$@ؗ6q%b Y7_S\l8Q#{H7~u|O3GReH;%ÿrh~4̰m3&-9/..8jݑZYm9FYS[DRI Mx< ZZ8pւRxCZ`pc{PPs{g5>8p8ueouL3Srcr|S\܉-mG[۵[Jyyݭ--Z[ZI,ne<حjrz*-.ֲ~)cgf8n'hU)u/-c0E,C#?.br"-TZ}lF73;UVMK=䳳EJR*&kȎ, Ad!%SOY/pv/WHUdo6t:h, ׌E4'8 &n|nnnvISL^ovPRj["\ÌYrEKRD"#!v.Xd8WSr@XTġW)EhjMa ǝ,Žޙ9ߧ7<<EiJnB>b0AoxBrZ-MɕJqYa-^/vPt>!eM'NHk Etd4z?0wC`pR]í[C]]^J8, ,y1?#QIń(nB)8\9[E&/Y"+"eoxMJ<)LEU?8`ް1qF-d%ǷBN ^G˴ZAqySs PQ17Vk@BmBfkh|~?|*NqRcwc>io q&r}8`yT}f  l6[@[USXb3U<B[Q o8ZYgGm b|絆81η g0q|x7ސrC޽㎵! ~  LL6{@@8Btdew8=o;7p@98ϝ;33oA)ڐl#ЂoG~SmgRD/oxCIR,_J6!*&諾ah1W{>tgW*2dj:RJ(;r[`q8l$E*֟:]mzoF+X*g7fӧYgp6@_CIP ^%DS&OGJ:}|qNYXh0X/> F"8ZA(M&^L&Խa*:}fk}dOͿ{]Y5[` !qgdHSNom_-A`Us?CQ0Oޛ%W0Fڳoj_n2S*  [☞kpUo,/8}L4ӊ&i{?QQ/n6x)9~{6c|d!4Whv;:_knn.!kj¢8nHsm9%qpBMşn`(y1N/M41N#iYCSP\P`8(VDYLћ|_0-zuU_N[,:ԥXϱ۝wHggusRsii~J㤹W7aW$]{zp'@։CSGL8:Wa'qX,fI=,#i nJWHH\g6 >=Y)^bq9α`EqOd9\J8C:qNtH:cpW:;;Y}}\p@=_էzrA:'\%?sP8"E4X!stt\B0\dw:7B=! 8z{i$Qʂ y撺z9*b LՕJ\.3u\N+vs{~zt%n]Lܣw5XAFE"_E<*#}dډD_= ޏ"xn}_V|c;.WLcõ0ta0Hdrna @F/ áp0%?#&ޅ$h8oo=&y< W%u0W<`7 W_wFsZbhtr[$}:ǞG4)#>\[ EVy#dxi"g6EYuqbMxrX4"r:!F(M(,O$hh]bq" ץ* 9֗Cҙ/DV`Ίc#xrGX E!_? mEF֖C#MYk`.B8G0Wű1z6ۑ8V.ф Y]kI~l~_X7Vc=2!%KiXcL Xq]TuRt9UUCqr5|Jd7=_>qIz3UغWX`Tu;Ç`zAG`yʗ%KG`Mq,Zroy9Uӵ!2*q^%qKeferėŦnK.K$cuJ!ctmj6@j8_>rK]  F.\.Gwxm_z捎JBCOOhsefQpuSf)DRP3itt\g';QwoםV1 DEKuyzc΅#@NR??hO8FG5@Ncs--?udqtE 9+?HnocL Ύ)-M8,G=n";`16vĪJ,svF<.j2%#%b_&Co54zE'p/ܮKqY$qtחdX,.C==]]kfepx}](q`Z GYSH8J ;r㰈9 orVr#$Di@',X{n\RGr XL \{%r/xB*rw+N3WiqԆ !T'VVUuǗvt, bpȣeo-IgwbCL`ps|5g!{+хb*@nc]CLDkkrcSc.Wi]40GٹRs4EɈ# {{\]Bׯ8:NևC+rqHz(.97>*ϩJY.9lS8(M}r,5/~w!&ieg}l5DH䬪]smyqRE!iHgu 3>IW%+seF{EUX$ E/zz,{7w\ ۻưFx%1?Jq|"e;#Papݒ8srCΏK7DVGoõѨq0KTh nđ&dr"~.GW!W.ة@qė]\`G7,!G4"&≭x},py@s(8iz0<zRσ_}kk,>baBGڗKbпX_q]:nt Q|[Y,r Zʻ'ܣtJر#+l 8X܎;\NKS`}vE"h;'=a|v1a1/F"?r:/2ViCak.ag +:6CB_clyrgrYZc7"D4}CNp|&Ee-IL:u;J0qDإL(/ɰc1vLx qiz`(57:oȻ}w Q67C.9Wr-74F{Xޒ"K:vQb0 >g#+wz9nU_{6Cb&$>Tjq^> 9jbG\f,6N漝_Ƿr88niJ畿HƐI~9Lnm%!ŭxlR/y,x3E؎EÑe1x}]"_<8;"_#{繷I# xqO݋缾ߍ;Zb|7XAPDl;y{c!GZ:(ފ%hgt,z?r496z@83rc!H%ǣA؉b[[8DeYA19s* c[bbq$*U^`p}p(~clӗVd'8<` ;^=HRv1,wqIr7y}IS=ev;iKõ ǷGY˜Fv% '%*xB [Ů8G)>[︞Ua =uXfۙB:,5wu{{zn\r\r;\.ᨣ7ڣLK\]ufs_9EO]s}mQn  %C)l6_ gG]ٓ(Q䨫r]bц~`\zڧnCI/9LO ͧiǞy&(t7 UwuI`Jݹf3 Fz/H=rhUKS,s{i!8iTsD/ZkKKV^F8!wull\{nV?10>z1kjG7qwD/{;jw1@#;(p-'8, D}-=+U e88~'بT^[Z iC. 08z>;%ݒ\Cf1;;1ᛞ)Jmkqc#!$jyŊiC`sm㏥Y/PP 9HEts3L>1=-VI(T*Ut7G$L&qpoȩ%QG6W86rBiB)`(ŪAxdr.bLU(Ξxop*j* m8ĖCѩ2_ U.CT hA e yLM8LJ PS!h BW\ə8 E)qީ7UWA{uc촜 9s zoxܙ1mI3V"D PPq R:%)xPR /-'( 7> stream x  & (*- '2'7'11+&32 ('&9&>449-3K,,W\ 6J-M)>k>x$],NG+OkGWTN2F8J,4gwno3n&4UHR.RR8jmMjlk.Rp4hQI MK> >> endobj 38 0 obj << /Font << /F19 14 0 R /F20 17 0 R >> /XObject << /Im4 37 0 R >> /ProcSet [ /PDF /Text /ImageC /ImageI ] >> endobj 46 0 obj << /Length 821 /Filter /FlateDecode >> stream xVKo0 Whb][5إi$Xbǿiٱ&N ER$}$N8ho)ӆ<.#F C)O_FYcNt-)+kO'\jGRF7WDwL uTk% Fw,#-©VnoP2T1lŰBJ%MuZjjwv>>rO=)es5̑.yN DUxȓut1$QVRggx[\j 혦M\` S!%5۾Ӯ D^ \ؗҹ.ZRIzu*G W$ƼT-UL齽lt$;-v* Ytb(V~W9[c9=N7iR|AӁ"'n]Bv WaY`X9h>@ eu]à @ΉӝviU *~~"ϳmLUe,gq>^R5 w"7sܑVeHgM=ᡖeQ Ap1pRmUE6 1r;Ͷ TT}& #s~8cG> 0#a8'Ow:  vWn]h<8M{/*HozE}ca(q -j+$}̫=QgM#]nԐLʧч Z_p4!ΌWP+ 8\ݞQmoxjgƊcnW[mNmFendstream endobj 45 0 obj << /Type /Page /Contents 46 0 R /Resources 44 0 R /MediaBox [0 0 792 612] /Parent 22 0 R /Annots [ 47 0 R ] >> endobj 43 0 obj << /Type /XObject /Subtype /Image /Width 1593 /Height 612 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 255 48 0 R] /Length 54166 /Filter /FlateDecode >> stream x}Py/ 2]JbNuQt:xЙTj ԩYqJ[uD}X=zuVjV(-!>3Ŏ⤎ʑ׻c;y~/!0*}%ݝ}}$LY0(=z,—EIدxyN$_1P!1 /E57j&HUl.TV7NI ^kt8*h)LcDҍOhFS^>D1&VIq¨0ag,ղZe5F ݼcHwĄ RIA6" ` ! x sxzWh&d!xuE-.EobFÚY٬a+̚ XpEA!vf{ މ/#m}L\+X\ZO<}}b^>ڂh{YSdWY>Ҏ+ .> `onƀq]<DdaBSxEca25NZMN{8"=-f)4h.Oz|cqrd ܯBe<ٛ$Cg" ۧ*ěW\QUv)cXsS<Ϩ*)ΧM,X ѢaN 7Msp4hJYl44":WL aR"CѰХqx3`P;|QAs^9\;b}N5kka,5k,6&(SAޞ+}ht/u,1%WL˰wAh*6mhODEeBDtjVSj :wxcX9ZSJ%z +>`7vWzb;M46)QdII!Z~88xd.`N8^7jX;*NEHbJ})z[L)Ykxu1r0JcTi/`;c;epؿ \L5U578H8#p{Fɇm՘5A)I}v`ѴH Ab!b)Y'0@rPl>%)׉`qB{J s8'*8-h~":G\U0١aioDD{?G֭DT쐐bU T@Zdn"_6#| ͋C@.yau ,*b(~?%kS G?w 4Piy-ͻyZ@ q^( ۍX>e0i)A:T4Q D^ f:9+bhnnr/&weI XSw;yN!}1ʳj}>ӥ8=P& ;*N%`B(@{Kı'\$*` y4WB9āfkjhĊ+z@Dppz>}j5;$w<+háa8$IA ~HI YZ\'{{4ks06׎s@6 !_&`0@ưt[me٬`ZSm%åNr[myYII)EINW+/ռ1 \iZue[o-WKJ.`WY;-{Uoof3Wi&0[#G9`Fn#sDNCFP\4o'n h0A=Z(+-)́kʭ{luuf4t[U[6_) @:!=t1{½˥Ec^i?v`%|jIND܀I Ju2K`iDZ;\ ] pzJ'x bT!n ^km-`BEmġ0jzɄ?ӵx VPn@t8^ e_(]$`8jjQcQWW8jNˠ*vBި5L[Uov|Ur%%FJm^.וZXo֨$A&;d\nU@V W'r&X[F#π 6q@"봂^`_j:A~N@j+dk*PX"KWg )Z^ZCW^o-+Gza*ۍE\ dt7/JWvGSW1wTFl8 iG"QNѩѹ!!G#ē139GG'AtxT*@HM"HݿGbZ}Bۀߝ,&# zT&p_qyO,. }xcrTL@2ud2dh 0RBthf" x#ı>c}uY8v@$ qZ c2 G'F~jr2d%pq@@Jx}D? X<[#)ۏIce5e2EoGPs=ǿuzׇ?ZwEÚLB+/~|mnGcx!2q7<:9G|(j 0cb FdG1<#20@0prr΍&p\,++yƢ?JRt`%~wG& UJvqTg ~1!E\ջ/ 7"1D bx=B0qźKáS$$2C01Dp0G+a @0BSXRRSfԷ 9ad"A*ߺv˖oĿ3A7h-߂Y5'LA{e☙qxLI_ >J9;;7 xRbzz!"9{Q[ChScx \GC{!LDf`_t[ ⸩tV`9qK@Ԗo+/&4ѹw˶ M/ X8ΊW&ON|+c8dA td\ loLN,7HWlۆ O}SǷb"),,|8~Jm)jnNM#x=+OiDXH BwQgNx%YZ>C pl?l#F?m8e!jA6Lq`2QVizzy(o ǖQG.lX.Xd3 }zPEnσ8l$`%8|}3 q`KNoKc RvY@=CO b"XjGz8jjJKL=uuNxQ$bbDQEF2qOB$1ޖoC'’m؏G]$U+Mq'1`G*RmfE[>{4kи%耟 =z"!%i{i|SomWׯLJKK"]SW[Gd+j48~* !SUdۅ@_D"$Pb-7'#q^!@O%o#HtTUgc\+JRo>Nwv*⋮NH;?O?%r|wiA^ PKOI8ʡsi% rm/JJtxԕԃ<]GХ-~8_8>=qX*~ΊLJHH˄l6]aMUZa"{ Xi☖}@{1q|Z;< ŮAe|%^rkI@hFzp^L uypiɾPz8ҠBıEqٲeKii$dºm'C8%xlxڦ"X|ZZ^dxNUv9+ %5Rq Xa(t6 V-0cNoXd^ǧ*V}Qu08r޽;P ְiBKDCwN% 8ZȊ#>)&GQәBz>.~Hpw8ƾ3P%֭rRpxs]YaD(ı\+Mq\S缜7%Go?C7*_x;8>S8c泔PG.]>{4#nu7vI"AD\X7.18=pi tIa2yy8l _MFQSطlzǶ/d( q9e*N" .:"Z8]kUKL⨪-%+! ǽ53$"l>LIPbux8!Ϡj%sɽaRe=R&ŠzGšR%!D#8Bcc` G%IqQoaTr3$7X|"0q'Ke؃yKAāx: p؆otA-WCC~1y|c\oѥ~˽{E"AT,Q=F}Vep~v6tXw`7^P*H6VmdV>K[іrt N`Q_G&Y?F8ӧ҈K])E| ߧ*~l  C q, DG0?ܜ\['NLNL zTh*$m# qHJºN(=#Pdۭ`m >U{"AP*OqfXnqm8V8,$`X+%K]"J*G3=裝5P\BKDZ*_ HF#Y( }@w_vKR4)Gl? ⹺ HrBÃ-[P p|j Vɚyj Xi@q9 8CxΏN?~JA7\ a&7R$l99!W흘4B+55Ga}Idlrr*l"k`ñ!o'GyL:ئ`hp T7ԕQSSȢU 8@|..p\& ISr4TǙ)JU*q`TG9\g1qUw"U8 tuxcc tIT㼰pZ> 3G]Gyj;8 ެݪNgx082<SmJٺ-(1$q0q9p߿w'55ۙ ;|=tr<W~R4$=ܶ\.REYl!'FUn&0c̙3N0wx4"*%tQp3 {N87GHhd88g U3۰OG'A# kg(|')yFo/~?gyr@?Ý5:~RqMqrjq˄7T9*U.rb*{Ơ2s'r`(k0$ $ʞ 8]Ƞ8!XauGl"{Q)\0dwJg>@@NDP2Κy!+5$-+qL߾tqgEB!O/U/K Ořp ܃zlE`pn:I O /aEU dd,ufϞ 5[p@Tp$>99$QU+M8.v+8-QW qT握9«?9S:b?/-'q饲kw[LXuBԹ]Aī4/G|p_"˖8f,ZW}\Lm, j^q_i _ fH +&&3oQ6re1,w  ~aU(8/KQT>Uϲ,aͬFSa+;ދ XϷyV% W9;y'^{QV.i9úx}]%\_P J׊fG.ƈ OoeX*23"^NAϒB}29:y䆼.(u$=͙X (&)",bal/ ̾ e -"⨐niM<߻w7 y^I,Q>Y;;$=+hSR]Bڀ!Fcc 2IFT9% B4|25:5 6XLިG=U3>#⵫a9-Q@\N'Qj|^1[̰@XFsxn0?сj]+/`TՀLwilËlxjv^\J60--+q,{4>p¹Kg%X@vƉA)FpF1I#Dx$EpQ(9k0o?qrJT)<&at/+`O-C{D ۧEQ`7sG*L cKk{wfBJ#37^aLYLG2o|T ; s)Dc~^[U5YR Z[Sc~Q~HGQC0:GW[Qk,YV++сm7l֭:Y}M-jm Pک8THFvpLq˰wMZς0y%ŔY9,~G/4xl8q{iB4q˾qŀ^ێc &0ESQ^v9"luVhҕmZ[᯵exAFf *Q+)Ӂռ~-VuhwCFnrGr1!h  nfQIHbe(ZAt=TY|³>ER:|ssz3_qH/x`5ad^J߆pKڝBe%jԀ@C!r;=9tp4f͟ FRayyJe83h'W*mEhy_Kv nFdGSŔ&"hۧfʼnc!| TjSp1Tk<[#<3/3S<tuwZ@j|\PXah|.?"X"0!>J34h[ODF#V1dS?MsN]Zcp:y?E5vC)wIӴppQZ$|׳8l5Q‡`~Qp~][iӡ nrKi(%G/eu I7} )r]nW(qא?TId"ƱУh,Wѣ^ )FcXoF w3\$$\wGȕM$kG$zy ɋj> 'K  4辎v;f{<"8$J d(ʩAz\q8va6@ՋN(Mv)g6^MJdf:weyL[&fHVbns"=ruChHZL:-Ɲ4~rQ_4^0f)-D=u=D.lc1,+Ohk+XgxNK~tNTHOUűuk~UT^qwKM c9ʯWɱ8qjG5VI 587|=q!' #$YAv@q8!qxecQ8#8_bwէ#Wk1e T!)>(5i.\Ѩ)ج)`7k$?%GˉTu㚈s7]4P M 73-c"DRmM?hׇjevdݹ I&ӳ1^o&*\8"&4bG;):I ĩcan_srNT?7qx5@Cp8)] q䩶?{w(wG*k}y}ʿ'RƯG5|䋆 KPĎv1/;͎f;͎nhs}M}l7wty{{8 ^3:c^Zg`p=$Sx* +MO|17{e}Vy?Ӛ.mYq .#~<E<HfzQ' d cM 7{)nר!})&x/&o{|^_{KG11c8C8qF[ ޮC' u6@7;ze޸E p4āa{6?-(:vN`Q\hڲa] c/~0o8EK!&C 1P;7(7]hׂɕT•'҄yPfC_2ݶ p}"&Ȃ9g,HQs]JOqFBJLBìVajavǎTjƪT)Et`LY+ Cc 0ȳ;N|=) ]b  \.oy|j&Q@3~\b@B6Ҙʾk~9^bvjPx7ɗhye;$:1tZM<ZHuiQ%T:EQc@v\{{ѣ`pjvKM4sl2 ("G,DTr vUUz=ǎ 6eҀ˂,E#|ߋ?=񀫤ՅX9/ǁ_ 9}huO"<-&lX5_/t'FTT:EG 8h =p(`sCruVnu:>gxVsXs F E7LnbD6N +GLz38eOpJ73 aiF? рG9%͉֊  䜀=hp!Ѵ^DN3 }ȁ.PF1ENͷGٵ;qlbZv3xbɆ6/{橠'ێ"ȫ@9ƽҹj[#Bw]KeU*= xNi;z}N]^UeF=,EU9\2C2%&"uÑ-]qA\X_mj5DSCmմ@4b(`ȩDZ@cj 5aQӵk`4*Ji + @f̕SA% +BxCx7oӻ"Nk{mᰦk JU9 9v=ubuF敌8_ @w73QpGaa܊S85 aW4GOS?H[K!IxMA\AFhh*YS`Lhn Ux*I薿Z䈓y9œ%X~"bd1R 1Qג48o<:"dCy%w+ j('JJ0{),U.ܱD&up L xݵs-J$Y3uF8E:0A~ӷ,|O`\>ǏX8|n8>EVʥQy:w T1dNpE;.f)J ƸqrPΚ5Fc4^ q ?8S] (oHZK.Dr0B;w$`SLnUTLz t-DyYC"H&y#'8@|f;ɁCNe(^3'kQVu͟ LD\%F0@t{Zq>76c羅XmTX&!uU׏cG,Su)-jWI!V;kB^yrš1 My)O5#nF(v]6m4u;oWnGf[E)lY4Nߢڍ)js2Ti\zJ>::Sp߆۲x(\RuٸA~rr,h5k6[PPG{izY>5v5/NB?iZscSԅcN?n2OÎ♃XGN:k$8q\zN\~qc AH JSQAVUlRz(~ w:)H@c2ď`G]{p`eCF KT_ϝZEhJ44ZR1uVi'@hTa8M9)>|Ysƪv| &2װrfy;U ^7W\It&|T{煤@a5T܂q,zkщ(;N 139l<0wgǍiQ9wh4SrӠ!(XTDx|QleB=,B{7R(Щosv>'7)4fB@BNn VKd~ц9VMZpyoݜ~R8MM\8ski0`dZKe~HQJB`>QR?ջ4MӫnXѨё$G_u/xuTkt:EjQ@Z 1uf)lxgY3daJ q;͓$? ^Gc+T.@#*p1 ݈rs vxnbү-ƾE)NY_ ƞU,4n;7m6X?_Uq|.! )\ ТTKeԣh)kăD.#3)OMD1kB|juԲg \YE T$©c> H6U-TH)!],\YEx޶͏gIdXN+z џ"Q5U*io >9 I:qlZeHq4]*9`{Ydb|hA >Fyht=/usɁ5ENl{}̭B˹9f(Ho:AMb*WPaɁZ5 {#?p)&෤l&B$>gҙɺrR` zTsxH&ۗ("|ʢCbEq]'8'EǜNXQ?_K¡XL 2'Щmn''Own}*ZK^J<5),w}{BH"Hų!˵뻀ptXw%i^bX/ɱ/2'塺͉4'a4 Jú!O!9vhFbwN1%~Ffr6bpvN$o} Y4Mr2 !< nu9Q.P"0 QcQ1ˋTs$oCy1))櫒9)~#+.7'_5#ء!> *;.)*{;p,XD8ZI +ύrYpah…6@KJ>7X{S.g9.gP,pyU(8T+)>KE.S9ݗ :a萭VW5(1ߞ;wpY?I/zWk츞K 턝oE0x\5 ?kL֫᡹# JBbUV#~5 x, O#X1m=rTʌ^>= ۤ&!>8qW ?\>x 'GΌ4ێv%p=*<=s7@xd\V'O SUJZ$ J ɽP?3&X0#<ui{dr`8 Yz(FM]_BI_߿nι$r qlQ5S..W h؇rX+ ?&O\0 oa[Ҙ9Kb"X7/4؋v9 ,6:՝*8A)C˪8b5=*ƹrYu4;!p;r\IP<lʫ)>P a& A R $=Pver[%1p@G ]*܋Dv&rIF 0>dX֘\w-kU8UP>UPND.KD\P~[\}imQU9&$hRV5Pγz#7rȝTՋt)'A[*G, sD s؁@,cE|`qss4G k].\;npuo\\qrV+7`OD69*K%W.͏+ӺX^!s!x-`JY}{@ȁU۴)ŒpqC/73hXW)>/FO{͓L\qȍ;9B`jS)EAJqCMYZ]([,8sbV[\nov o@T3JC!+fqx6S(T7PFynҕjkWj6Orɔ{SV.ICB- 1iU:M%2jǃ`TvGކFR0)0Q WvgTjj|ea4Cs*T}ve\ a䕘N_%*N(TCl|$JUxV'3i!Df*ǚNw@_CJ!I!J|^CUI; =x\,ɕEn.r J T9^| )ñ/ i A{ rQNBKt*AeXƕ8AZs'ヒer9p/Uˑs6d*oؐO*yOv'GSU pQN|n2P jVV!X*B3ëH)'R9zJ;."syݑ q4* 8o]yI9y%)(sJ. 9?IAyV/ܠ _ܼMl$lBR.S ؀ K2d/#ҙ',|Bq@Cqxk(N(-#4. 0ueq]nPKsz|jzd#>D?ߓ)O9?y np ho0M]FǞ2/NR9ubrJ޳xi: Xd1.h R\6:mSp[۠}Tjm;*%$!"1! ɿ#^0筬Ʃ*Rސ@AZ*<)XԽR:zz.6ȍgR\]XŘQ˺H@WЭ V'C;e^/M~g?6'{/!t˲x34W'.QOn& B"#,0S{D+] MiyR,ӯ-Dù*0gUL;oxh?P[)^ry[4yڼjGZ*'~D,8NU+Y*e<0T":ޱvNMRސ`aL1֒odE*ǝr!K`@ feC2kZ c0ES^?yGQxF{'̦)^ejeXT0?ݚCL[5c" 22w0ǺH帓Qfz96餱vN"(S -aa(O4MNhaxdQ4$$jhv<-(}nQr^^# 1IeΌ)WX0ʞjr475t±pDn}қr?G44EC[ӟ|1.o RN/KByx.E!uURO`aգVGZh/FDE`J{9אz{Y: ^|)!] 5u] |x鈪@_OwCyG&+@58pR s]Ek^Z !U~;"#|b 4/z}O)ɄMq=u=N{!x7TJP+45L٣Kdq  QP&G͊an(BvwէsNEo? HT气@Vl()o=gBYstp|PK8^ӳoi޸˺bw ]z*4Za8@CmvJ~1c 1uv}{~D4'"/(Yސo6B1TMzr^߽t]K!쨸A4|sN3Czy~Bo9`~XuǪy-m2URL67X պ}wgh޸MNšOp1xhZhosi0F3As ivˍN'iI8:ľ2<};|| ƠSRE⊣_>y߮Q-R= |jkف.dc >qWJ_^Vf/8b= aL>]c#RL$&1ÔVaìojZۼZ)g[W\tz+,o$arJ:hrP+EU;bZ9::(g⣳O00|>K\;_SpQӬ!IOSK; fDzoYl e|ĚO87AxQ!7s~&F=[T^/t[=n,r>5Kw&r/ q6RGG?U?;EnK5T̿7|+TpRk8'CP zҬU#hC l^0,0,٬V z 1q֭'^D^3XRpE,qd7a6Ո_ k<#X- ](j)i~h9CDh4 ga LdC^K|Qe:v>D2㚾%đZɌ,~ܹsdjƛZO8}/_1 0Нj:<O!!G ya@|a0YӃQ@FCހ}8ӷD 0G/W_{@VcznPbOqkf.n4+Ў#5[U-U11 ksXhH[$os6<ǪcUv*3$w7.+N\D T!T:6lo?\.W9g%Rc=br77Ш!qJ\ƆdV;x`>ZM ǔwo^Q{P/V ZV#!2Z[x*!{s,wW2x8܆}nzWO Y-XIcGK`" b!" -KxbUU*:)oB̐\GT;MP`46==/&[49.Y54v5.C2>xlz ܍FdՓё WƻrěIt#V 0]8 d/^}ˇ糉2?+2ODD_J#wdt\%#k58HO8k_Fx׋~Հ+_b4zL<͙5=rۆP.3~2o;:⤼a6< YXަtdpLF`Մd\ܿ\1`0U*i2g81utd7̝#ֻrjHsؼ_1T "Ga]8 =$] ͎!_L ;:Vsl\+u I5qמN$o44-H&ɆnBBZ&ޛG7qyU%646i[&Ď;d484\nB\ۧϋImsg ݦL/`lazNHfb |$UJlKVU/O>Z@PzXf՚AXĕҧrEA˯:Yq[e20+'$?)[861smڼ#'^YANNy1BTtHuՐ o ږ㩅Dį9P~rl*r@ғҧrLͨ_U}^~_S͛6oۻ8yY~9D+: $ rЃ7U'ֿ>0/^f+;Yp}9Fd~ 8'ۻ~A7+ܼyA voټ,84\dtિK^Q@(nMo\KzW@W^lWbr}YxJyYX $e4p@ҐJ9}S{F)U5k|oHXlzmB)G\CQJ0$S)S~/Uؚ@HYTk7 c yrG@RJ9F8[7mx^Cn{{ 9"q62xcGg`tL3y:O1ܛBrަ~U; z/,n!nc0t+0hf͸ w3G{f~K * t%RQ{PG9?D[۪.޺Pc@ҕK9gszTו/j3M*򰇪/h t%RaE"!MlhW&M}uDO\K|淯me.8~zsܬPt*ho u3>z1XJ)&9}MzeϿͷbz^׿ @%UO]G)o /1†&K׶{4ocӯ7v՛HZWA{C9jwQ/Iړ3WWɫ#D%bmڴGTmՉD;q<',Ȼ/_[>a*9r nWTlz F^}mkQK4Ah+ e G`qW), ~۫Wn[~ބq tx5X#UR˥/T.aZW  eBp x}R>|xo.aAtPͨVJ6j-sCiw>.o#B76 דk0(է_CAh8 w\2`y\j[y_pXHAI/F6a6qO2;MǾ^ {#SrFg{o7ߺhEGo$ۈ T8ډaDKQq#bWǮ]slh#dkO0pXYiDL7 l#H ݽv. \v]>OsR޽z>jj`dr=ED&R_>;Ò0V Є5)ˍD6ҙq{].5| wg]ukC)#u t'$#*o0a!(.]Nwo#,5r0Mw7nݠ_ߺskY} L#.pXJXa6Uڠ_#6zAfk.A_,UL:4 CumH`yom6*N}\ {mη$mmgӫ5&sV~ݾ%A#1S#L:"Vlj& =Womm;@e|ч?o^Y[GYk[p;4IZy,DE#FB,m#"X Q7}N65 q$u-}ԡN) U4&\3JT6X6rx3Bʙ8a'z k&]  -fK3s^SSD:nlL9}f6aQP!Xbe 6lom=7š*k:qƍ/"ã6>8MEȦDwNgg@cDFo#ue/7nKL &j %S88(*Y0qRrBX9:sUہ$BhCP6jѹl#EJO]Uer8{!_B#~gk 41_N;AžP6|pnC6ujA5BueĆ-w./g֟@ϴGG*-[6*, .kjB'sH1r9`*fEQHXPꏯ&|߀@\ЄҮ*2/Ox[X>i%r«F`W^<+@|8,*ޘ6jkQCr{6]襭34G.`X/?g ȰA.Ehsh`8lbq#6҃J^j6z|H-4}8*̍i@PQ,jaiL憂5m6KF.hXxq㆗O`ky]U>2*௒nҚZ\ϱC> kmHH{+&+zdz6518wzu$$LvNMKrD^Pۨue$s(T[7,NF/i=t.paO= )`={Ы={={kUpT_B_p(kᰰ`id_?!&ja˙S<jUZgtƷ5 ]91C0VroV6"^T[;H3l./: 8}gz<|N`tW%a&ؐɗ moJDSo#tuNxϜ]b+$ g:uj3N4}ूm4mnKSәۅضw~ொYA6Im4;n`rP"6* Ƿ-{߰(7Ncy}kl^7P[,զ;aB7 Y658 WD}XcۿmC{+o8PtĿW r¬Q`%| <+G/Ws*ލ]֓|8,5FQEmm4 ,C1f/KI_$Go.]׽ʂPǜҤNF FGebn.ޫ,I~E?$8.v^cOP U65 pXͥNёW m`H l# _֬7pW*G19ϋw߶E8WS̉ ڤ{EFFiy//'x07|^WA;|Un%ƫ,:yzs1nR3e5FӉ}8m0$ՠ?N8So}htld!J>GœlX( 8ϦĀo9} 7YypU 9>e~[u@Jt7:qQZk'6>e.@:tpY j*y?}~0/{?acrUNwU;w2Kd5r 8WPJdӰWH>nTƅ8 s67oAXuz&7`>Gt' My(h h B۽09h}va6?n3W`SL1&>G!nm;cItB#,B16"v_FÂzB,^t_Qvфw^f,`㣽v +ؼ\73>8$ǐXIԔhz"VH6"PƱ]U*nv(QԸ*0t"L#K:3]M'˨@Z|qQ۴9QpP15`Qo QB;1mI֔jdMrICXQAkɆQFVuQ$}&Nt\iU UQaӪ&WŌ4~J`q0P!㴘էQܼ^79( 3-4\mbXeJt3G06WC3לܩŁȡ{y{NᢩE ~;~\faNUmʄ„ۨ̿ʌ$%T 0" LLe3a0>2w"I9 |#'bhkC熸mzam\{@2BUtdf3_%vSB??ՄdI[Dmjmܑ\b)*G 0H9༞cEe\8%^Jo>H\?ք^Bϵ6n %V❁mQ`ἎbFԇ]U8ġ̎=D 誂:#Xn%3'H"'u HHp䴇FÕj]YmTp /#62:I63)~Vգ:yӧwyb)nq>Q4EopIM>6GbCtOవF# 8༖1yJ4Qŵ=vD3 6ZTdqS3wcɉ1qF;I6j W)aQO(^ʛB"<ʶHx8|2F:Q: HYU6:R|QOIRfUafaN?`W.9ݏ ^|2#aY> H6 Cn#7v\Dߜtә3γ rw4/t8p"()V\Ax(mD U6; Ã#6WÜ*:.4Qo:8M>_YX0mq9eڸ`vah1/} ~MdZn#p5q1L ]8)n[0mTFq[Ǔm|Eښ$DF8ۮl6q_¹WpUkw(cu>ΦL5h;C× @N[Z RHmSl]36j=y21rN֝<()k,55'K!ZUSmb>mI 4b$?{sAKȜ0mՃQ.;!/;6 3@/2H9md&mr< =TӑwB=W|}ð1$"olA5\5zԟURux1Q˽a㉡C1F0l\9 ;㠳q N_E'croa};Ou(sy+m>l>}-\UgBn&=Ԗ˰1 %g{ʗLPHm6)A.p0[c݇^ON}衢h=} cQS>z'<4E< Hl#(8hfqU5p< g2`)#<vT1; 0FpA[\eZvo㷪RQpl`3!px) 9rD6wr&=5mf#,tqlq8W]mP_Yg'Ǫ88;txT'9ΦжFk[7nn5F="[T ivtBqFiqWǡRD~ppL)]Usw/`?}s]VcE~  ƨsMFxӴqv@%DlpUTar EU%%!ɓ>ovڌ>WCqt;7) -bS~C);gM#>w~/m+~#FilQXiqcdz+6/|KlY{cq q\C~K&46܍62D[TߩG]%#a~\-5B+HutMOM}r 7]I8僣C߈cǶ۷J4RCZhÞj[CäUH.VA{hF LS>SO '?Yoo~AhΜoql[E --^\GCj[yy#)+}n(o'tYON})G=e?z&ApL9ݪoo[HcoHZw>ޗg33RwN,\lChi#F>h2PO> fI/3ۢb_i*+_(ijp 3AatSR!Om8#pU!tۢXK# #,pܹCv(ՑijpBt~UÇ߾U%hwpϒ` +wt%KJOV[zd_L @AxB UpAYTB!UwZf pL[IO1G w|,\cz` hBƂ\japAHS SSY3#)ԧn4@+Rw␅+К `L)_Hctq0C6M&s_ X(w\%~m,m _suXGSO=_w?4dz`1I J! Ony(퓥MP (e# )44.H#0 \ )^38& J9 qKazS[gҳ4]U8+ ZY;] ,(argW"'zp5,$#~P47~ஒޝCzbQd#77URz%(px@258b쪢IU>s"τH$yMsߕUQ\{ )d>%cN@u(νpq{ h2͵Vnz$Zۻ8xtͪ.X_p͘3oQ%61$WV-&$d9 BsuՎW.G 4JR37TBpW͉" wN)B8, KŚz_;>tȮz%ѫh$A7C#CypWIqJfC .8z]g'CwU,,\\qIK\O GEP 8klX>H V)pSo Z>ab9➅fpWஊ͂1na5111-kϸdbbpWESfe"LBpceL,xuˏzS~q 宂쪘vJ+g"@]K8iOkb5qR wU|?mG W=I7C!d$N.pDr4@3Qe}] rHԥ qKLKbY\ſ Uˡ[DdbP*( }F]`uwY_ǭ%*Ij=6tu &ZǮcnӀ"]]}IndX 2?)k]O=whd vDΪ#.C*&[u-쪨pH/QVǾ{C MH:Q*$Z zaZmNO6L:vdJWYEJn.2YUUUU]]RQQa@R!V)cpK6RxNe8y|E`pH1xV?,R !r?a K3 f ":"W.]\a|/82rB_n5rH 宊,d"I)sq QRLGIeg2IHT`Z3gdh)4Tqz.WtjMSsk``$.тY]kI3L$a23N'FE[{:>p#n%CY\A\59x|]MȭC#黫^ǒժyVkEEffEfM92ы39qp B#Y]RYCp%?QڎUJ芯T ߖ:}wĄc phzla833ip8J'zK<*I*tģJ &ٳKz~c3" dfh;"S‚(UFvW,蘅DfhSJ`0ѿE把{+J*2+VgVef.2dvJyI p#*U~Y>)Z.Z[393֤$V{COJ_TQQ*G2DQ4/"Ibg+V(ȭU(LU8`VRe79*DNKV,~#A>C|{Wp83e Gu܅ $K:sEлJ$gZZ0Ԅ+ ? #tੀ^ÑEF8ʯh2L>fY@ "ySwOuWĝMl fHqApAc >,:=}o53QpNz)én;Q0'Rl]]99,|q A8|V՚C;%Z9~$mG@ I`po襦0i0lOMT|X18ՙmG@ Ov݇xAIjgb%/ .F| ..NgdFb=>8~S !@qʩ,Z8ԚgA#?-5Hс6 :woiHNR%Vm(co LZ Otuuw#ntu_ؗK T#33A֔8 j E!kCjLO4ݯ7TyB 36X"yәIW;Kyjt߫pCC0A9 "1 fhH5|p fVKI>)ԱYTUp⑱UULdH1.?D.Ć2!OIYHRrȀ`X ` qJgeUjv\+1 a)bHFYuA]QVu5470Bj*hp8F p LƁ>YV\ܒt H'p̍ܔ27*( \@bQRܞ HR&yjWWݻpT?{dMb\<7`HR*THQq{z˱l%uęehPxugYeWh,e]:刍4]I)}0q8{[RSґwoeEEI3$J5/rhh81)8꺺?lB8J ]5/(=Qf"$1URS7TFJEQfdL*UsWW8|k1WMuы1 tuhg{ʂUgU&Ȃ4zX908(U0[(#mBu=܏N*fJ #%mkhh]z{5ڎ.)FH:;ծ-79))RGQmG@+"1SfĸdzBj}f-ɱ_4[ ,FKz=Itu=SS4$q zTK72Dhtt_x<a==d+C;Ug0f Q#+ @} lx,jss0l'x†bpdZZn F% pxpt׵u8RoEn$vYѩޞLFfJI/tZ:;ip$ksqC 8@pbrpKIc[RW|cA76 ziݍ=W{z1W^O[#K$R$L[6C;|Bosu44j$F F¶#༖7VHsq|t@^}a;Ȯ8mwAnW]lIZy zת~*t uww\gU$loB\wrQ1:qbc\j X~ָ^ċŀ6EGgV8b)mG$tA`rDMJgMJ4+vxh*-W#80:Hlx2'}ZFq,Ev dK=_"777%-f88ёYIhP_n)^}67eiԍ]a>cS N,] {=18-3r̵Xs# +A,v/51폒ͣTs tuuvܗFZ A&,Q(*iL^Z^l4=+1xwRvJ9 4r\PpSܸ:;|*_ GwwY1. psP48!*GbȞEٺ/t_)]纻k,6h2]b^] ]LQ*uExqdxxd[ˌmefkeluR9 6`brHXH5̺&ۂ47ذxm Ɉ1D{noPkXT9G18KVVM-g{o0-COwǴQ:8dL{XveC"[6XR)DG.xzSw 5a `lׇ H?v|?2<:n d9\C׮y!{ACQIZF?9Ç SJ*).hlaj*:x3&ǡ>pLb{A`hb% }@΀-RKRqlg p |CpWҳ;@ '!2R4SwV:e8x߸{D4I/xr@I\XbdG:U$Ik IVHb&p&# 8hnttvlN1&^(Π^ Ե{,6 Y؁ :uC1PPw].H!P#lFR'Qo\f6ut)} 59 Xނ}``¢:2S[>"d,*ajb؈ bP8Sfch}fܣ?hb𸾃k8 OzFGƲB?J=T^q=9ٻ(^vq2Ç4j]3"`qű"p@TԦ!S GG' 9nǾacw-YNj /uO0ﭡ1P&08uz$Sx]VC3<Z]4=wuv֝<8sEVHUHՎ Gf#A <Ç_Ar^f͠# $rrN0Uw"?h$pMәv&RU?x4)=w1:ZlɃwJ_ǂ||ME-2[?t0x r p,6 8܇_pD.6TqKEQ?MExvVGcc9ͧU"W￯܃ZO&&}qbӁT]U]騬tTWהdd$!L{U`Z32)*%%-MKy@qcpr] .!p xNR=sE5^@йx>p (ń=Ng*18Fy% p++eh18H q#ә(rTU)qT8JJVXppy ԥ(Gu*\ JPp`t[WѱP]ٳRTM NN ;QE<[=q.( KD? ,UrLtXZFbkEIfYN,tr:p| ɅcjdTu8&ٳK&DJ?.Gb;pnIUrW>՝@h8HR`v÷=AyY{˞==I!H $aH Z08zՊ9Yҙt:%afÙcT2NU%{KJ &#B5 G] !H`p`>Fppf^Vw!xd:q5s%Zb_HXEu>YdgfffXAV*ޠ?(QUUVWgVef.2U}cggmAR%^ngeB T@2Rػ aEJiGXy Cx]9Re.  ZZ $˖L*DjvdG7M&cc=Yzl2i\ƑUIrP(lx<￯`ëӑl0X&ΘZ $\(q8얪8p-Á>']bZ|6-mWqAMZz\!bܛk95)SÛx^h2QHgS8և=[l:5~~\ ʼ(# }Đ"gи-dg ~TAu"p#pqлJB*Vӎ@({,Ёk,Z Ⱥ,O2iLt]IRJv￯IHcAJJB z_*VOٺgKwy<.+3Hn/*,*Z ~d3<,FsHQNI5yS5 ~K`Vj*[ʤ'HdFbʨ$< vq `Txat2b/,,,zzٲEHJpHSk6v9*9Uk\&Af̎hˌVؿ$31n,y&|Aa'c\ ˑb 3'H6+!_UW;18H#+$Y+ʎ).Gd۲?:88X+_` R:;x z"~#_t yrd:ՙ꒪ET"r*Hc+DSH2g $1)8^&_pd5Nõp۱N}l t՚u1Bw)lL ?+;40`Ou?{g6;$- (B>{%? 6v]2`2ZM EiER"7,!!/ݟ+~za~ W 8@AhuH-8"N-A"xHnȨ.cN5#>:J\U!Q:.p|!?781;l@NרTyDWWwwGgGg) 'ٴ[ ~T Ngff).q8yz$04aK%q*$9 qư8H90*A6<׉NAq&]U1*!iFR]a5Al4=,$8750}3 ܪҼUK XbժVJ[8JK|pe銥PDcp TH.떋 ]3}pgk-ΩB&ʑoΧkq59ƎgOfƺA UNŁ/=sKWa+"wsyؘ+[}W*s eFcgY ei /lI%,JzzŊqWFTX7DS"~lJ$ynHKhoU^mq/Fv<>G c@9ܬjAΏ~;8~%8 =tC9&U"%t1&ƃBZoWsVl? ϯ\֭^wʕ/bquԗB٬ ÎUS8Bv*Y!#u.l]տKyٗ07phܖ]=Wx<&F+^6qo82H 8@]]~۷oF/[7nܾk?>諮'FǼ^I;a}wC#CC~=sgxD+hb1ϰc0- λtk?a#8_ʧr^dCc@Q^>2x$^'ǟу?1^8p<+-)]W7Ͽʕ]v+nOLxG|_;]pnzGo_~8Nݿu+cwz{>d͑ؒ\q ]Ng5{fK}&Бc}wՠXG 9uq8@qU]Y}tѣw/_XqbεGz~'T*x[q>-9i(Kw= kת䝭z8VtW"#pŝfԗ"=ox?}%|:Em|# ϋ IuXuir))]ώY4i"i]՜6o'5m:*InI'TEʲӧѯ6v)ɤy^9iÙVvve7ZW5ol^#̊8|`̉G׎p W[ūrV͡ P%`#0GD\C|*Cx*iOcGv#7Ih3b݈${&$-G>ߌ ~pb9ֿܳu%f+_*K }8Gߝ{}l fC͟b@q7߰t|\\O%m1OV \p)lY8|C#/?F"=VmV{;&:a5M#o39e;it:z7 0QI$+iD5߱;[ڨ*7XPL3gӍ,'>|\ |oYRTS8a(V m\bq_;3J  M/{=CQŪ6Idt4P0wx`J'(o;N:|%~~Di}&sm]qR+^KR1Ƃcc(Ϟ bCLsV|OT4xwՄ9KGD#TW8T4M3kEzJ㲸/{e#GB>g"c:cgp""lšnOT6>qzw;>,+!EKwq!u#8*ۓ'Vc/vСcpxj1b81a PeXn.r\ًPevh"T匈,z.p(#R7s1N] C|\~nCbKqwpcxswG=Y}ΝFk[KYҞUﮙ$q~]#Դ?F  |QmȲ* fL)]Y5pMӇg ~w߬6?;h%6}O,qR+{S;Gfx4rv@8QPsΗgN48Cޚp -b'i) 8xBz7-FZ&a@#OgTO*JLզGG(pjã(S=>DJմI=u+wQ& h|Vggjw*sUU'~ɿCq!p@#-aN;M ')b8?bv_W sLi"\Sfb҈yCliCWp@C̍9E 3r!xUD8!촷47+A/fOW4TS=aYtPP+62pl􋮎ƷѠ.o؎p@U#^ ȱZ\.*S⁀,I\LQTꖸT뚦d?ZݒI GrOg\IUQZReZIutG_UvymJln6L,Ō(}b^EKx`充ך`#?k¡FB@ aX6xȬc u.T!P (bʀc"!tojLtᘩƌ(G.d+}"~aû#j-7X1"yhU^MO,KVKRtC. {fn;y-ldָ`Ío718papppہxHC!#|Ņnϧu='7aDki#7hf)N-] G$J ?\c9rIvAUF6^W4xp<&Foxʅpl4nꜦT+-ENCMPd3jLQⲇ}SP2$cj!g2 vcyxf㿾~? QiaS\UT8pxZdy*{K*ml|!{|xRž.ŕ9M^ :_ʮU=mujW7̯^*)<@`Ҽu8Y8"C/vI_e#⒢BFOĕo4, *>MX8b"-js|ÑWr\`尲 Vt?alt4am>RӇ:]3탃 ʃcx~f|KRXnD"XLT-\垏 rz<:g̼ǔ4M3THh2O UQ d6{0臫>d8N ͊| n8G%ꔜNɥI6~H+&% u$0 /<7YNlY5RH3_G"k;Udza8p ǺN^ӧ6`{A89#1Sg7ӋI5G+j ˁF4Wi$t-Q tiK3##}~iSCU?ʪ O "1!\EW毋¡RwolUv1h,RŲtr~)t>ϙ, %MOp4!>-]9:s|%3擯# ~7"ݓKѕ+8 vG8p|AGqUUE~4s,j 7 0P`c<젉sC(Z!c܃D+ M$bi%v#;-' vE8a.^U ѐ3rB(,%Xx8LL&uc2 3Ocy@#/j ugd:c6P$~`)QJl_*\g vC8:GFmnMSc]d(<}(sidy汦_3ݷtSpjW'%*@8?*-]prۈxpiB塆Y`LOgD8|bj#q8< _cdHрgvs8Q*IC=CC/GoC!o ^WEeO-DŽ1N^}VG{zz$)ȿ?Xa{PsǦת"|V ks*@8 G۳pnP4B"A/?', , ~%huHN}/}/~/ߚb_ga#Gv:l%."q. 9b3{x8k¡ G[,6$䧋P_аpx(K;ƫ|S`q "RCrw7zz{}>IGpȲWxhzeYP:E}${=r q~e?pɍqwnOxZa?PW]~ڧffillN,Vb>8s3i*n4[޴Z9~fjkctG72f8±}Fc}]ݫ$'5bo;jX7(y'gd"6X1]{c^xQoxn47g<T Ǎpκț#kY2ذFp0:ád#=3bcy&#5\6ވU<[쭥…W);̴bys~EXlV{kY7o5 3t#8mplϑ6=ZXq?E8lcoR8 ETzcc0+auu1<{i>l4ttO_#W''__[4EnX`Y(fc#:Nv)ϳǙU<{KþԍP2hO'ٷa=^J>r*V`y8L3<~\*`[lǥk<Sׯ_>rj"w45Q-jj~_Ç_j˱d t*9Ex`φ〭uǵkSo XW±a V,lQSW~˗nh,U:Ɖq8]3 GFGkW_{j=|`*RWW_W5Lf.&_?3οXe=#f=I3!x^j 6NMQ8Իt8 N;O56b?y֍ߟ;6ΰ±;AWQG}=]jotŏΥFyF8PCrTDa3oj[p EsMtkF{ܠr l ]/d|^oA G4Up2 Y-GݺUXrYBt4uWbU>> stream x   & (*- '7'200. ('&6/:'2-/S-M)>k>x6j+#N,NG9cM'IrGVN3F8K+2oo3n&5UHR-QR8jmLk-Qp3gjlQI MK> >> endobj 44 0 obj << /Font << /F19 14 0 R /F20 17 0 R >> /XObject << /Im5 43 0 R >> /ProcSet [ /PDF /Text /ImageC /ImageI ] >> endobj 52 0 obj << /Length 902 /Filter /FlateDecode >> stream xVn0+xtG۴KC+Q[Jew,Ɖe9!3p8aDLr$ i$~y+t((QRrIx{qʥvd{#͟ٷŻ,J6*8Yg\[jS*;>o-BPi@V|oS䶏NKM!Yv{"<5Q9XdLU{%ϗϛ>{m(+35׆Z)D|:Hװ K!%5TiWS`Z"?\ L!t ;pzvmȋ$IQ<>$ƼT-ULGk^ICvڡȡ&ׁv*V Ytb(V!w7]7PiRz^AAUϡ*U~sM\m7-k^&0!$|B%ZS jڔiZ]}AlMY\\5u]]uM|Py㛻uǽV 3 \5V 3{OOmHYV}]cS+6 #ZEm6U5K7O%VTA2,u$@S)5DE'aH´O$!uw<  u,^$$ZS$ jO”ϑiHB}:@_E*' ol73Џ#Gk;-&]w҆3Gcxc>d+!+[S+ b,,g T* tRh~z v^BQO&cI3@,K0$7JϨ6qZ[cq |uv(endstream endobj 51 0 obj << /Type /Page /Contents 52 0 R /Resources 50 0 R /MediaBox [0 0 792 612] /Parent 22 0 R /Annots [ 53 0 R ] >> endobj 49 0 obj << /Type /XObject /Subtype /Image /Width 1593 /Height 612 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 255 54 0 R] /Length 55770 /Filter /FlateDecode >> stream xmPSi/ [j(-TjeˆntzJBj:D[mNHb&$!UA>I]o]uxK&Wz3h7!}V^ZEOZIZ2bHKY}%2Cg$,$YdEQ&do)(((6%JCYx/}q'bb1Yf}7`;*}h3Cǹ_ĻwLAAAlAK~~?Ĉd1ʊRb[YNϲY78CvNo.eTFE*BB1Bw#xGQo"I3 p,X`Y,Mf6XoX,Ul^ČH9Dn7?fvrDmzRP!e&*I%EvavxiAFi?ܦqٹ;uZeֳbH 5dǀO7QDA2b3[&  VX^ :}4:yslZMVևV dr^(Z\shcqrP 6CjD#??z^7c%w^~dwհ}NA`VtN4nNՂ_@,9m6VhHgq)q 51w4`ﮩ?A':/+qPKvvc9.߯mE~h坼|n[Vh>N zJV%kǏt_z1G(5Z1UGMO8 cA"O k(`yЗl&FQFTRDQ ` AVZ*wnW_8E*.P]@wm8] BUEp-!"B%І!f='yqL1DGqi^x9N\i V[;*>K=D9q::0筄0AOvm;zzA=IfB*9b3­4 w̰Kx50~eCx?`l!̐vxF#q%)֘7E7*p: ˥lUX|[f}6eonk彏#q!%D[œ . a9XNo0k8R((֜7@'Gk@qG}MzH'u;K l1@ _W,v~pYs!7ys̀5?3P(";oȉFb9W5~k:Jx6 ڹǩb@#@@TGg^zV]oxP!eSC]60>FhN2jBT$8挳-1OJRr/Ś r z W0A}H}^ I6Hw=9AINdIm2W<9;np(BIM]"b &oӧt*u:T RQ3A837jы V=iځA3)6 & 3DP=< цMQ…=Fy fYf#ncpy_hO\. d06UU`Xp8E,&zf(q+:0 \s8jTA$!ªVCTrI LP,TW٪! ˫m ,Wm<(/,Bmr[J[~~QEEű‚"IxW_iۊa8vKM)wT$ ?&qhU U$/#&K ADa[u Ưᥭ* Fa^aaa~aa^YHC@+hUEEy; 㣽`yCdgYQPP)7j3`ER=UCke8ZJj]ebFXGUlG*[UUaA!f H`طU}@C'<v0 zM}"-TE*wsuAzh}Ѻ+=h BDU0𲢪 *[dT/a$P:E, _ 蓩A~ o<{$2\YK'RڠX[hU{+b}*)X8Wٻx*>A^!⼄ ~H? G㓐6MNL($ca-x<F́D nQC2qD8 GGe?`Vj4gPSzh$v 8PceF -*=8(^<<nECUZXFL)TuqϞ_U<}9qd Jk?F(/?hktyث:˟ˢ܇s8' ,E1Xx'[Bϟ wLȸ4^A41Z)3_8> 捡 ܶx$Rc !0FyEC8ܞ Jkin(|/$ <S`$8x6\Qxz7ܘ1W՗yj2Mp,,KS.VU_|ߑyGNȡ_Pܿv[ȩx8>BsjHFˑT({1i4w~wBsOc(o/٩~ ^|d^Hq_#r\򓓃(ˡ+v(Lj/ljoo1@Ў`cEWS.m3V,LTqP qrB!X E(qPu`KlnE>Szf(1r±B|0*TI y~9E VbVP?rc2ߏGs *$Q69#V%6r>)C@%޸{7D`НW"o }h QXeXJ4L8ؖ#_ t^;"_j8 +/-/"0`Džj?\YOh q1B$GHpOM   ˪*z PqD DFM"س/Y(B(qPߺD?%reP%Q~_{\Vk>Ѕ 8۫ ][R_ o|c&GA$GÕx?!x|%ۭ TQEGJ!Zf_XY8#*PXT?R|Dgxu~+׺_n0?rr 7=3w AѨ6p_dQ@\q6/*z4<>?$CρuQNbC sȧ{|{wfgJ/ط_طſHGӏs=L[} 1wii7)7&,)(q?q_z^<䍉 i{+8}/oїщѨ\QQPp!NhAr88ˉ0; ab48*~WI_琠财(yYp|5i*~KWx/ƹ* .Uٰ > g:7ͫ 5'}2!"=$={' "=`u`7|t|QAS0-t[wFg.+8(V*Ɩ*+Cq#$ *oGA㸪T[|t"|wāv;o,㤏7CV.WWy,  *sr._@FQA8q(:X{~+/ic{Xa/ڟ $y|޽GBw sܽ{;N&kQxm/O/|>v`eޤ}*W7qPr )/( BW}j\@=bWiùAhz5&~*..*ǒcCp1?'g NLb2'UE1Ǹ}yV"Hb%|1'`? LMJAr,u+#Q]LNL }*x}SPf0Gb!_&Fu H)39sw9;0cG5UkNc8cpK-H )a$O[ %7A5i8*? O¶_GQ84Ppwb |8P?CPXȿ11s]VTۀKe2yO+*zP9q VJU2rh}>}: tĠ7bc6tBN>KA&PIC5Kc‰Q8>65DN/+~>~1}e!~ɿؑӞ_4Trr|2W4"jq\&vCdS X#rH| j?I!= $3Nb1/.)rkppQg㗅cLm.<j# ߐ~҄CksW ?_+1S<3:>2fp `]"%;N>%T 7lBn}7Ǟxcw'?z:&}y~oEŞ#wDb) lKg7Kr EUCTJ RB賑7!G#`aP  ^:.Xkk'G?oP4cpp'O^pd`t<}Gh ;Ο߿oŭ;*HmL^U☺9,icu@ni6%` @1O P͇a^ >YXDѳ񑡁 ]Py>}"LhX5'AD/VWTKF `_vtE!*o`!IlǾ?|J 8.+IRr/ NQXqܮ!x GzZ8PPU!dI .212[7MLN¨C;vyߏLjCVT MYH/y9})١23)\~LPbQ}dQ0tfSՇK߽{ޑ{40G`uq•r5F7DZɔ1dѺNzѮ/Xla*Oc.8~583Fc>B  nЕq-@'(c.($87U%*3 &U}1j\VbX-ef0ajQ Z˙x˂xwS(U+t_!m|. "9炓N1Q|u q1$Н8:$7YD"qiU{+EM3Vn52PT8p|,/@)B#APp(82p|"<0gP 6&'C`᎜Ui7ٱFGjB,f+Z: FDQYVt/;jh~9x ~,:eq֓9GW8ެE*`RH L̀q{7"FPO  A@vc#84‘A$pgл1y<'cy;*r/ "OU -Ia,ߢ~ay="IXհl6deiYYe9=WbprFĆ-喰wOSj8@C=yf|$y$$v}`+U/.?(Q~8(Irը%p8 ~'&zYX0D%<HhbQɍ?$|(ɡ6J) x9( rM&I%rhX x/ mZ-6qٹ2>b,+9Ib )ec0yH{/'K:;9fo5|Ƨe5r}(RN ,OE'7\z=L<& Xn1A1⑉01Eģg7G&M+Y,U)Sau$:ٺzIe9\4:5viZ-jX-% l+ ې/MZK/b #n0"ɩ~UUeU/_96G/uxPY,r  ˓X6(!8B$Ɍǀ0!k< 9hL3M$N[1|j#2Y}KwP5Dp<>eɝ[-%<| 0FUtZ,.㲲yXUϞ0*Jkm_|oǝYDsOb*bje 3Ki}ŗH]˫/SXC,whLd4V}nu:mmUWɫBŰj!|[TXi:0(OmUŕy0*8X""+*QnnUeU"k(%0vg]8=5<ϱv6YT|^6Sh>N `7,z oWª#e|R79NBfByc|T{QDUHYUP~sю׶%x!U`i/rwZ IURy,O~+mvh`UUmb).no^AԴi*QQēc-<ϰU-[h=9yj@3q1N8 YZA yg@pw# UkLSo so8;;_c]8wt4WWq̭DZd4mrǻ-}R-GZ$"JfZEUUpoo+U%Ȁ` zq͆MlhkUV___yE%A(<ӿ) !́y`4 ,ǟs'Q" ˫BEx:4:V0Z txy7'e7E=)Btٹw1W h‰Wd.YeXBս(g)[ʩ?#b?KJ%,(.*/n* 69(tA U63T⡾OLRYQ_XW |g~ yq1%VN* 颿iR~`Foֳd%zY,~v 2׈ A^ k8 hAYCk8RsR Fq, f̸Ri^hv@%8c\iv.{+SL#E5r`…> "̢ey P\TdöJ%lCb@ 6⨨8V] X#lPP#wBqR6K* Bⰵǭ[8Rd$NPx^*l$#*Iy1fN]C: N`ǃ $((S86݋zJv{/47Hk|bbk,7XNq/dx$4-HRC S}4 r;d,zIF<[E2V=fV^x3er0y=-ŮȤ8~k;֭h4vK q:g4ZN 3>X+hIRzXN>ٳ{ImٷWW+pޜi`祯1qLyڈDQ1`0&q]N,CBQ穈CH 'Rl 2 hAADq? $c2G!NKD|s,;#;_tӤS?)tFN# 4mp 8w0s,*?&N]^4W""T@u+47666gyS-񮡖)Liڐvx0Zg~p,5FޏC8ӈDg+)+3^?:/7т6 J>uԧ@r=MTB )|'N{k(҂XVXt:ug P)._ホ&g?DA~S#`G ySC)K_ ˗'{*cq {OY8>=έU/z0=stA,FS%t".?^iE(R_>T?LV{E9nw":@zT"c}^_o/ȧ#8,_Ƣv|W'!Q@oz]\u jdU% SKޘb֑[6K77ev55( zxY @hFNQ9a6XNgZZ0OКCpF]T(,& ofAo{.# sAHl}Mijs7yF4hA)d`]FV^&xܯ AG*8mSZ"qjkuɉ͍uVf4h;x nN&lG ` V$Y?rnQ[hhV8ADZ32Ҵ|`6nXP#BD}U^,ȆA8?AxHj`axp^A౨NF>\cp~gQMT;k5`F华zG]OI{O kFkX-^2TR'V /qd~YسGcC&Ikه7R3dRSn$6`A`4!zhfbгY7vfE3} ⯴Xy` I GlWc%5Watr;]MM.Ni  lkA/  g5M#(JKݢT6NɄ'~RJ߂Kh qʞv7ِ1Ya󟉲لf}Ώ&n@\nN'0 /">@Z,(+o4hvn%HfjV8q;koE3X\"rIwspP8ߓT '}}{֟tV+N+JLF̨7`"CTn)`ЌĪ(D8SqegfkFiUK98 Í<@z>PVѱNhEx-$"mk5XwZsպa#uq*`C@.ȫ5{}jx%|3}o K%[nܳPmY c_1@ ^on ZLFS(. 3ҼIJrRҶ7!#*/5s9V[ |(7L8}ES~ami1:p3b9봌7Ws7qNo6R}zF[O[i(xobv$ctb-K,Cr,RhxoøI79\f*+aIJK~(R+ 8Izx <޾]oS0[E7He49!o4ϵF@<<.g-i4^,* KMkzOK-&RSn\; 4MS H7^r=ā7 |Az--7nRb*Jw.\ 0L\Kd:٫Zl' |탏腓*p\I#-CfyS9} Q:qi O^^dH(MM.'4f *+4nn-͕ k9G=R©MWj  䜿 ?nǖ`Ch$Y4f٬(ɵZx㦸EYh94#Yn؄fĥP,xcg/fn Gb֥x\BYv7V:F@?CW&%zRz m'a$;@sp8%82ɥ,g)1gZ?''Hqp:8bQU`-_lڻ z"6>E$ݑRcGi_vMI5D\hJ'"3n7?Vz;ڏhF9<> g5(+FG,g ,Yu L8u< kW.ѽ"AOnv=M:XwnYp2vrzj,z~Z=뮺~Q,Su-1QK0qr@r{C]˷FoC M0(iCCxiٚ đ[j 5dgub7]IylIF|IJh*%=}ѰY0No"okq:vf9erHD 햴-tr[222KO&K$͠߾j4ޗ2j&Pt$Ʊkؓ+aTb^s`;sF!/k up`~O1is4NjmͅX$GZ&BHc=u'W]w( "0NHzzK)QC|Z?r,1p(!hls0 4p4mҰ}GoAofd"]m ]*p = EF g w=+E·4w7:)C <"`G(2JPM 0387s8Vedž@]紻WۖWF(`HXYF ==l#9j]8==-=#= p ?=NNv0 5ګ}SgO J.2r7=rqC&8%d&Ѐײm,nH$&4#n6W-Z[L5ݽW8ojRj`\4HXT|NG4اK؄H2,%tf7o (94fb  j4F1Dgo=.Li F8Cc,vuP`{|THy#%$nIJ,j'6SBi:&!t$9KY2qlیcTcwCbAUV^TR%8䃡&) KpI:|QQ[oS|>ؒA%#"OOh8sE- ;x{nSn2Lo[K66J/yml]9ʉ0؜Z GT5jAnUl-ERj<簔Ez9bD dА4)EݧNWļu7j#-$jKY>t7ݞ̴_H[HaL ҽ!eCM7L+'ŐʱMjcӕs,jGc,RHt[$c?=NpRj2I) fdD \nѹetrvʢ_i7c+sd9n'tQ̡HȤg# Ŧ i+a!#m3r5580PMxΪU[4H+F Ѥa RzayMA;`'"ӧQ:P gd99~jU$[ lQI-LITT^!6Y*6>q8n `G[4EbRݻ5,GfSatc-|]>8NJUCmnR)wgfdNie##)9Vjn֭Jc3kʓ%UџyS9!$3+NwFr y2W#I]%^àoYr>g/2Ȕ۠h͂Dҷٺt =92KMz?A 9{m齽+^0Vowhf@Mmow$Q" ܢQM* 68Z_Ӄ;^I5}{<.oIGizYVJG}d6Ø>t2;#y͐7giRJ%J8K< .`TzqwۻLyN'b׌ zo@l3}R3ʰiS9nrN**bMSPn`rv.j@!B1KhL:#*C nr9II*aew| vsQ4!"9*4e&e9c]2BB)zsX)c![^_5.lASSH \;&`CUl[{W+*{<UeJZP#htݟK:3鱱; d!R_dLcR Uq`䆘}GoׅmSFP+kcsrD`<LNո&k8`p\Fa\ź~bUTZ*R騬aZ5|! trDpa3N*dHCUL` @IccxВryRc?^_ 2ؔ$򉋎^LW7Gk0o`y7Q퍫gRCD보~Qѹ7tnvbÿEEB^Ʒ`NAq"Ȁ[nAqm*g(K\.Nrz8~F qQ,3MKۄ[#q;`/nbs8T%lU&WD(1=-M"g̘XnMnkKɁ[TAJKbrQ0]ܲpDoPG Tc=c+4pנq;?:Cv*\bCSYA|[U~ kn}6L(.О.XL\f$ 6#?ڙV8>uS,͗ʡ'trj$:+Qp8BHtXUA*}Fƥš餬:zk@|8Nc$]!,¨HAuج#4BZ&Qq OUj2!gMn 7dvگeZSxn16&do8:PJx~n(Dի1 CJW5&7If_N Ia␱})( '<ƞ 2V(h@ t4DdF \5"GJq&97M:->|SkB*_,e%R9"]u :zT\Y;W7˰.Ɖlm! $ܮ&6so1԰zrg #=~ͥd2Nv["2F\*rFZ(<7~mt.C_ѯElTƤqqD5Puի.;Zס'YeCúeKZ&nxL5u]2?H4Qvcz"^A՜G5HW5uyVY\h K7)_hK6x*ҧp$oN KW#rWWr>}VxOETCqL[,qqefܙjO?RiYC1.Mұ~GO^Eq~3/bsr4:u55]x?B zQ=WԍzF}ca6%1C Da܏ s"# [҅f [a%Sd+E!t(c}:' z[S"1P]96W1w]CTG^uĈ*qQpI(<]u:ojJOwyEks:eT[3ArWs>]W c8(VtmTƺWhjxPuJn5"qR*a(ʵ)l!j# B*d GpFcnl?jDhE~4ZUM*Zʉb)lTozbq=Xr޸jw8Ї:$#X:4Gkl(CN&H@-YEXd4oӔZ.` zo]H q~#Kbt]]S[pE^ E+h]CMWWMm]Q0&㍦ $H֘ eB`@.#'DG{`=ؒnEcGX85h*ɽ'oKK( JXod~t~%/&l8΁pUP@t^PUw%}E"\ZP+S A.Ϋ,QtRU.R!r{|XJNetzAVR3&<Dz\V%@J~3ico{26H 6O*1RаWS=ߨf.[FuGȰu2a4F| QXdp7b\^x=¯}3`* M3rxb`FR2R?IizcHe'=o%# j+(9^XG7+u(eo8N4UN9Hc)R9`+{Q_W }b=oׯ4"+FgYF_t+GB`pFkI@&RE 3ó5;Ooc0N4:iC5PR|"R{գ׺;`G7d2#?hNxE L#IX[<>F?$:[cQlgh4MfD !SiҧΛ#^wOo -oHA0E[lTv2֨zu-U"{wǑZ Ѿx\898Ca`-ߍ4F{ׅBKr( Z1<$0^7saBJ%i ňc==N#5 @ {Ezuبġo,k]5=V++Mb3a5`G3 /ĽK@xD贿n'kXM&y"dž+Ecs_WWf-^Z-6a=}-XqG"w+] 02wժ`xXia %yJG(K %ztSq2c<<$oS;s.|0żQN Uo ?CU媣v%āf3HHffA8Ѱk]_W0Ak扒G?9љޮMOdAl)f7h;pv^w GeB`ooPX}^Ă@4YY%@mh4W3 Otzvp.kjƲ\:l?Z򆿥7T ,IarOĭһr_%A$wU{"j,(8XGG7sZj}a$v`}\ NC -h:%6|*lj?j+Wڕ.IoTW2c6 QhFijjs\nNj A`EПeu\a*29IG%aRܼ"s]'Zz)bJ5E38pF BG5^Gotw-Jo=@vg,>Nk4:  [?+1-2Tj4eN.Lx7C>djuܟ{`Q,.//s:8%7x*Gݾ:56}Boê#gsаN wAqqx PXDh60!@7Z5=l !0fC>l76FK0z p34Xwƪiz1Q9wʁê:cKk&׮B;$n$ݰ;RjR෴o Y)d_L=c)ض/UZO=U4GD*S4aro^Qf&TG tAeLUP)QIXy0$GE$u/Ei[י7C"q͈ ʡo!qZr85(@W,*ie pIM?ǧ[7qmӐ/FGM,iZ_Z05bP. ~݌XHة'hrڋK \mz5oG樣 e mf;£B[VejX}.l[RtUm1?fO]_FmnU}79)=uw 6v5uڎU@!^&&b+앏ַCz//NܵT9MS9; .Rh =:+G.5B=W{WO&~_' *hEYJkṞn2:g]3?XZO=T>G͉qfq\PSW eѸamyoU#Is3EvIw;w^$='qn'g53ݝx98bwb.A`G8{{3lFIbKyߪTU* %TU}m@!Sd8]*IE-}բ.v+r/Am۞gT;6q< 76oa66}E7!:6mڰ {'>㯅UY:iz ,:Ή\]v(x[(@rë L0q'8Ne"_o eVtq8hAKS9sٌ*ln欏 _x3: ){c(@ڧr̼Ϩ:Ǵ3:P 8Bm0?7JaT>L gUb8w;/a)Y}1*YA6[O@rڧr8YxDžS?W\]vrct TWtd< eUtLa8`A 7"gmx)ܷXϸ= ۹92E@Ou;wsV:G@G,mMhoS**堇Kt󇬽}OSaL;;9vK3j+:nA?uHRO))=9` .Y辮s~+yn~/^Իnm/rU2XlqHMQ7`g:ȱc3O]x*0!ܹn .Klx//ԴieTtxӬTQ-0[ޱO :>LϱY۞Ťn{^ۮk+Cd {3`m<뇫d>Q]]4abSo` Sd{S>irrc1T,V#デk* |R+Ь~Y\:ueǟ^T*w[WA?uHRv)ǘTEDZ aMctuc^Z;^zFU&:$k)cHz@#~E7?E!Zش*~V_ϨwK# AaDOlx &X-vԇiM1#pZ>SÙsǟW/YtMp*CG9Uf׶AaOB5^b_fU> &RmHεT|O6aW{/Gzʎo .T!n]nPaNi&\׋rtl{lm۶t- ʴc *tS7f}P06`rG?sL6;yM?]s;wa t8HE8z( M %sdx  8tx_1,;?0eP.k毐a:f ]>/m&)ҵMP*\R8ƛO]+D{Ğ 4Jcvm6?.^L0$aaH ϋ:C0r6wee#k/. #_r A?uk$'P-Dn9;nN0r;7T!(/ *yqt\נ {^V␛+2SAr|@p7žP&xn?k0[d5rpeh;$v*TxZ\t\9(H}7aW-3|BjC̻IU੒9,jl'֝ Bҭ:1gy/jCpt$^/Ee%VJC͚; I<3:&,177.Hh Ӻ*v3䠠1(GUn:6N: AnȺB-~Z,A (ZpXY t@ <41"Z:Q HhL%;Iʸ[%^0V S@ Pi8,EKt,@ PB>EMEm/\+,47:d0uq#ouF~7޼u ( .JH* F_2>F_=177By{a.ᾃEb22b_9, }<~P_P=ҡv݆(dLtZ-#ZsfQkG0V|)O^ ,#Bk426li |T\?8TGyb+H#5ZBtngzjm51h3jJdv%w3^FiW8qbA!VbT~!|sSnQ+rp,skipoYc g~ _Ysvǡa)EWll ~0IG8eD:24uׯׯrOF=;>> CCo5T_?4LUc&iy$JCqG@o'o};v[ioѭOA(> &ʧqDf dEGTdl3p@MD1 DO<2yT'x>\M&"b*" U몺TSX\o Ð'| Lk4Bmkl&a8R%j]5nij` '/x{k).ӳ >pEՙyyvv/.s<e&'sp5ŷuheBzu Q twR=ǫT;(@;l3YE*5fKd,YHhEծ d!p,fq8~8N6GN;i+EpX5ȿ'\7w&38&l;ޞ^c{я8[{H`Wa lHkjHGZ$b^ eR63Gw6j(@ &W54l'zméDuyA~qhLˈ` ^F nEx'3X%簫2(8 ޳S0G!7yQ2^sF֪wgX\U oq p< Μ4Kwh鉠LZs2e[F⋏~reDgk#y8oTqϳs3O{z/8CYϼx rgd'TRΞ9t@0)"W`82VνH%?6beo?^Owpj$z_ą@<x(S"=LPh(b.JC USK tz|6yU0xje~k lB9!ܠzՉ( :n?U(7XF#xzFoOg8 p^2jsey*joD!f)3akQ=qXF ^JO4gdWh%\/^Vykؐ2Qٔ9l0|b!W ]x`KןSK=L9}@B_~U283!rJ8@͝?aիn5{{s)PaqSOpsBg u^<}ռo}-ՠidrgLą= P 5I |UeeВPCb/ճ8F-~Ho7aL_n^,v]ܡ910Vדe!xd>_/~/'%Kˁl^W%Xӽga:+ʮ2بş ^hAW^F e)ǵ=p]Ü/19r ߵ/`VLV/qydTW!X6A:1Or838^ 4 G<zޥ%"[)_x 4^9Y?βŴO0<UHA࠯iۊ@n-]48v[q4zm&}s8E8BpbykQpH ǽsC={ar@G<=U}χo*4_4w0H z3|#⸗Sor#9$h0(c} 2bhR,A[S? #{J=np$ྲྀA+ б6}+ӣzc~ b@?4e?J ?6/@ǜ6P"K∟F╋uɅy ɷ@Zf2RJ<T_Q,W%.(#j^(\\`2JR/!'`nAp<ǮүEsiC7L^$/) `S28 !ȡL/ˁxIq~WOEǮ]> o`2ƒb/ma4eۼ*XFQrC"Uth{]|nz/ ҁv[_d< AAouXFeTpl nőuԩ@{]Ra}_L0#^B bO)= 5W^g:`ts,#r^˨eOHIxZXiQ6.۪jrSr dӑ0kvDU55d )CRk$}`H{B* G:52f2R8v-`Zhx-M(hG "2wz"gG|Y3AfT]5Q˨7(s`)QDQ< ҵ;צku:t"tl3^6g^GdG:A]ݽ&$ujH-xgDLWâ9.fch;?L՜"+DK1!ސY"f^N[/4CJ^FwF2~bwvÂ?iIaΩ+⍦(e糂C;pa=Z6SyOoV#T29V ؜eDII.#UM‘rf|sU[Ui,-],TV v\F8 "$y'̽a›ph!?Qb1T˨s.p_FjHba qzm ̪ +Rӑ O\ѨHG|$ph ۉ0z{ r)8ŪST^08l(#dX.zrOv c}H(8#{q0?[@'z%j3XuQg/˨]F[ed:юU5`ˑӒعsG1tUP"(A?{Y{" B32{I sYF^_ /eT<)rYK񓧥CR#9+CocJ[#,񙁊( -픜XCrπthBՠHIq:?8),/~ŧ1 45x_XFN8,es6@2'jpP`qu.(=@(CP8$ yj`##v7Bd0_ԟUe̱ øˈZpV N߼'puAC:ȱv /E7#8>f(LȌCp'qqgIUW^MM;0]k:@Xsh8q -\=c`x,: Xu*eThY( Bx~dXHd_Nqgi KxM^:L8/`: U%p0g`vah_]p˨7`JW8u9.baqHw{p%Rż2Gt5%Hg.OL{`CۚB*u0eDu;&Q/gF-#\ 2s\,D_FvK5 _)Dt"zO%LxS`:EF|BbR3؄Qp} &aχSns`&RPx廭8_Df0uk$h.atJKl "U1ed?0(tŨ(kFx7^}>-aB 06wNppyq :==s*b6:5_FܥE_FL/2*-4R'28zf_*ǹN#ސ.@MP=3U=52pU!8~_:^^CȎ[ L t,ş [͹za¸mչٱ]U;w~ 0;ܪ]F<HT}d.<}0zc~ke$kC#{m >GbYF\C"',#C課q*j7#jTg0D;lXFDYXF9` !WA!Gb$jc%u(免 ΗQ@`xT@q؄q28)R,#XF Ɨ1IN'OI1 9+~zH0 ɳzepжFyqyűb}ա#{kE"$è\et>nˈuY T~0oŴz e[ɵ2E/gq,qC.#,80ŏkR L—< x^H};]?5 H-˅pndq"oFLG|f\}"ɴ-#-T?ڊ8@m4`-$X!_juXR Za]",#訮r ^t^CqDC']=6 zt8GR/#퉘 "㰌ԣbUFCYU @uw59D9baNFVH8eD2RǹVUKCn"|wuג%%U+*A=22o﹬*` ^&~dWM>0ђ%KRSq [89d2*eZpgnq ?caG{}wqWj{JgPNj\FM/,#N qhȾXXȸ҇""BI".#C R|Crj=.^_ƆYz =~1/2Ds~^~*0$8+w'an [!74T *Fȸ+Ȍ0,|wyֆHظɣX:Q§WM:?8 <#za2)=F)XVzRa{ep845<{䷻w Sf>+u$ s<ש% ˈza⵪:x?๪"U9$X"<SYaYF0IH8:Ãc\=@jDa^l2 ]h㾀J`qn q,&CX<]KMtOFMed< e4 j01f$1&Ğ߅*5bX,QeD22Rx]O8{^(?]U%MHrq65̝uhĻ@6.MtytW# {:=RoCkOL37K kv B0T[<"pš8b'M-8 vYcqFQa)+oW[f籌x@*"2aQ_\ULp !8vߵ~p<b̂=N{O8f0V /N_Fi.y/#8:عs\񫰡SDt"?kt`#+ZCmeIP/R ACb^]aE!T/<;<͒ygp@(W%m<92tdkLVUɨQz 5**If-S V-!;\wW_Ic'.p ]+BcϱbkdWh ldEꊄ2nnOc~78\ӓRn/Tf%[}5T/o=UJlK0Pǡc׮&GH>{2R,@o=}z4GCѭw$ zf#ʭ,#hʰ1~<;ߕjM 'US|$,8vRNwݕZzk0XɢMxpW)Kndv\hVGeR1п,4I}YCj2` 'zoRlh)>4Hn>ܵ5S.;Rw;eXsǽz4v<80{ؙ7!00Ph9% ;O(gY{*t+X=`EsH:zAwbZIu4YF Ri*{F_*WpY} 81t7rc.w1S(tݦU D.VI6#eD̹@j=Z{HZU9wjɒ̂!GԄ9굶hViB"潌}̨H9B4!{ʬ52j8 r]UziƲ!7dGMͬeJ5ۊj̫u[JUtTG=Z$\F Wɐ4{{^'OIY^{}oiFLHeL*K: NEҺ k88{{oJ⠁}TJahk&YYD) W{zx*¤^vo^/]42 5K *iDТպֺ22UKۡ <)]E^M#KY.qGz(údI0dUwZe¦[!S64'$A2Y T6amaH»m./* :ew*%.h8yK!JJ@wDo-)m0׭m!rW0qprtjXq RஒҽJ[\e6R? `V 'ŀ2W,xf#*@G{IM]ql0\>*RT+ŀ27Y^=z3y, o~6B5kr۝ d{ mZ㚅{R`'(_=}]D=3#~fTʢ' A1L^^PxLCpҰJPzpu,pdżb@)lnOB{R>K_oLUR^qeaV'FddU&|Mt"Z] J6Jh/1Q#E B4,:e,]%%5kݹ,D""лJ^CЃ:l:rL3["71Ty6gTs!kл*J@/",а $srADpK](f²pyEؠ:H6޴>J8=@+>:LLܡ8$/^R5 iG%ʇ\J˗(2 |pp ׻ UqUpyv46Q #њ"UDg%B\0BUzg&ZEVp#APU 1 $Ȣ zW-^aR`2&D Ugn" /k*PA9ZhG||zHѮB8zW-䑅 r@7iS\́cBp@CNj6 .f2MvD=qxzA괰R/d%fIKxiO<":}LUq|py)8nxX<]?+,\ҝK4JӂtZ%"$l)a#]KgW]Npè ڎ(∻'i<'{=@vUܮ䖅(LU 6iַ&*Iл*>¥㯳YEKX)q.ׂ]ȮK䗅k0AVa&=;a”~4Ȯ䙅QH:&Oe\u-]gfȩn,q8@MqBopA7 Y']EIRb1hueTd2Y $(LOWٮW/p;ڎ(yBQSRԔ2d"IZ999$iH$I5gg%j;e;](2h.̱ :T?+OZoޜE@IʜNP@Ɗ T,fg8 6Pn{._8J4obؖ S% Azv1;6Y[[, D dw8+RS)ETZ@?1 Src݄pyp*F#PY?_4GpW|4=Sp:*Np:pP$ELRt;8.ńf$t)نY [`UAJO)PnJgnvNJ 21fdOچsQ"$S4mm͏=7wO)ƪ ,4\C5iq/`d ?7ہ\`Jf51Mo548YY$aso.b ҪT(I$ꧮY7 pWYiϜAfR(B YGR^ge>**ά<0(L`jv*rϧ5`ܝr!b̦"xq Ӭ܆ÖFcEmezT?H6vt*050l6U8O< ֱX\١$7].arA~lqt_(.[Քc)p:K, 'Ǐ: P+g-U۴PƆ('QȂ!W,s+FGNΒVf؆!1FB(RX?`Й) Atw]ULm8A 6&V1?™m2%3k@A(*QX?Ȣ3ZpM.W@#Qp`t28jYѨAHFտFA %*<JOH0V,s"c"q;;n D\1%?\t69L\ܥĚ+-fsO.7$>D.Ɔ:!OIMq PO].ii,8L&s%h=dD4ĂØp9u {qxs# C2v$ 8`,$')zZ\LGhhhj*^jZe2V d]qv62/j됥`Z%%xh,E9 YyZqI"drW=NOHئg.3R@КMeVӊeVdgFc㣶bevl 0ZZ;8hClCPgHrJN5*?^" mLچw?\Qw/IÁV4T4N8 ޏeDL{࠽y@;dI $9HhI;ڎe6ET( Y u :؆8]7k"N2QHif{q.VwWq^Dhm-,,/p,E$΅ Ji0 [#NE=uUJ"qFGR 7542q<0Fє58<8Mz/ ou]]TƿN[\ZMY R˙;*ŁbmoGW'#O="8Ik8uM5UiUшQilGRL)&9: p08\62z}q@cq3N&Ȃ186~#lHOXwh0 Kn'$$%aD1qi`bζX4.e0:=MCCZ]7"f&on.ke!7QuF$Jh;B#j [{tj%08bfqFVCPV!2+\i橯<㣣ӓh+ it--ٌ'qoAjn-Op eb8֏E:SUg|iµ6mڂ4j[f&,x1͜Ҿ/e.ڂF-DP!҇a1pѶ y>I&< n?+}G*IM $1װ*%8m>X`}YX/-iy>,z))w\.ۃL=8H݈!Xtlww571H\ydp5xp'BX_5 $J~>~knEw@U5IQӛԚ5zV˺oy/5d}}o嗕>xe`b)˜T|6)~/ێ@(T8ouc%A? 8 6AN-- feQnxt$\Ghސ_v3h (&=Z3J\8XWJ1de֞H|F19~ ǚ4f+Ϟ}.&5Y),GoYW6;u^¾>Za㫛RIh4%̠W"jܼݦO>ӸbORG P4Y9knKo*71: 848p0*"e^>4TbpֹSSL$a6iiiZZm7@U$Y_gTah;"cM (*=CRb){40kYU \}ԷfMOOb?ypf__ww`83\1GY񎍕x[h4Ǐ֙/_5#X1r߉$Ftu!Wvrl{7U1j#lZX87\A z;{ipENB_ Al;>^T>2#%MnMP8|vD&O 196P]Kf6,qYZZ._|ju=vqbsCɹaO] ]Cd^o GеꭷBǏRBJX9gUܤ~(%Ŭeprǀi,{Cp8ep pf#=.D 䐢/.;z~xjjs3k_0.Opxa;+}E[7o,6(jhB u8$SS89Fɀ h;"ۋMaFTdJJ3ע99α]>4~,nE7Sho 87jzdѲӟk &R >IAQz{>NQ9wUl)_JZa^g(RzG$m/6+*LK۟/ Pt$8mG`,4" % pRWx1^  dWu!p~ߴ fpZ-A|?/-9SӳޑR]Tn0/,Lp,` 5Q)`L@ `$jC8ʘ(p_OpRfccn^*`p01RbAg+8JKȈ+8CXvD~ E 9bcqX͐Ӻz*6 c`fJ .ȱq[ &8N  Ϩ3>Ȉj^PMn#r8~ MSh+6:-3h w.[,FHjXЏlZSSRGC'!moII"Uee4=6V^>8%SRnN䍌\]W"Q(6B1LB7Tޅ#2'T$lJ"*.6pD38r=~ܨ# 4!p :f.C;㵗X,\[u&bAQ)S_\Fصv<aCMqN Up({S'H`; hN˴ٴfetf.g>Q"t 山)}ÝVRRm<`γfitǐD?p^^<1GȖ!n!p9Xc_-NCe|%~JV=Ӊ9cکZ– GZIow&f텋(rбi\\/MYq"{^tƧf*"ig^coL!oyÏ SJ%rM(SI04ƅ8\Z[9p3()!{RZ68vNa>}zJ 8. R܍58n+Za@H133q\+u:Z x$\/7Xi>L3Y/g&o)}[+(&{HwUkqJ,=Ɉ,8hGҸ[ocnMa =5eK#nl\DZZ Rk54h>zb=Rx0Qђi8mŸ]SRF=LanMb/%(([74WRjvחqa#hj*A}nwJ 4">FtB:7@w S  w]iٙOłejnj.+"ʆyneq-A0ДMh;5e.yQmue%VdT/@G`uD+V:wi6t--܈~T^i&SSyylg0ɺ04A{\En3-Al=5> Z6-xI7߄ OX3AAp -ױoqEali{8T[68f18mܯ0@ #貵J3hEÑ8~lv#C‹r^Ժ˗GGo#{cl {F(xj v_MM-͜FnF7 I@~pik )a7aq[ޢO #p28+ݸ$KJS\eWS$iY̋ң*6.wϷ<'}>Nٚ2@PXXlz=ea6Z56n.+--+muZB1ʄʽ4==xO /Nx/)/h"|P 9(=}f^iknf8 q[xYAZ?xH OMmG8ɁA"nlesrh~\"[8|( $|ii-l4L`$*kuH>VA`?NPLSK>%qO?X7>16,>6nARtL @wO·1`KcIc`pID]yxA n3b18Ȗ bt0#p-)363pV|#Xh2IҢunT5;Zuܟ[EO\ʹtܢdfTK1V4Hn_R;Owb1Zub1o ȊDÙtD`$58̙iu]]gq_4mi[ǀcBocr۱16'8hvHݎYtFVkZf5,&j1q,pY n&G;X` - mhaG9 \tu5>e/1>p\Zj?L]*U_MI#q[yطdYWdeg[ۀU5T暐4H| >Plܴih;-Md'=8,kZ08p.W<$`'Z,4:?ݗiLa]V3LULd퍹qoqMpaQ<|MCXaiCEC&u7 8( GԢ=URuGRMǏG B`AK/,$̋zrՃm4[z-fu}H8HN^̦NO:q,{7wU}rslF',ܕNhԄU "ge ɄIrErrre0dfjI(:nȪ#Pp$w 9-˅q )"3"3ggqGiY}_{LL19Yg)pɴLłuT8j8jM9,8puG 8sf7ʫW!WUxGUZXEh4-930p E56b 0)w+dq}{o1v ^wVI IZdXXH;|zSzhήu8r(NFGEnә\ Ij(2:K*5bJ1[ΜYyJWm"p$wC5-jiqli9~i;wB&%[o;qU]K2MRC5jNGn]"CY`өp8 89**RLGW9]NlGl$18I3N.Wwx4}5:qnUsxWV#lPtLHO)]]]ѥF~h1VT655쭭hȭmhm]a%Im 06B#|0NeRgF$JeǗ8r]z}Rς fa }N't2Ȧpe͉l\ӞЮK1MFcLRe 8p妒9RYWI>blQmCdZ~|ll ;1nt)-dβZ,z+n7Z,{Ca0j2e/K1R7g@_JWX+rg­C`RrkkjHpl++4DR͜6NtlZ0pD|/ 8UX؅t޵V׏cU 7Ӄ3tڌ Y҆AȠGȾߔm@_MqrT8'FC״Q4Μ̬)fh("w" ӎ 3|If4^Of$ KgXW}˃Yiؙ)GZzJƠCjQqg}h?w:sTRRp36&FcK Ifd#-_!)nT$Z?I2U32ض--o7%VLC` *r3}y8p\!#Wbȴ)2&NgNɔ>d4|9#DU8&q|3&G&Cu8qbʊvP908 4Fӵ]&(Yi1 p1%Qordcc#USR/%yxnΜT?}Yߚz0j(ʜ:~<33%nw"X))]R{v xUAʽM ة t@lo@Ge/z`V%q\Q 8Y9C~p89vSA&9P8`9.FF&7!7p8LIdΎQ薣GEw@?DBwA[~TU Ow2? +_G,1&"յquwS_jC&ɻMfe51:pҮ"/ !!RBuUc&cvD-Gl-U?y H^bPA}dRmth㰢x.*+,pEl$gV!vrqR?r8ߖ_?GOHG ɠȴ($0(=ń18G$fY+rx>`2^N* yMiI& b[mKt8ZO ,8@cf*|Mn)eH dXYUn-ny>U0y?ݰ|ll#Ã8?DS~3AOqH40X1 yɞMP:3q__wwqq0Mo)ev{N@II1EV@pXM2<}|>j@>~%|sͤp8\E圹App9V2j4T444"v89)wiM81 ["JXœ?s# - d@`58~8P]ͷ-8 iә[`l dd:-j:rO’C~A0n~`WypTD 9\P\x%& Y_QXT ,ֆPpAәH u%:#83mlhmZYkSLgg; e:^1Lz͢n2zpZR[8poLI8Dު)sHE8"p)&$l 6[G{{"G{1UiFl14:9,VLْ&ͭ088/?/S1 O鉑cr/?FUcqˑÇ>|l@ x:o;>oζU~իW|͆ _z͚w^z5VYך5k7k]!kZjc?'9~ 鶅8'SqA91UgYc2 aCX~ŀWQ78~^w#h bC~"Cb|w(#WܱA bņMoxǪ~V񣢲Vp2;:6 ƪH4-%nRQx>okXTpuU!냩#b-~p0-q<3-h,P8dnoӯTŁOkV?|l?`<!868TVmLǐﭪ|ǒcK/Xx#?6LH\HJ4닊QfCk,:4r?j[I~ZGg֭iG8`"eHQ=Y[̿W*s=U뿇NwT Ex㢨̝Xjj ċ 2{æM6nl[:*lsTtztݫXU.sc;UD;ي#,;pM]7􌅜'9px|_e{sÍ{ א>7F^ f9UP*497FGܸqcl͛_&:C_W7>Ed'7>q_sܞZ$7&'?~矏{FǙظ cՑ9cX1GgQSdq&Uao=c/ꝫL;GFUAz}imE I_ꋏKG??`*77gĖq }w^z;{,؍듓X &&8119Ƽʸ92ST€ n&6c|Y}9Ubb8QlBW+ =Z;1t㏁8cqdgtsc[oS>uc)1:NYwWS_ {aщ//9ƾzAWqZHc lpc8!a Ok?),ZɁT$saBG_>LcۃF8͜" e=7o24wG ?NNL [ƍZpLH\BXߛ{Q4>,8w)..Bfj:E58\),܃ 79n;x!{OՅÞǐƷ_"xx< ėMhoxnbGgg8 l`L|1΀c_$A":fqlUn.6w0q>7`U^s/Gq)QY :8 8R_x\xز?򎏾ڱzU|ǁϿQUo\~goWMޘў?m89^r9L3<3VYc}9119׏G[ jp[# s9-};u+,̲⢚@CGF(`8.;jx`e#(!pYj ٸe#pc9G wڌ_|p}ob:\^M6 }ѕ044J+Ф&N댪bIn ǃȺh5[˝K^*?$IR^Nj&Զ;j8 @,9\{kvC﷊8;s'Po+R]\nv~rZnY>9,^SL iol<rmL,FZ]F//z2X2{7u`$o?P/=-Bfv|$˙cJLjƪQt+{2/')Q٣W\grcDWqH8ͣbǾ ZBqXh1NSqPE |cB8ȂOOCE!_ytUu./>~nJXh:GÍ~Ǔ׋8f DuHUӹ%:__g7Kfh1pL~I]wwGGG+*. LjvrdVQ)O8q[ !6L18C<8q Űvo97O^P;^M> %aXJ$c䙐㳜8okǾ☕/ݓ"_\S "C+ ~#0)Ғhf# INn`"A}dl0/DZ8nIOC+kq7=|e9SzG.LGr*ݻw]Gk;=5ylT9='8ız98T$(;bIw'C!iDhtEBfC\8D8ୁAy.x^:NjW ݃5^q77ƹ=.Dy|5qĖoRhT;8/qE{-rq<3=Uɤ7(gxbD̋8w!yaX;!Ѩ3>xEt:n~ M8 oa8~[z%)@8ı)ԩ8 y 7zAgCIE8X˞l!Yo;m.TqsCiRl(bq[n cnڝ$c_=nUK/+SF&hqM)qAk+btzerqݒ xfLpwW^?zGh{ġ엽F[{ Վ!U<>Oqer#RטF!o"WWNM=Vaa1,+CVuO< cLkKׯ@O-hJ/smD9qlJk:Mq|02>S"Qq}ⰥR#76࠾_ÇA~:}ے*9sXṞ#%QޑgY'$䦕 ;[60'x8.{`'wuTmS*JYeCaI$atx^~nf-å=;v$1b-N9qՋC:yq"ı)[?ġ ZܔR_[{O QúJ#\zE\fkUb(H$$.2FH<e GV{'a~TUY\͜FACqs3޼fv5G8kSPeZP z;Js+}cp"W>Ӝ'hm{Hk?x^rE@A}Q-M:Z Fq}}&֧0N֦̤4 `P)l*9ʩ)֫x^ғ1:V/\7>É86džo|bq|Xq ҘlCreJ>FF1nOo4Z9I"OC)*#HB/B*/J&fFuj{υ2 v0T@9toareߒ3[8G[*|-20t66}00rg^Ϝ7O#ne Bvr#[[%$IP"aKG(=iA(W;{g2%T1Ǖ+W\Wfűg*c3~QTAFmgn.:C$"8N3@t$h# =DW}x< sAOxyˊKl#R9S3gf8s^81?ߖ3:?.x\rY'k>/K$HYefUe%]BD,ke l=p>Rv5==o/đt <,##.c}9bJ8 6% Kq?²`5f!E^8>8G!c/|aԑ/.ёT-7O$l,䦾' YyɪD))'Uq=X8|}uUTIqe6ܐxqɪFIM^ϝX,|y_t!ұRVm;iCĘ N12~rƤYzND?;4әgzgY-yT "*wmVq8"m{lz(&:|pp s_a*KUZ D(o0!d4(`t:f^7)` :q`MRLiȡRK3'Gcl"[%|zl*CUg,,[7o~2}E>hh߿qk*ZVkOפ42B2eUt^Cp)qQ*uWdFn~99yhKV 88$*447GiAaC Y8NlG2wml$gmsñ㓓_oojIR"EUJ[Zt: .yfT* $ӽ|.oe2V5W˩w;h)W{z4#Gn"hHf[A=r8K믫**z|-ioE]hs3 ijzz>29O>ը>7PH|_@t8Mۋo!8V)ŤJöV_~4 D7n\'3U÷82=:V_\"aq֬V#+*UAnݘy˛_|qI&)W¡%V|?zKon"Imgl{nW]7577qܘjV$E",iMCN'iT$Mj4~oohjPpi2֦Çj=:5 TPQ=ݿK +|бm\AUy EZm($'U{kcT;T*ے*S;eva{4RMN~QKˉ*Չ֥tBqȱ$˞ť۷VGlX+&!%%[[qО *C*ѣKKD:-sGDKıۿ->y{OQz{4h[[ RnkR[pĕR;NOP[J/ǩӧs8R54@P*mw2U~V)3r'8iNqxYʴvTwǯҝVe.=š8D(yDβߟ=# /m~ѦXNjo8*+G  + }Ń{sVlvDY()T>=U"kDD,v8H$UJ!h W~+*CT(`O*٬;TQ성jcܱC@R<2wl`où[IٰwTn%?#qأTh>!UEsU8Ak +ܶY9JRj`;m/TvG s*:#Pм z9V ebhl Uh+}8(:"ĉ1+!~L=]eEDb;l ;+y1yaQ`߈ʔf[8} xcq֋܃*Ga(8~8c(ADYAeH!P=vv@13" 8TOѩ%*P|P 78NoPhQa7DQ(LT"Ql;6T*;:Td0q@ԉ@FрQ:(KEDEs i (öE5|EQ-ްNq8Egۮ@%R8lClñCMSM9h'͙=:R+?Deendstream endobj 54 0 obj << /Length 779 /Filter /FlateDecode >> stream x   & (*- ', 200. ('&6/:'2-/S-M)>k>x*X,NG9cM'IrGVN3F8K+2oo3n&5UHR-QR8jmLk-Qp3gjlQI MK> >> endobj 50 0 obj << /Font << /F19 14 0 R /F20 17 0 R >> /XObject << /Im6 49 0 R >> /ProcSet [ /PDF /Text /ImageC /ImageI ] >> endobj 58 0 obj << /Length 1026 /Filter /FlateDecode >> stream xWMs6W(T0>84M&D%AS):i}wA’Ii&C[,V0DjLir1*פݒ9F @˂Kjsc_Or `Z-p'F6vF% Fs[prqeEG?aR}t-?Cld&NIEM.n%,?) D)+,7w,1/U-O˷W$7 )+M"p* W0^).IN-%pƴΕ7o&Iϼ 0&A4feҜX*8 NbBpdGknPuNO%.e$] 3:]´9k&y&弪7% ­ĸѡ=>*vޖӂᩮ1F"B@Z5EZtSqҦ0Ҧ1[mn$o7u]޻L]㧬e3o=3![Izk O"G쉻 =m2gLjPz"~"\CGl vmߗ_|0zsT @1׾GE' vʧ^S"D7d%a saEH_"Qה YQ"La"Lc)ܻeG R u 1 8qF>1<|6v¡8x"Riף+W}Y1H<85XokZõU71Wՙ|.Z`nP(_*N:뼘'mgI- ,8oSpu&?p<ˬpu$ 6$7aq;9fpZ{wv 6K:1Ř=c:7PBQ+2Gu6_ʱ9۾QǸ 1endstream endobj 57 0 obj << /Type /Page /Contents 58 0 R /Resources 56 0 R /MediaBox [0 0 792 612] /Parent 60 0 R /Annots [ 59 0 R ] >> endobj 55 0 obj << /Type /XObject /Subtype /Image /Width 1593 /Height 612 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 255 61 0 R] /Length 58759 /Filter /FlateDecode >> stream x}L.jEP"X{3qІ6H=j!$qv\{DX:6S_],)M@V< ✵Ӥ@N䖕%#f~`}όg}}* R/X*E8pZVN\;(*V"WTWLAAA  !p/Ӓ"]-Vsl?ޕ !|s8w/ bSɍ_9قV|?l1RXXhx=1 r^0xz~w #2<\DZWHFPPD_ū`O}iC6kҋ"`D4>ٞƤ1;v|:^o w=0c[(oP$";jk~"􋡠xy`/fTjr, 0](fDv]!PHfaUi1X㏻x8 P2 av`?v6U/DsMeM&5/8EQtttz| Yi.+ZCo-ihcqrP$ n? L8.;/[\d}׾*,)s _y*Kwt P Kc.+ ;x@i%8ͱ )+mQPm:h!:G}. t;z|ذХqb'Sy}n$=h^fqGj0Y>N |Ճx`[CDwPP~Xu]E6PTsAA2e*tiH~s<' ]!6OMpK -JTW;3K,FD}7[yI).>Nj_9?&E>)Ǒګ9UJ; \EVAAf hnNߟ֛ӑD-ztFQ3CwhiĄȑoX>g0paB5KmU[Gcp0^:xb?JE"H.MaFûz ēS(BH.-$yPӉ"#ti<wW֠mGA|nb@qASqzɊyeesVQ0}9$ڎcNݯ :Q`a} 7&H*eS?BQ5*Gg1 @NU[a4GDf!|pfǴxG=/Vt5z\:a7O>|q$Vn7Ʉw0дnpX0,`=cKOźF,6 ==jL t VH(" K݌&shHn"dIEE9aEiȊvtwaa1!d%q:Y:Hr@!O bq+3U@=8ã{qN~؊ ƢpS hrPdX+1[:^UwK`c}^Z P$ o  h \ͻ9]Q9Ŗb(@ ,f>KO7_8sGDI$p[Ɯ] qzm G zޞH=q1 rU 芯HRSGb+`6.S3>!3 46 @QItOB;zp.#*6ʽ|4F`*K缂7HqH|T=$ R'@"\C5Ŗi?`bx "+SzCR"fDk8#*2t/^_Dy7>kz|'Q~1G'p49B詠Xo`@u]1|v#y>BϢhw@9")u 7c Nð7[E;HJ,EԐ%; OCe߉ s|5UA ,,o%@\'?ABNDZ(C42ߠHˁ?#A|`J-ؤӁC@n\&̰#(B^hi)6Pl%KEE4R<y%/H22 aDed`9`(5rǼ}{啥y9zCl-EEoN5P$ 5z:Gԝ31AQBG:Qɥl`(+qHU^e4WUU-ڨ>#4kl@kM![C>^{嶪r$C>HWϱ䃟tW7 5)qP#"G*^pH(s\j(jXWLDTQ F[EY-F$@$dT@+|`kk@lAI"m嘏+*X#菜ʊ*(Xqpœ)MźG]4l*뀳/R zjvH$P Sl.Hb`qobb|: 0hє$ǂ{ ee$?#a5D g-,)6-x \ȶgϞ=q8ߗ{Gݓ?J=G p%48Uy5~32KÀ4'Ƈh2!? K? #v<G~΅ᭅfXފb)KGY,;o߻?JKyc% Ҝ*|N1KWa< 41^Ð5wB܃lc'pyA=F(֑88Á(u߿e_R%uWwɊQx!8{wo8Ɗ`|p0 ƁPD SL!6RqZ=66444642 > o >́V,Ggz3dT?&q<'8)%uh{mm_vG0IR{l`l FA07F6)s!B%c}cQ~hh4TX5C}}Ccx]]d\AıhL* b*[%bL;vϋQߞ2Nb=v|#'7'm}=>@|(LE}ŝPhP, Н㧙)xE3df ޾A&ɹ۩>^^gc3MhfxG<=PAt/Mt난܌C`<[eNN,f.Z%G.ڳI[y{Q~+!JfSJE(@pwp$ o'3QXK€q-*_$l+/{ ސp9(|;ṃhПWv l(skUPmwv!;PU^h cl둗_і}>0wE2Gv 'OOtO/ (8RXC<ّ@~uُW $,%~IQQy\[yXEe`M +u7aH?cQ6,o U*XXx&8fm2H"⸅IjRR1 5̝%5@K}&rpY~mc̤Lšyp|FFa@0U׃##h0X.ھyh7+rC^@x'0ٲdC_zA(֟8P\Hey=}V۷AO.uv}0s=wO?z,^g2ӵtϞ=&Jl+&LNJooΆj`ll(;KqUo1>>hD~Ag~ 2r+G#e֖]< @(=I@8.$wꎟ\B˹9=9Kp;'A9p g>@7AIܺ%a`50&ġ~/@_Qpܾ1( rFqxnldCJya<  QL=TQiP/pTq m ͅ8ݓ(UVҐ*'8>]̿1dL0St\B.G6X ee7CXp PS갲vW&׹{ vwʲsCU tn04t0` 6Pð^%G^_x8 J } QvnFv^^A8#UM8"q|爴@U}˒zUg/!_LYB#Oey u3xn߽&g\)qP +M ;V;CaUe(\P,76bx,Uff{( heֆB2<ѾQpOf;< e}lC8&(֕8dg&qwg,Do<̢c/?+%*n:9:lBbUxqk,8 P(里ܼq+}8]]aUq7%Eԯ S$1# zb#XpYeeNƻ?`w=* %IB'^{8Oşa,OOK?-z68޸}PD֡RX.W1ZDhq*2rk\=N쾾  }6~ad )p8t?A=*|d*S'Pr!'?#< *G!A92:n2!1qw.\ ?\"]?{9[Vv8t-tmRA:L/*;l41j58 q: S7!s ϰlmyhǷ(PpQ} W}(ʂ2\Td9 PpdG.WK+@ġTh8'g;a,վ Lfq's3p䨾s;/)8(VyۗM>_ ɩhHnS23< 쌶6@hmӇ$OR[.2UB%ƴrh("2|NN͝E=G^8o|wqߢT8稡 a*癙'3?bU;jG)qP+8.?(kHqT:4brs"PH ?zV%ġܬ@EInNRpȘh~N4H@,VUb݉'XLJUP5Elq dhܘ83N*Gt> 7JųJo󯐤(q(2 }?@x& /Ut**H EղEΌd+#y}D1EB:l?Fh=c[*vY1#3 J@>vLtWlOѤ¦Q\腇r 8qr@iks  F+݌ɨc|GH+8*i*u'?BpS\>8ŽqOQ! gۣŭf˿3 J/u$&*{#ATq B:"'cg{ai;!ʊ X#99LJPĝ\;N܅V-8Cc{=) Qu=х〩8ci+.}ǿU)qPԁrX[1n8*a [`=BQk񑯑b`Qp(tsʸA9B{@r111iC~s}?G)qP$ qtLU03T57;IrÉ1j⾸0*l:M,"`B1KbtoDo#/'<<>Dss'vD>B~a-?c[Cͭh m|C::} qʬ-^QyL*sEUq x{OYY.91~MS?/9wɁt)HDR%]?HuF(CP?&ϟ?PPqn<?g#qtt \ qALWbF8]URSE376{ﳸq\pO ۻ/3?r[OQfމ"<%eEDyoɷG(~F`p砤Ɠr|?M$1;!88LJ1(UeƂApx:)8WFGPịj`\æʸoYOceNXTF5%ɛ߶w\$0!1)$;&<ř%a\x LCbldddՃXpCUfdAġyba\H:gP]qTׅ"u 6+1ɁUZ|ǟč6{VO Yǒ*R$듴Zqqy4.14>^fmpM&&d@-2̀3rǍ!XT-9_:}9s2=q$K,ˊJsߓ=ַFYЍ|UUUQ b8x EDK(FW\/} MsX51[|&h7v*2 ·?zFƇFZ둗XF}>0{"̅OƢaY/>:m"u`.))b-VKl= uu!:Dӗqc48ډk| m|N(4 pEIO\"qgaU!] se-r5|(Lm,`) mUA4FMę͙A$XR}q=)@2~:|$1{x%?<d1FGFnIVhoVSF$QBzcxA `0tכZ?l^)J?0rq(y@q! yO1 %r/?'K'b೵$uH&P|M hH0~C~F<}" GG@(; s<SxGzp(eHQl4$1'~14,˧_E0p"q|ƀ󜞷zEjٽ霄ebK'h8~vQad*f ,Z;e-ör}yzn us7j}7L8ݥߝ T*F)0Ep8 ^>rcbBZHvzMxNJ%v'`N0'Dx(fD~/fDg|zbgZŅ'# !' qڠ)qfXWg}0,%qEةV:Hr=+3'''?gx|A@s O +9;n1'4$KiEȈ"'Kc4鲲t|NoV=wGI^ocކk3?=y< ''_RsLbײF~QT+ 7cVvPXGtʓ4G6LgnnҔo+ ^yeUUT&ї^sk9L PYUd҇p3[A++*QoΪʥPکuX»wz 'v2xn$IW0^,6KϣEge1z,z`+807YLU2g!pΝ ^d;z!Ns"GH][veh^yDG%c\WA#'=tUتmmU1 mI6[%d൪fA62 /z %M",*'NBbaԴ dDpe 1E6K|_'ҲDFx9z ;d.x8OOfci߸us1cfרBaYgw@/!emcv>ǟ,E>'\JǸpzɍ-:B(z$/1{/**ϴmmPi弗2JU⩭ n%IR3Own'b33kB'g0q}r^o89; ҇$NV 98ڠ(}y6 {Dp!v*D9mmey0r +.S'_]TIy(8'~P 7L O͙Pn!:9}?(}լ(әt7"S  ð@xpLj 8XJoI9Hޘx*,Rf. Qך8K+ǯ=lM稿=2z bQOR1If00,Æ O\U!T^^A9$?J+X*́(+/-rl*GፕiNA% NLM- wt6DfœR|TђyiPECo' 'v%pr]qS?? ;wod2tN>e1z>.a:nh*~%Iېnud~ S}YwN/c<-1Аeiԩ jZE7@^28Ȓ"+e`^ 4a9~+>wJRd)1B֭(2 uF<u ։YwXzU(% ?U_Dldl3́o;.T$VO>.C4)xOx> rc mshZ痼!0+6.Y -+%"i|~>82-3?uq<ov vɼd»OpVp3.eAǰ 嵒כQYd/0oE,nq=?x G(MpX zl0ѐ2%|: ,Y=EdInis][w/.XLE1pGp9 A >9Q+D8@4N|,{%M$ǧb*I"lOxzc? -V,mtL#ru7<@QW݈n-Ro JR$%3=N}-i Z_d _JJć kXhx|P Q&)y /+m'/rB1r = ޸h'QpTYL{' x(o./:h8.76ifR+@`ȖUaXNyDAKl6x|~h.2͆gvI_ws7[ GE K7eY1U'8ONjx0VQ*"ǰĨp+J`RJnM&lG!njFwkoX@0&IS'Ow]MLzj'9;"]+!C*$pU`륌X&ۍmB>Gcu ~^d@l9Qo1G s :{waG7t9jp!p(4`Ψpi~,Y]{{tXA3z+mɡ1qi'0njO>kny<&Z9D@<khqyNX,G;ܾ4y#UU?Oш&!x?'4OHf}ԻwY_|yt',+xzS>*V)YtBs8vZ#ӆoU eLzLc~:(:vR': 甐86@#w"{ሑ/2i,s4=p#Ncd਽BݐK;hw zf8訾R[g).K\_,:Le"w]!rB5gFEę+d)*6m[")ʍN!~@GRa./4.UD$EXHX3`5{%Bر>p|n G?bv*`uΩKcAԉ͋qvNH==-=}N\ ' _Du6"nmnn"<.a)T,@opBo"ijqTMS]EX ]Pq\U t,):vx:nƒ8A>F/$Qı1!y4T/fziL6s5|\%w`ֱ b$Ҡeᭅ%E*Tal"O\E9HxqԕxiֶKz0-Gqa2){0Qh hB>뗿DAc񦋄B0n"o,E^a%瑩QZ[G~sК< KqV7hPjQ2I _bY6F.P'`Uo4=dBcpÏ\#熩[7+84[ ?j'1xi^w#D5`JJTR$e񩰙8c4)&?+ D4x ~SX"_=~C=6͵Z>b57U;phBmb9.րg6PSF"[ܻwj#˅EsȨBxDi: x+#">6,%RCӷ[uu !6ڄT}_@MzAT{ 'F4r}(MIilBj m&Y:bh 㰖aYI(2rJ pMlP(]ȢW,U[4CC^x~9e{qʴQ}xQVsTPv )O le#x;@p402)T.hf$Y/jw&% z` dECw6~P,%)'hvI&fpyE8Áh+q=v|ʣ'QXe$n4I"VQV|X߯nK0Ui[Ԑj\颮@#8j9Lu463tTM:| ւ@BlH3%x24aG QAJJYnFģզ`Zp1<`ϸ, -˰?n)D,Xu ѪRbixjbEFحd^lTzTƎjnDudR ġnK^;@MxaFhQu9:Mq%R?Fo~0\>VdԊiqG=Z$E! @_Z;, b"P4`3Ѽpp6cBmenƲ,j'Փ:-uMs &ǎx:_I5ר /ZޭG5˅*-N]#KoZ4`[L4p/ݘ81 5ZmS3n7b &)ZLxRllcm1Բ~[[t<ǚv(L&o|%`w$2ӭ(7_ G=6^ײ("|R EѺ; ƽ)M6[[Ж{9Aˍ$爣B$c˕ 3?#q8uG%+.I~2T7:T@'Rq;R) A2st:7#niA]baRinG>v;Y<)7 ;wv`͡MG-TWKa!ES9B nZEgܻj5m/~T1ؤa`x!zѱ 6mi\nhji`4 |}7d]*Uj`H7%jhe-hU"1jrB-R5 ɡrx@]PC؏тxc\R0 ]ZT 둰ұ Dȉ!+U b@J}KP1dL} mj`-2F1 |unǎ;~c+kqKӓlT[upY 'p5Z֨u j7.0̔݅ U;\N|`ߖ*(qCuBќ:"u}jI26/[ _QE&= †+8v>ؘ3iMj0>J+̓w|ےJjz$&kP[%́|=] eTaGj>ثq7R%E%F`>1ui(O͙ z能ĝNwB|868|ۚ[ܽFP9f,h+585Pc,A7(Q6jIF=<4@ p\^!څqw@mEudZ<] Х22jUW13\9R{rtτK9:$НP~Zr,ݖ>gꥹ] .\|@JjX| yG`ĔAGfӉư'nh2%HoWƷ+D Z*q{BhZxVN'<ѓ#*k߆{m65Z QE*{ 88ۜl#v5܍Fb5i,:ױJ1cUC3W囔 Msx&OfGb. 1m&֫@BkחImMv %!5Be1?446}vMxTm˝FJPx[s ܒMr!eؐ]5jp*@w'}1l#j-&xC![/8U`v;45(QU/ӕꚚJ量2G͑Ý5#=`"{k7!q(&߉E%oI 1MtGR#_5%O[4+)Dh@ٶ-əe0X̻MJAhB7#F_r|dn)crUu+ +bU/` \@OHGVgB.Y6~I.T¥\B<) ~OI@ %s#.3⢠*#n,ؠ7~M^.>:: 3&eR9cQDht AHKV]z`Q:*2l/,8YхXdq7bT~㗨m&+s;½b Op" ,D)cguIyCZmdMSDž7FQKlTP\4T"\X,VeCoZXPFȠgYVp"㕔$&@u aTSݍ%-44zt?&`d ^poJ0XR}lBR~V-17[wo[%#L#v+l4*X%X1^'MdCu@- 񰂷 7l0E߃{ȋ a SXp47U*ULp|Hg(ڨMb!ek?ܻT^5t#CδZXaCKe%8Ӎ>F::'["?&bYU# r )V@B*<-8pԬFhD=#=W/v[elлv zl̜a3Vѽ(\#u??~/`aIWMI0 ] *i$q`@*:"K7luBh$AT4RABs2"4iu[ug M'`5e$*86KʑF77l{[eery7h7-urs!WW:kuQJF{rqDzx5z"ԅ3- n$bcIVFYX?<#=Kox5T!lTWQ~J0pUh^ pi0 8Y $d*Ap4xtO ۚ pKZw;o&,ɴۄ|p1.ٮR7aIWͧc5]Xp^\ 5U+F0[8H,Wk rpK3"ѐs}M>^ldZQBI),\ȉNv% CgEG&hѱ7:z[ra#__6w;9?խ8"!- frp~nR"8JYN` :>wo8kh#u>ࢃS_GL&%H]*8*zsp0. vjªW.wUƅUa4{ORT i q~zó)tssɓƱY$fψ>ek MK"Shy÷$.RĦOzXE.%tw/;ïDյuG,sG \€ @tN:]Nǰ:3?^W&cب/aT`d2a`%s _7Mo᷈.$q==[os=ysث>&5V[yz* h`|(: pB4xg`-6Mf#`b\ڐz%E7r% dr&Y6z06{*sE5q>:9:펵$b'r9vh09Q4X %'{Dhѩ{K]Iٱ1]siṮ7Y\4mbrb^ wk:..#]k1fAɢ/6'4K9=7ffgfL&R2%: L4!ucV ח>O& sKQz$cuPQTAUJ[Ao6[f=@ʧ}RF LF;èuc+|ʵCC7ų1Gg 卷͞q ki?q$daEY5B8&7uX F/1bVhԘ\8>%p Ro=y]5q[k SYQR_)2EQBAS\UCYƵ1ǎvj7m0GD媓~K T%lTt+%^Y)n;.7JP'L )jChC:/D߲̱2:>XRސz牃؊Ѻ +u%R.q{Mu, +Љc1a;O!ƣf44NڈǍ?jZT$jp_,G\VC {?܍{ecdži$C& &l_V&OPj:I/WpnrjdRhD'rDbR%cdt, *Ns 4ikJDwfK8~8йԔ3U[^z{PP׏"ۀEqU/ mnc^`<2L$hwo$כ=pgDB\3UAsݙI4]5Q$M"/ ;Ό;Y9@ˋtj}Qi_ާ̿W r,Q@yMl!K!3R/^/vΝ\_`KƷζdlON8?b#UZ'! ]3[QӫܤgAlJ}Qê!Gn+L#Ggg+zŖV\ 8BEgﳅ䯆 mɁjcݡqLltSA镺ZWM~`A %H_ٺu+"WHjk8ժNm\n~K꯴q1QW^.WѡT @ K9ƤfIo8y<]&/ze:h_W/PloT Z^ Ȫֿi]hop +r$7ҭ)fceQdt V74}e*:CpSuhgLSFž[wW}{nQ91OD*8}7sl:^:"U=/ɶmg2H48o#yS#J_t. C>Wm$l2T40@G)j%zPpS_!s*7bUE"e:-a܋Gv<#TKRsxLVLL;Jq HPpS_!YOB8VC. ll::"+=󨁥:xաBpy?p#RO)ǽ٠xb5v_ܸ}_am~㟾ܮ!j բA8|8 Pĥ 7ГbA+@A6ӣMWQ!*P:$TlD89j118&5-Ur;eZ196$d},EITqOy|_RM3:PrV%; \UʒK990S4Ii]D>EELu,)ß\м3:DqB*cŸH\=6RJuY! .Ϧ8ʒrK9>\7N-a֞ [Q6?nJ95@n*̻4Vtw5!"džta(˭Fִ|nFE!G?SWS l|WbCR\8S2minho~ Gr^;2+Un)NFֺJ=βEoܯ'Tׂš0=B|c*HOd>!@`_2M}ؗw5S$nP !BJoп޼'fn v IWᰰPd@Uȴ79'<ȑ#.M|rx˪lYJ:4K'`8*69' /#Ro2y1'k=R#[]};pԣzBT_A$I]{.farK:l.#i#46.0)v?fG]-F-*\z"0VMl]5\\HeRn_71>EGċǑ1S\D ՀB'2vDŽ=U3;֎(8δmh_Cru84"$++Bm4ܑpX56=eTT{kZ=e#sUizytt744Ȋ琫X\{Nv ۲" 㐃ˈ[E⼗Hzeč-Gp0Kvtt"!W~*TBq}QC7|̻4\݋ƍr9ИJ˨CJhI/\BYsuM<rU-16jOC{^~5)\1'W*z[b{No>xLB5Fuc3=e-7CK(YU_Nq]j}c,O29JI-#ȵ3&K奦S쪊hzk«7 qQ.x}o 'BG{%ϯj[R˨#$СrhN!/b(;"q]ƨ`a{!TFq:.f0pXM/d/#3xzKtٲy ΰL/1`Q&.7`/b_G5DVZ"Y6p5QHW*sԪ_To?/! p؎!cqFb-p D3#\`8Ŕ,#ʽTo~?ϰig^d-/v99xz&?c3$q={K<G"~? ͠eDu$*_shխ}sb̩3gN~{ꥳgt\8sg.|8hٴ qdgGGh ͠e1E/#ikK:w9|~ lآ)!N Z_"ͺ)/u|g.蜘QP2:/# RT/j;Z~wP.USsկٟ_S/KJV|>QZ5B )[xu_Q6*׍͢^<{J;%U3] g?Mm5R28T>׸69%}G4: =W[ׄn]Q6 &$,#(K=[ 6KgOIC;H`Wmys6J0:(i.gH+0:|zXFZRʷPzDT< ^P<ͯ@9ҡF c2O{q/29*Si/#C=Gœ貈R1WplyB{MJXN }_ g2\L.Y8*T`K6 !氛 ZPG$iR'; G4-.AAouXFl ڋ -L[*)}xgUwp{ܝϊ X5*7M/tD>e5Y)Bo퓚]G&BrE 2:Ӹ;W/",# Y vǏz71ͪw >{%}WtRƐD9QBnX qcKR"pd;W+~;^ãyѫ$82*(kp[F$,#X΢0-:W)%t$<}q~ S?aI*r8I\ݝ+*jeOƘ-հHҚAEsXq&{cN#HZ%a^' _h[6e$Ř\ iv\KrBKa!xΩ>AKJ\v-o"QpQ*! Ü4/2_3eHRj]Qpeq1/E8~+r KC`{$tNc\ENX}208I(rO|q,3s#\ iw\K8ج*{$>5 XS.>hT+^ +^ON9LC#)jSYc"\ kIY4OI6];3W{;L}_ \ep(w9/ΪbBKq{ NhqiCF5sQzBYˈ |.-p5☎"c19"髾kWӪ F6`p8t3K  j\ RGf8hõ(:bnpP`q1%%WD{\ YyṣWnItqAYUϪru$B)2hVT~sUjOtV u@W|nbGxP^5l=@82 s[>Kp/$)SL3gϞm|Eo\dz8x9%c_.A98(\c$'ݦeD$+,3L0'1C誊Րf]U1#.;>.5=)Łb%HUbl~tw\%hSesu!"Y :NtFwǥBs\?) b.[kd. &q5_>.Y{ǡQ\FZ5H}E'O6EtuWHG!ݫjǎCNGK*,nNn$uk{$bצzMC;nhd?EAEeJwy筯gϊL:Lâ1\/\^w"_%p 2 ׎P/\2*6SetQjq(/#9 p%_9ˏ~'`#V/ȋ }|$ٸO|2.\:W:GHwS酀B=T rXF9es$5>%YՃKm8^~9 s^8W"ȑL2Ҝ>LrqNpǯSDVf B6nF,#_?x6[c]K%r~ 0 ɾ7yR3Ry nU;Ep1d22;9 SLD { C?<Ǘ*Vt9/Jl_PȡYSWae/ZFS!WUhtN(P/Jj5 e[Zp]2/喱8ج*lptE:0ܝJ,(H ɤ jƗtI0%b\5mhpǮH8ybԢqbfXFZFzF-ҽe$(ǘTu6\8R{`NYz5)Ր+HSHE/2ꩼ l6I:}8>c|Jf,^q*qH ^Fd AdQ*ռ)"lxVleUQ|p6,1+Odw-8)=MKAXF! R%Oj N ]U?! @Ѿѻ+P%QdgV ra8,yS j5eoOٸlCuW]OED3Azvm͛[چGF` $ @2҈6D\UK[OgUJb$>N9(y/ɉ^`%BDAJ.#q0A!3HM$TDUv2Rڈ,8-^C^=];̈|v=k*Jݼ)~U<*ѥ<3z NeBlVۏ%ޏqQE+9`Ml:@Jn8y2>P}L}n1VQDkB(m%WxK4r[g]U͍~ш #+6U;$tS:/u Z^2"\FrȘ8hygUs_pkEEtW+GiȺ*Xh<(z5oaҩᖹsU_)gq))pHeU$@Ndv9cS, %/ﮊK6f J5n:o]20" ʬ؀ӣcrUq1B=űD{౜/$wxTdu 2yp!hT65'3>w}%"ps\\a}xTCLeອ$PHWаN:N"vpJ HtM@ xx 4F X7f8g9̏qG Sbp`hdsC:rJі Hy3$芎*j8<RR'7 "iDdFYx*xRތ3\Wuڪk9pܵrޏq?SvɕତϪ4Lz RNαHtK8i9ީ{8s=}9njރ#6MTĄ76D,sxq|?|6B]88l|C; 4gS#Kc<6 U0jGI7pWFnoJ='ANNBQ p42`'7JBDSKަ 8v bhwb;I78vҜq ҁ[wZ9V\)aJAѡ Id2 Uqfs,@VCv# p|qqX[ gcSKJq᦯_gO UE%rBL?+өGI`4W׻3$CAvtMz?e8# }n jHj銖aqɸTҘs㏭v=mKSϏW d>KZ#ZMWf=;$Aou{-~&g•>0H2:#xn[dMJɱS=%%mlأI=%sOf.黕=Ds*ekJ2 OQ! l9s$='5xx{=Ν;Hi,bH#!XWLwo.YD ̪\#m2mp_ԫwzǏf_IN-p{ҪY=V`u$_}T~i3U&庆:H UÇkp)8qW*hJxaWdɹ>L[P.H.c0hyN^|(y,4zpwhVs*$0ɒsW]@-0 ۜ5*t҉2maX-㚈}P7&w4:,pWT)H#%q8k8opT-{`i6盺3eu@ T5Mb.H 7sK#CKʺ?牁 *Qvr,@hwB`1ۀL`%$o$U˪>U`V Lk~nٯ5JK ( nY F'ؐνm2yeҾquq1A .qߘB.,\*o Gr5,ռ9I|{#ްo`nCULZw4-Nm͔PxRt ŀJV:pBC):YgU%*np, ]_VuNP%mBHWdF)-`HepG&eU ;HۃAV*vRmo+[Gϋ'=^DLU=xʮ|t(e-RsK' ɸ`2`^J` @onT$O%@W&LW4)}.ȮR5MY ҈ԈGdlZ72UJ8Z5 JqMQhL,ؐhdThkYFG@ﳥ Rd{x ji)UGsn;8)]j4Y< A)GhQ%VSKzf MZL̽#r]KWp"gg.lTTTf\iRCk5E a-D-li0jrzƵυ~-I3jVjݟmF:41iRDZܳ^2gLmQhNyc-N fziCXOۍUgni@UFQ%64\Y1;k7=W?uU84|Ui,) Wk*p>RH&8o҆ǠlD%npW-dT(h;ߦ"~ƺm=j5wmSVU׸bp"hlD1TÃEק]s j<k"oE{ZCvU8,\vd.kCT6P=Ic:-]eSZdWVrp~(8ݱdIP\ *ݯ Umj]~1$2* zHT1'+Ts Ϧ aHt] *ee4 mGd\EIm}] 93SdW3D~1R#,\2~;L66 T(I3""vamE2: R v!*r+/ 7`,sτhUU|)x!aCŧ_ v֐]le*ޚVܐ;ϰVgV0WuiCrfW]#t/\]"h<bbS36{V,$~E]w;DPU$F0m6sNo_jFYvDWt8uUT/\mG #KlM{Ku}1zج*ESTeWpP,8zݲb=l/5YI^6Thlp#(*h4fEp{<@oOv6AaXAY,%%O[+/{,?w] WrvdR:B-mScVYAǀVrGj*FI>KtVW#$xjj06< 9< 1z!p[088) W ;&% (nRU䰩(p*S&c ߙ:dXs[Chz=ud,#,ñQyApemGaEE|uM#GsŔO=̮r8 8p"xWde!bq_uuά*`s)/'Hu*gԽ$ WFS e(Ѭ!!8&`c'W)ܮ2oJ&h" bq:,8cjs/8!{Y'yY:Ld;eݸW+ҭ^ƣrTJœ Ʊ1_rT( C2qipny#É ӹùG:05jjՈ:yy:ɂCg2]TBx%#vDK Vd1T*(_Qŀnyp18cp݈K-YYL- U AVNJox 8 T S*2h,?.FLHO:sE䮒]aП;δ!P0`RDA!HH>yy1<*Sa}s.f"Hƴj + U6z}9K TĘ%P$Kz" 0[ǀ[^+J:eaT (Z~aRS;*Rd–UT _DT@B˽rogrQqNonF5i @Da#To`U(7ϡ 4GyyNQP-K h;B g &GB3쮩`캗/uSLHVG =x)Ua_.XQT|wVtIQF}!8]:~4eLT )CY]m1&(s 8.q4sFܸ [ YYrS[&Ոn;3Rfn,SF&8)ac*[Pfrq8l?8qjj#v;E Tftbh\\R;49V/[fJnUPHd[AI(QQTвe\-n.U)wfY.s}bGLYj_|Ӊ7aKSr8Wv;A`̺Ba8op|QHgsJatb8I7R 9weZ-ziarnz KvjEXj,+z~R'ne"p\*,4AJp cMdp4M \AFvr5oht:kjyp޾/)O7QA5.proa%{FvJߕ{{ ٟŸR48yR `ۑJ?07!vhCls|m*~6X8L˝llyUY&ؗ#kBkU6•g?O_ M%S p~Bsrpc5ÌmnnM!ङ#M;AO:EGFua !3dm% O3*\AQSS 9/?hl8WJRF;ڊMT@_Jt=2|r h:.ͩzQ@FnoM&GAuuy]][Gy'k͈8AL Hcpl<ɑʶh?QL8IkMUF>5jMD͈4A.)nM~sdrL&{Ydq 8_Bb Þcٝ6NwjE~nUZe25wtwXG^Hj?j;B paZS25c{uU2I솤yQ jJsqa4ؚ`!H t:;vɀa51~ ctGJ&[!woeQ[(YZѰ̍dkkU\(:4l17qڜ3CX\p\(*z(rb#Ue-0SSAs<⪭e }WwABWwWC IPQ7Bﺻ~"ǴvD0xfBc5fRlI0 Žɕ#Lj"GѻG ˗BG.2~S(Ct".tv_n-ra{sHlpDԫzpMs1 ̍fH fƎ0Y?Sӡu!3q(ޮntg8uujDT0o=XO4sQOOo,ZѨg;喕 \#CMnU%7Xo%EpV}\F7#՟Eq5f#P*JYuq`]UǔT5֙F_:ALrG@SdG@7O 87A+.B7,-ܛ1@?zKI,K)julVkA_Zj0wwc$zܸ}^Y\I')xHgkhT?AvEID4!18֜КQ%ߵ*T`BCП?πMi8 ]٩`pnuFANݱgrYtЎ,{) $Wmdhp 8T1RQ9Ԗ1J/r4V .RNNgΎjW]^ gAzz:ҷ n/BG?F#Z\MVu W頉Q{ECوȔێ kz^zģXԪHV5Bphz48rn8p|{p}ߕ4=K3[π`rF55̌*h?~j;v,GR pj  Z#mʞUNTar4v#=օp]~Q`ճQqL ]8Z{zufU Oq8)OCoݫц]?L&Y{ͅ|':WRYAџ[S ӼÿҚ}ZNRG!>ٻM +9n`##']4G!.,.91_`Zn"z_OEJ./Z; WThzƤ>(GT1Lp(*,O8D  |Y}ӉW',AM6 cE480:VdWOooccA~OSpF i㻩4X1AaQa0G[=J+ҟ` X\ݳȑ> 5m\Op ~\a{$]dtjkL l;UgHL2큃{d#uUf5I;㨺 'Wl陁d2y*"o E=3'v*gI#qUԬNWjȠ8uBph)]SwPd8ή:M ΤI*A =8.3!pRp! ' Fo<)QYLJ1!>a/pe\Iy$jCNiV@})Fo/8&PDcl8Ape&֏@k3 Es{8Rv?x<fD.VWDzThrF`"sebK^Y-"pjѶ z$*ރN_jl7P/ {8Jz`6\8rrRjq$:iodSE&o_Q5)0m#ȯRҮi~^6$~=7f0V#1 gNOrqbQF#f~KqSޓGvI:s(Po$AqRCk5ZVx`!]~.\l3؝Eow9 -8 Q/1f<_Vŀ;viAZ4AVKwY&]@vL#6!#yBpݙO=o=Ho/.כS7!FcDSUZwu`?00P&I5G9 Fo99GNl2oi  صc. ph )\+'4z='|Ohy; 1"ڗjP9ԃ^q [x jl*Q[V07(~iD)ɀ&⃍)"'Zm.FF>@~88Tv55hxCRP@mv6MIz.=Sg !yf+};.@V6X6UWV cc쬒$ϥCe_BYSa! -æ fͳZE5Y)R$Yf0RdF?faWչ.&Gnfqǩ ̿q++, UqSwv Yp0^Y7w7MF&_%HmfpO98&]ڂِU_ &~[ !-er Ya0XRt 0>ϦƦpCDqrnY)ņF1Ƴ\HKό0vb]U𡅓#tSɁX; ) Nrrl>eYd V( *aThw+AXpz.܈>q"8BG#"G3Ua++pY m7U^Y@M==6ve==zy$EF?@r>2<8o;2[9٬CۯUIV,%gM"A5t^{Ibpc{zmlLv;^oBp Dlb7mq}F:t'#[d "ǧ xQ?GfG p|ߥWT`1EVphS#"IHXߧ?tzzz{/n`s\Vdc2{s#) sWu9Bh/ #9:H,6OblA6G}Y" 7R%MChK.Gϥ˗npazՊA֠F~cȢ  i|?LO uWWc"E8GP+y=ɻ[3f&E@G-hK;+IJ!lPIpPDc{WW% RWo˽7OOc 0ؠGG )g%%W1lsJuU{$.U}Վ~~nA<pà`Wmq3nQ8pxa88nt)p}e67G Ǐ_t==78L84Ul(G_FSss;:bLcz:>(f(,M߻3+"?]>c\R(@WG#QJ2EɛET?vWGjemj(Ʀ6<|T7mӒؐGǻHBlh ? JcJ8:e,0w"Co:8Bh<.~rcII)RIAOH3lѸ(/<W@@wE}hUggV18䝿\MV媯ǻۋpx$i>D@p*;{#$ |GoL8"&CZ+AWEՒ'O=gX u $P$a#p\ jloݻ2< c91\ 34;JǃAanH|!D]HJZ*#J5R@O%ODx*WEJBp:6+wnaxrhqnr}}LW+ Ued&<-KaA' qEtD8I:#S=+~C-$9Uk#|^-BG?7q•Kf/_|ӪImzt68TccEр'g`͠?LF՘1wXl x  ˮ ^H/:6٬3,n2xH%!cb5@k*ג/UUFÊa3]~|W'[snR w#pA 90D0BLEGw¹V%48AKYZ=4y 8(^o_jlYfbO)dtI X^g4k6/,# \lp=Ȏrsqej287d\ZfWù38F잚s뎻 ݑ018Htsl?˧9CNk^jz[,pH7'3г6<;5{uZj~Im͡QGm`^9XZIjkGltC,6\1 c:a{=SyMj;Nyޖ=Q7u.w.G`E1x>i$TUhu:ΆJ:vXEw駍&BhQcltdnnediܩجo}H>AWb'.)sE0>:~hOO?H'V=ںrOMM[^"? b!wRɔO҉%h@j7l48>pd:8-3t"SŁcd>۪޷%۟grZ D𐋗p)'-cF'p({<"Zo>l >qvs)G>it\p$ThӾsilh\U72(B{xcWÍ62z ҃6+A=>dsYfM83# }7MtF`0W8=do п$If18TS]W:ǒe;GDg5yn|V\Бٕ8l]L#G#CM$d-6LϪzl*:w?ق7^& A6U#Sv8HhЙNSYl gWjb0Q'˳,[ k*-ȉ F@ޠ^̎sjo9lB:8z7u:yo{ pCB^#La2`t <΂wVń-f,{ u8q\yѷ-N٣ ;Ή`Jw!k{n/^D%D /p#͚=ƼOS~U+W^W,\ `4/.F~C>8z럡#6?9A4& |Uaj-617vqNMor_>T;6 -Jenݿ[>?5/"Ǎ H2Y fRGRbp^^WLCi ow[^^qf hg7187Odɡ ̭G^z~q pVy n=tT$qV.H˲<zkjkquETtut# iQOCX/F15d?;:<K1\֦MH'>>|n*7p@0gaC-m'$iYEdwZMj$rPޘ9_0:V|pp@pT-#'t?Oׯ18ZNnۍFC懗:V^dkx=l;)V#xM֐1'&Zip|8ϟ@ѐ=dA ҟ)YK čիW^_cG;1Yރ[ᅬ>?:?@zO8_ꟿ+{ky|+l+.+`jW®Ik)65oS57c RO~<uEHBA H$Okֶl6ip25 ej&@c2=䒍sx? rsH b[`gS9-ҸI)qW?_޸<c;D9_ sFޑ_.@@N"GNL`̪2PhB%CAk7 *ѴqD*wmwrW9*mVU &^_ v8]WE'H|wU"Ԫ@-egVoK E XABuo jxG;۩8$$ 8x5xS;ų,3 1 s389xb%bs83qޅCܓX`ĩǖݲ}o8Q!?Cq(rH $&hh W?S,A_dCjǎ~5IpTT2J׬ECwR#x@qb9Eq_iiZ!hWR__x"#(cO ; fap 9n)"D"gѧjS/JJ(%1BK(ŵL*M&_NVc Ɠ uQH+ѨZzFv ޶;g׳PY4tk `ɈG6:O=kq?',lފ%"L#C|y»y6$Gl3Aub8/?eV/{D$ɗȒDDA\ I(~G!]35YTIbzRl]EYґɤ!,/e,׫hTzo.|0q/.48ȐN~I#XBs4#& 4$9!D!Ry*QR,qGijI%Ո^ǖWٻ%Al\q(li֍\yTj!P,"-TW%ir†+'=)8/sJ8G)G.0t+9)W%I$)#_b9K!H0HtB*\dJ.bq !H+O處U)鰆G\dUQeJU^ OZнY`4;~D׏Z:<77AT #DxCH$P28h96YUQQ%. 1~d$.=JaO#IiY'x[kEiLq@~eSeʩT5kЭƕ Otuna[}T D״ca@87ՑV %P goȅKMbt۟Shqkz7>iW;#9*y<'I$c+Aacn)ƅXlwlI*XRQFQOC,xلw7U>C/Uھtѣ:/##\ 8WU9;^ eEC7+7u 24n1Tq\;Ɏ7N.8*r*jIFC) 9-=^,Iv6ݜ8bE 9>mul6*@,%r~EqI 2)b(Lsg¢ F<-%H̱ F/n#YicÍN0]>FOtCwo-( r Gc]TFɓWTqTcitBWۦı/7rzX~$obH8Yal6bpYE$@wmOH' >1rS-dNg0/F@Ԉpu}We2CW0Ÿ}D0P;FU[Mss D;ƀZqSG<7A¹/ՐOU^ 9HСv:ǯO^7pmdBjaa W#g ].#N$VziIQL%snT ~\fEב {Jg\۶ˍyufq9U= ݿ,bSn6=%}{IsEr1O_bC_ CR96!kM }@#Nj䧢xUKM=gǓerl"܍Dc賰U}]QrͻLB:H()dDF6rki0] 2B8i^0t|嶾 $_X261bvv8H/!B<>'zH*0XO8;N\䧥T*U}=qwoi"}:I<s]9rhnq~-/l͡î_CVJMm?z??3o2OP7in0 K}˫FBshLc6hx8_)o\NDImgRJʹ^f*U. R$4St94]_C oi9ns FS0tOẋ $}Wg<ޮՋX#ugջkt8XD,q8=^$57>D1|ӹI- oy7Lr9D,ʇ -$3%\>1DNS-HVJ֍m_hE k[2GS\=HY¡[͍W~LDXXT8&DF>#b^V|/ s$q:v*3vDõ[lc@78ԺpFG6JQFki@G8/A7α8Mk\\_fR@S%OEU#P*;\>ٸDZE*Zx`Pba7ƫ&%\Xyqd V16Vܤ SV_ 4qH }薱& 1G ZVYQ*@ bEiMU S|oX(;tlG! 6*#N4cc\AO28I5`ǓÈ$]eslgFbghe n|5_y!?&( [Yw2z7s9* q|kq11r 9Y>^0t]Z!n"_7N+qm'yi8yZ M[ j8D1WCJ aMq8Wha(ݣ.UH,췣A:N1fn q#ǿ/hAcccı);0 ġ6q{VMtw:|g7Y4*E>C! }ljڨ*n:4FB~d7VOzhѣ~;Ri`3?Pнp;qFꉢ-dGPai489zԉDF;2q0bPXҦFBsXTj-E΋al0bd2"mkmo?BQHߧ`Fp*/_qqS̱x"ц9d!Ǹexf7}*eB/FaQ>.bm4q(,]ĻGr}d2!dDY`ZT^[yRumP|;xKr7n)`9Hb|x2)s!ŕ$QD ~$抈(,1c3D"ϞfȋC* %IʙL.1لrni}z'a!fPXe=&$#C ;P!z]~yyJoOBSx;C`==ߥH!ٵ7#e#9zq( k$HJ*ȶs[rnz{Ml"^[pr+S_/AٷU -?@^Vx3Uʻ{0Cwqˉ9A>)J$C_L*CiHֲk<F||bHQd\hw⨴we!F ['Gֵ^;Ol8'H'8ԀCIq(L$萳4ԣQ!M)J6E+DH;q+gOW%tc ):Ja:hIn{c;8򒂡[)?IUF)EkCՎ"q"&mNdln*SAJEO&v&a˜[Ԙ')*a)ޘ.zV)EvuC\X'd4@s"Tg*v2CЎTT ;ϖǏs]#\YjG׳Vd-Ჺ'! "ՑxU'aMz#d l#~ђ3=ޣ6U鍊9؜&r=~ufo&b7\. \o/m2bwkFr-Ԩ*GdmVH@y,Q eEC8mVۓNãrh#Dav8L6u*:v! w{ya;ZuwIs͛G#>Tss,nTGNFw:ɇ6?# F<~m(b&K{xmh4 G\EծUzru#ؔe1m_2oUqlxn0VpMyL8'^!OYOCM^y8Ѹrs3;qbD^7oB H(=r9q Ֆ})=n+gl6N` Ooی\~'w]I,7OPM$` 񋍳8{ZVkGq=dM.HYs VMεhN޾}oz|u-8CQ8=&Nt_\?-Kf3뎞:M93{^|?h2`Z)pb XPo#:L:ʈL}H;M~[Kwsnz]YSA8sF?r~rȣIojgϜ9{ |"s鴢P٤3/GýM %~l CBgA\бiPZZ88>{KQFE"Vꅃ-MZءg8Qq8D# $8y!PgR*+rIbZy*W1_u7ј"/A_ˉV:BKKDDwvttZ^ #DM4`44ߗiZWrD&Sgc52{3PWCY6G3 5@{[K45q|zM>w18 HC}7N|M懹VjSRQjڧ*r|\4"CϞi;|cO~BM:&Ǚ7o@ћJ?v12mD>ojn|+/˿ܽ뻦]H#ϐhT΅Çt^{cdt!}SnfEΜ{(e:{jr>xq8;>W8>!_O4P4q9"6'GHCzbi*Y>}Z; q7+(Ȼ=,%-5wܾu>LTU([[]N*99`TP-pfQޑ={D1^CI ] u=έ[[CsT9eM+Tr!~tDo\5&To@kn|9r Kt|'w>Ν֨}ƚ+',+kwNh9z֩`xQ@Uoyu,ՄPZ櫎#5:m0q43qDI!SyPuPqsQ& $8`[2{hnۏmm-/_ti]Ӊ33u(l=n.8}zOJhƫjb^CY(_JqɗCJSq?u'Rq +b(8==3}OO-Ԉ\9'ܱŲ:֖]ڪk:ե2z̙}]o9K9sumx NQqvh/rwt \8nӝ>MaZqNQW ^L0hGCb%˱S'VMꬽ3xEԛiU?ԴU2vPXkbr@\TfF/_J|CY|[L ۡm1Y <F|<"V>=i1cQcq;8V[yF;+"Ce^Jchd(1[X$/ gU򗌔v0,"dchP$nwgͨC˂"y :$li֞)n{2^0dD!l^kHm/8eu!$8D*e=g8ȅHE ;8P`;Pp*/2"GPԽ,P4JzcX(opU(ʰ78voP %^MQR7Txʻ`).*ĽXPvJ&FnJD0ZTJqɸw*GQ1fŒUP-(iP58 -ՆU*R MT!1"#[0Q2d +Tc4/@J\U)c|{7zqgClCAoáoFMo=Ar@dm')Eendstream endobj 61 0 obj << /Length 779 /Filter /FlateDecode >> stream x   & (*- ', 200. ('&6/:'2-/S>k>x+U",NG9cM'IrGVN3F8K+2oo3n&5UHR-QR8jmLk-Qp3gjlQI MK> >> endobj 56 0 obj << /Font << /F19 14 0 R /F20 17 0 R >> /XObject << /Im7 55 0 R >> /ProcSet [ /PDF /Text /ImageC /ImageI ] >> endobj 65 0 obj << /Length 1091 /Filter /FlateDecode >> stream xWK6W(5Y4M4q{Ir+^+kâl 0DIN8(k(S,wFM I_Mxc~ѝ2˜%\LV 8@8$ߠhaKNv 5,|ufQh*51EC 5܄))[lrDX0 VN0"+l1{alλ|ʳmγ?L^ %I~n\ijSN{υ,0THIuiMtl)m|,GkLn\Yjhy+ɯ L$,JKK3MDcV--XeКj D!!&WNvl-"7TSKF4IG;ÌN0-;}E5iRfzú^A1j(t^dOP^}e k_iᩮ1F"B@Z5FZtUa0{1]mIl_X6u^wY&k{oZ3@%8qq7\$n@L ] S߷D_0@Ep}S۵MUWqmݬA 4t6׾E'Jva˧^c"D7da aEH_"A%ט YQ"a"c)eG^y):㘆DRU#fR\>G;Ip8x겠Ri ׃<hS;wXǽVoB?vkl@3ci•=YuAiUļW/XܜM(}c[HVendstream endobj 64 0 obj << /Type /Page /Contents 65 0 R /Resources 63 0 R /MediaBox [0 0 792 612] /Parent 60 0 R /Annots [ 66 0 R ] >> endobj 62 0 obj << /Type /XObject /Subtype /Image /Width 1593 /Height 612 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 255 67 0 R] /Length 60229 /Filter /FlateDecode >> stream xoPy7୭ZJ {RtZ8̹%V9/f@Ը:/{DHHjToUz0Gp}JlyPT9w93,Iߟ }tw0W @^z^Y\+.{xFO^8 eE$Y~f#k|6i6㩨.4 `~OvfKK=z.3ރT6eFh:;4w/ o`A{N#iج6Fh#mfYۊ;2cd5fj0؋{zֈ#"Ƒ)% )"}Lj@z@sgxd0&ڞAQԎ eͰZd( bƈ IȃǏ9Ng?ya7Jl$IV;yx6aw0p,]Tj7d,/E%1$Bu8@m{X<~_!_p:&AlmDF32e&[n'4@df1 k`6-[m7;x9!u1qYY{?-ؿsO^ [Ki` [C1wN4G/yAקz GbpZ|jR{D8L Qq@+X DO(\ #sX4=XEfxK)2ϡp@F`\Zҋ>.@rGhM*y#o DDrX q߄FC0)E/|7cfp˻YCAO][h,f{ՔHyRE4 Ha.I9GU3w` W@W9\1PެGo# %"R8.6a`< B1Ü~crU"/N43<d3B '8Q@kɻ# A.(&<` `xI"`ԺEEgT9=rp08xqRݏ}Gx]#fr8+wB=Үp$[3h.F{pj*# 4t1 q8Fj'-4mGѴlaK 9 (Tϋ<7 slESY4Y]@CT"< <#>ۭ8nEqĎ$À;*.ƒf)A>pܑrC΀8S_NS@xPM@]8joEhouUmL[⤄#HF wpXRʇi ]5ZZH*0L" !(zЂ0Š\h0JqLUCA2pmW?19ZWw*N討Dbr 6o(01aBpyѰ212EvoAoz#J(1tLc_7%A9?mBC/͘8ۯޛ@y ޘ|<2c1xyJL?Q[ !!< bXp̌ ùv$j3Rt`qރ 8p@Sߜ9{ҵ0!uXkŧe*-m}}Ot{$ҋC qde]*yhlh{|xږO,P;’ ̇b džQHxs= $xWGA0_Ri`_JKGف{c8(wDx+`އ)t+`H$S¯jS}!,Kc}m_UÕn - ꫪj CzghY 1.j_ >hnx1S CcWL܂;@} Ar2#ICU"Nx6;V3!AcGq1555z;է+uiaLr-~C J"1.Ub-Ca(iYy1U`FaVY#K@CS]ywtgLp`pmyXL 'HGxpٹɇgo7{L{#fBkO}7gJV,M5{(iqB#|Gޯ%:`_/zrZ-`P%PB;8<64dظA.!FSW(aOщQ o Xǰn@U k@Ta4< H H?֯WBЕ,S~eyh8*+IHGj&wx1jٯQ GT;4Ɂ?~8X~GPGw\VCOֺ2qSIymܼ+#8ksr ;' "<2p8* GXjĶ4i敏MЌIp 6Ue;֙8PGeU\Ï渌r/@qO!A-E)F?^_FC+A GMp| XV$C㛊7A4hE>Tg@KCU@¨ㆩg|hQ*k`aFү5Aġ6#G4G0epՙk% \ c{N,8dBʡeuU m:r*;y<CCUUcʋF|XP#VCa@ӣa |j >XeekqW X_P}CzLw߽wo,й(k r|*\ExA(h q߬W1VDq og}Ǯ'|{D pFL܁Q2+˚ߪ}ˏlPC"AJL|9zh_YYT<]!-L ё+Hqq:8F ~G;kl"MKaa OzC!q|qGC$` vEqX\phZ-S/?r. U?|y ] qm+7o|SSc}}mm}cKJm)p$0rL'32,b 3Z£?wQ*GQ+ 4x!b2VY<ʄZ1\NN}5l 4 L ?0 QV^;O`X{#JRjN% A4H05e|dIx4R00<Cs$=U(D%GzDXnhՃ$Ni%ׇ'LZ#8Cā q*Z5F̐?4ZU߽V `nl Sq+){5DQKCy(8_jyGr7<222JTT<ׂߦ;#(D$r6E ڐ ie5CO&s"Ucḇ`݉cN 2"h>o5!AYE 58!u'?(:x`'8.O]'gOK l3B;wq< qoSt:㨬x$<uVjQ !}v}ǟv'@?9c eRh2ݝGkU^;zx<9)-G{֝8HYЮ߻=xp6VHC>Ic?մ5Ȏ{aoB+ ,N}jʶu)GD88FUqqWAAa^:/7Zr -S^1򢥬pP5oMNU B wG[f {'Dj=g,MS: IٹIDCw) X2 Щz6خ!xiddbbYyYST+"+7Fl+q"-}&bwwgmjкUPX Ɛ"9*T?AWUjjU=|EyyY\zBu~(>7sU"U8^SwH{rHIKM<+/8/W uC>@#R\=}bDPC Ff-maX0gh T=N/ռ񖧡GAW^7:Ϫ\u'97m+ۻȣ*qpe&Y<]q\9xoP0"0oddfU M2T R..22>Oy-Gj$h]Ӓ?#t7T R}` >xA? WCc6“&'/ȸlߍ!@<$ED9 f?3#mٲd v50 rcd9`dY`5"=FPDJj_籋ןhc٬Vo j~;>CTr:r-clu +:_uS u͖=mZUY;(;o ˎwC4ʢ;1đÀ?>Ɵ?OL>T&%X*7&a<\;*J8x~''Ꝣ~ cf fOV'mlQ]Tdg32mRMn7vssrrM9$`۬Eʅ3xHXo\ 1Igqv%m~Qʪ XY?%JJw>zWXY K}$|iΉⷍ8k3P#<+99,e;Vd @Jҫ$ g;6c|ccU#s$⁃-߻ͧfDz1r'q|>*EZ@B9q98^URP1GUov{UJ?&S~yV b đqEccyc837G%y^B /wirɡsxcN>`ؕZ|w"& qLR[0X7t"]꺹~^GK\:pǹWMrf[8~2r{8[dgfR`^$YüE7QEa 5a)\] %l.6ʑowo KW r% YьGG4.;l,ig6[#w^N0[@Sjã"FʡX݀98⡧ V;+ʥ%(C8澟e}@pXl(짿C+-e*L\'W(d3Szfn)EObR Z*&DUU UJ<"d2uq sw>)G-GRQHc#ି )EC0#E/|Lk6@in`3bih[hp1^l"ѻ P4OS4hV<)GJձnd/!l?8oy!Ax<{cs,fЛ 0N!W52ƥzK[v䓔-V0߻9G~!2m9q$9Hb/ŁGFPqpE!8P.>܀^y3X(:8⠴hK2š=Fl8f_5OߘPxc_<$g2'Xۖ 8oyI_T_MQ4tQBѴ(4m7Êzӣ~zz9`6KaCZ28H"+e`3E)¤y`&OrDILQ.k5\j9(zL跫QuIp=({`.Eu`ߖ_ eq?Z}ߴVC)8O,ʾyx>r CӔ>v{=fY&lpTc@پ<` KsL6z!A:"H&u/DQ4_ky^xG}Nofߝ&R>iN{isT7r,ÁGh۽8* *!\Wwen%8RsSc}S/_WYqZɑkIM".8$O@ CmK-43YbWEc& Q1?EsOa.hHYJ>ur *]4R Ejdo>O H+A,nvhV i{=p\؍ud]->$G4G1T"[/8H>([8i6UIbU>#JZWYvu,ԛ{Z(lU5{] qvVx?E Ɓx\Y 1qr,Qp$ qȭ+Dns(xOF3$izZ/hYh8 ,N75{8?o/=QnwIL2b)Bd3Z~B vՐi0d2ShnnEbKdu96҄5$B =  FU4P/Ȝ?f_V0xxCՁVDN(.j*ԥu#1U‘6qK=_3axQYK^ g|^g¹Ғ)6$VᲷ1;g gpsvr.X]1 ⾻H?8Bufǁ+ [vZ&)"48:j\p5\hM4\?6_q3 y9&@wpMϸaB Jqq(ReI|Cex#+v}ۆ}%HKQUlGayz,r9刀 [PJOW =2- ~~h5rV8Q8Ѥ: ]u7joqժGUbp99':rjor#Xw4z=hZaV< 2 ABcD"- ;r ~_|'4yk5h"rQBۍ1좨}T򹟶1 vNDW #g*|~hzS9_?mbB4gY٬Fy~M ZCG֨κ8s3Q9|ߴ{C*3ĂP @w|.^m"GEq)֨-fS...hhIԒ'0W8Gm bTf2@l wOEKY|z8(q%ͲaN'MnԹjT޸ TlUmKDGM98^}m^sJH#[=pTaP.r BCxTd)c1o#h43Pl6[Y ]5嬾Q[|).ye_k%]7}Tn;`hh='z}Szy_q`1JJE@fda7-$5<b,VQK 0g,xؙi[GBx9e<.Q[ |n~= 6^7(;$I1q`7Z͊$ $əH"ubTW" yᬋH?Z70WlRq>a eҧyON.$Q̪iR\NH҅a(anɱ[e3I_5zr=M/,8bYhOUpBVň#p=:@* 茷T9ohVH|+xX%=lCfS4f'%E1[BFX`+ieLi PFVXB/i`ȠcK6b;VF3%aRc#th^d.,)oq:\5qd1?/&ü\է}$j mnPE{DJ%fD%ȪG (-G8%.n;q@D8~ $Zl>~ăkD8D\3ϫǎOgL` `d.!%)ip{eYt]H>LDM\%N{a™+n\1ۊ1haU׏@lv PX6Jj$dG^bٵώcVΛׯq.5u~Վ3hDUOc,*<>OÓ5ۀhea$ә %d<xԔtI/^DGl INrܷJRt4Ҫ؀^R;<K,9qz{8EF=~b*cei07jw&Y_-|^j LU[Hum _5pwuΓkEgЙ. >O.>5 ÈfdtXPV&A`-R"Y/ZK)i- (=1FN7@&Ȉ]NAjoE,{V*Bcԋ F% [5.dn88t9 U{9Yo.fk7kPsbm<^PQ⚸J <xПP$ٔRb;`yK^HxƋO Bj:xS IFBT9D >gRsk* ]6G@o4 ʢ~OuR 0=o7Cdѐqȑ3Ru$>^[BZ진9VK'@tԻ4ޥ*r#ezVuuRT4@^OW.QB] Ma$PxS g^eS}nj+%%@n7+G+j^VK8PuK k tڣߣ+pGii870pc%̀'*wd\!Wj\HKAu)i ^ׯSpA*R1;mȳ[oRgDYmr3r-VYWDƑ.mPr]իqCRB jԞ"J T e~xm_mzUs5RSäBƈ"MXvCTq-mD'n4}qU[j4FCug"o8Yrd[dzܸ/U#-qF5l<_ _EZ{Jxtjo8nE'v/pVF䍉%c֫\r1FW1R9"uvcI?mS\؃'د(J (_~J%nwuZX})IMLUvk4Kopݤ iū*Ro Mq jI㱬7!%*jv-RS<Uw<X)ՙ,A# >b= ħ,fKFq,HUwءϖ0K,qAX6, \woA+\}U-3hzPG|He9faxۍ1V>D> r`6HQ;f1ͽ 6v!⸅]Zu@K۶K~xo'w2UEz([+ǃ#555JPtjGk@7u>k "Qb_iN 돤<bOK4&xSpf9Ffُ`AG8$icXR7GG:%/#`ylTX0PEg \ndڅ$`ǎ?w]lP-'5%(n&zPr}v9Zpi2ar2X1rvkiIP`1 M'ݹO?$UiO"gI8eŒm5uy#޸WNXz갍&qiJ?=KQXq\^]~CA^(EvXAfcSlւ~vHC'uIyC6xRZm^O"UR9zE n\#Ԍwzĕ& R%FMӜfi(Dh^$TVA5!ueCk/B*P&-VQO󜱴{qӍwQ{'ɂH ^"8=[J7*qe\mE:x3Hd\0q>|'0c6Pn7C@Z-в$Y,QjY\is n^7 ȇaJ,K'y`N{)V EDLS\uwo[ \_ωҕd|7eY֠RF{z CSό$4h}8 >vzڧyyѫ{ŊrB8TRUHտN=V+IF į=F*||}ã5w`WjW3G"%6GFCQzݴ'"};hn^p vTh Xk M)nFD󆫖M5nOv?"w3nX1R-ZյκX:籷؍$4s[9o-b Tz89~MӀ4^_wgA8$n,.ӍqF\Rq-6oK7$0RA5nI.-mZo![z.߳#lV(OG4ގ)Bg6Wcx0@QTI5x=2FXbZ[zK&_*5zAVv49D´[".v#Х6MtzQ" ;Xzhhx~ʊ; 8&JD91q&/e}t\?6*r՛Zm®QsrW%ڣw7(d5rLt3pvI Z31/KQmP`sF/jÊ]߶[;o߱ΗS' #Ck[o:! 02Rc-4Ǎ#5Γ*400 @~v7CV R]e1ex |NNQb m'VVNҒGGGLk雥Aߛ߄9]7q#b8r\jAĂ@.tsP {PତD'ӧOQF= Pcmjn:{1v3X.6~\,:DKR[UωMq2!mrִ^2fgGu}S gݻǕf @AL44x=39ygyébCfeX`i d ނl45~MSOKBȕKtw9:z葷luRs䝚J%rˀ]cԑzN`&i4~;8:hbYm%@fzE7uM _<}=Kx|Uh)x˔qUijq.-9zK7gsG!8FF?{ApE _KKv,oZR|#;:( wzk&%IS_WZO%%*# R9B1kT__]`6TձH–6z0,ci0egfgg-ٶɠ4wx8@rcV% ʡW,I sq6F~-l8\Κ ɵDY2Vj(2;Yb(**Yl`Sr@{SnJԍXZpܿ/} u$oGlT5Nx#֣qqbmEDwOHP-۽6h:/ji oshj bH\k{&O@M;䁿 nU t<κ53s%YTD$AŪbGE%:E̱2:>" RđE Dz5sl3!>7T_ڪq1usrtu7n^kk plWj̣]r[x m}IDx"D$A*Rߡ莎%޸t2H4I lElTu]0tַ_VYXa.8Gj5RY86Ji0-HYRpUae!m# SK ߊJ :n&@C q w9ҥRGΓkI*??{oy ό>NScV41V}IJM[u7 \x>l0ٳl^ Ml66`̇ )mۤ7 m !F33fF3B ͌?BHh?;2` "@V^CrJ3b3詧1JI2[9v=} ]WX=&6vn;)]]._߲l!Gѣ`$$':+ lp82:o[oƖ.r(ޭ,ηb.ٿ`3+:7C^CQ_8'}ګD詫 GA#VyNl TuuS;f8▖x*;w%3I@qD41erOtl[9DB;v UTu?bn#4ΐno۹sǎ٥~ I &: o> oڴ+Te,>o.}K8h;:Ա"&u!oc3 KW o ҩz+XB:Ż\WHJ8O1'xqVCH hCS9v h T :E:cEIEUycPKPjc[84nkUxIc+UqGTO 5@1sYH| UW ?ٹ.m? UCj WHrpjާr`*؟:H [Z/<7oٹ v:*BKC[iɟ9,轕#8Vy~DǷ/{{iK+8GNG{WŭI~($:LtH zr0^+<ڻ.<|-#TRu<:# ֿ*mWT !ѡDTO.W@O+uJ](0l em ^ΊPra!74!̑L\O?ڵcۖ[>N{"h\W Ztk.]9g:袕#Pkms}[p_8~bWyuqWuΕ::UT'7 +蠕{okXxksJWNqù Ht!^h~*tITPP9; +wi<)Ϻgt Jp[9CK`t.a-jec'{U]CG \VqE2)8xXѐtΉU/}r*ST -r 5_3G; !7<.eUov`YxَHW!@[~rӻGg@m4zfW8qA8R-p\bGĆyBRT:\'7;ҩ'ٵCcOz[ʡ^XrX+wgE8:s7v]үu~Uջ>8ϕIk ? \MFZ9&ޠw.,\="2cG 9mٱcNjW3ܫ41N‹Tᐧu `8yK(F.\۵UXqE,2r|W^5'_"S".51O#M觔xc>];^lǶ9;nRF':HE cpQK _bt+T1N+ц)쮗*XPj8tcKW :*LtHF-RR4"z Uw!T&QJDQziǎ!z|G8űfWی7&-7ILNGᠧZjPTw\ҋA-AGx`E6.CmP0J)W6nm2u `a{nJpXr⩶Ez/cR6pqUUᰀ`HR׈? 6&IlÙ >a0Uڑi:oIЮ:|dGSz3pUb;%(;xeFxD 7i@ISE( kLt4P A̦x3By8, 1牢lGGhU7R88;0!82@-P`8lZh9D/RfUIQHtGᰐ(M#䥫LPҍXa褗&]1\B `1`bn À0O L }mq֗Do7o|9Xg&7 U= Hn!čW7eƇ^c,h&qDc8l3骄 ȿ\ 9 xp~~XAGӔC9tha4b5ht=45`8 sԍ}39<Ȍ`kyqfx0}>\vv0c$%q @= jf!pF0،rlGQGs1"| )D3kX0z}\KpXA61@88,e>G3 r/0W`~P <1?(Oӂ_j hg<=Ӵ.f]6xsykdd}؏(Á_[ La[T}Xa z0|>'(87'oߘB77o7ƿ9~|k׮˵1mL5aT/a5⸉ ND@b K̍M&qD/n/orˑ?~}`@ `Z"q%ajDGy_!.fS777qi5?by9 L s"D$ *$ a>B2# ?l.][bGkZGgɆF{ޒ8dưX(qpXMc-tdQ0bc ::|k_ m8*qW6T#DeCNkEZK(@J#J-83"!;b8Eı{I%m)X#pT}۶mOwv+\iǶm;#ބZmͅ(Sy8@>n.g*@k<ٱt4`Fd:7(Ջ¡q4Pqo(aU@iƵņxlkb>:E`$ʌfGR3 'q3se6)Ra/~{ [Ϝ'Izٳ+\/C?*UE_v mӵH+4BJ'uE/PJ&+n9j۶X=ma:,)%7::ۨJbl yEQČ!֢J \wmv0~yI8Ulb#cv֕y*%-HNc_ 3fta"32A"s|[WETxt_m1Lc\=o~ &u2ā' Cb3u%Î*P*C_ EUI}J!(V wDt^^jĔlHi)J8RcF9`F6аH()0+xWE8+|G s%E9p$h3^hfdGfŐ3fP|]{կ\=L9HuԱdf8mɺQr}g9#2 i !p* p⍗~p*2agy8C+ dɗZɃ(Xt&Q\WH་"b_u0K Zv&s4B8zMuP%QYidqhMoؒ[ f Y&uEh6V'DP} q|_]H*$7$:J<1iMdUU P-`F"##/Gxk\rƲh㧑V/xmuE:I,$OD,'3J'HC\DI۸VH]pWe˖#=BJཎWpN :~H%*E,G=/MDS\f{Z=Ùyc 3ZB^PG*C I2d#f*##O۱˿{]pSNu&z @ALQ[҉H⥐4xҗcW xm/Lyq é<3#KKbzHQB?F4^ =]{>,G/Ѕd5Y)})PR0# z5i߻7ZfXiP /̐oq"׋ 1* ɑ 8.8 IX I8'u#=\k𞎩;-. RSX # 2@th:$ `FDx`RTQ-QXL0#u!T6)8D\ft~) `FZ%,~& A93Oq*`v׋9^;pRa4Av#upwGgC"6y :W4ɛQ^`F%Ҧ3¢W8UuLHGy2#c{UU^?-sx2+>01IrJ'kF G_&+#!;!إ:-!{yC:ӽ2#8c ΉC&!ş+˯9'NVRЌ:"A9H0#X)0=WǛ{努# V\M\r u0Ǝxـu:IC݂+ʁ+I׌ U5qo;>% \ L'hNI/OtD:{Q!S @ ;[0#/4#59p D^Í(7&xK΋~A34\^"ZtP5JB{@!n!5B6J$2 lFjXRgP`N"$8h8q$F|t!0qdqD:;0Cٌ ԰>GrVWhDZ{lcOg**/"+HxN( 4`'(Gk#I2fPR)Yqemc*@i%B/W{tx㇟, j rjUSЌ:9cFd.3PU(9.{f|v| T3.Ya%D y"_?AQq$Etqd$qt)%ؑQ J,e3rB#E6 iQUU8TeX 7:#x i1=+^ wFg9N cGGft>L'ڌ$G ZǭSQ!ӻe\tXB N+& CW<eCI 6:&G8PUA7qPj>q0!=ac&ӧQߒhP7duʒ#20; a:h2#Jь`r#p}կ*CiB $/ŎLNļs8ƒpdC<.caf׮q@9&S*mϜ9>طIpo2TZ@, WQiGgGgX:3qQaXPlF1?[B \, RuSQxr!=~ C.Ԗ|9LJ7煓`N!=VʘP"TU![U1N@jHsb!!t\u:EjEDP[&UI ;GqxW6AX+1#͈Q'- \8^U"pեy W|E8;ONԄ竓k0`GA8NʘQČ:Nl@.FI9=qPI8vtB80)ʉTUqamR\~/ܜD|ppFQ1̈ǔNA.Dž;6#b "7|󕯽kgHMgpq88}^iFQcܐ0,ZQgH0:T )v(JjF)Tt-_;#̐~SB#V/ glttv$aRq3̤32^a<6rFLt:%5Nsq܀CNS/́fFÌ0#!2߻7< OR9Ja:!̎ Ɖkp3Œ:SkFq*U!XFXq@ Lr4t%`A#P32B3ވco8TũU %ChH/iς q 61fԙL'Ewa}]J-ɮC- '9jӡ8VUQ/8|4sGwgcѱ1؃$s~M|8™Gͨ(=*}(ӧOJX0*TuF1DZ6Dw%]d-Ͼ+>97X(dt f2 Pr|`B@lFXH+zV x &C8 Tcq쑩> owYx7?FZZ I,J.xHifDlFTâhi1ƧƩ7#`v)ewハȺK8c 8h~JHS @Q䂚Ta`q3YN:\8iaŭ7 !Awʀ=!z@Nɞ8650#9)0#?۫D˥͘(oC4Y<6#i0#ui(Yd- q|MTU%89{ᢇc"`qAq<4H._3J3R!{,e<~2T WҗeF[[[8=>81uZHnn5? tfdځ-3:)cF ^٤Z}yJ\U5'xCӟ UL9񼽩Q[EOEegעH PPg0CI˘CƌQ!3JFUpǿJkO) CGA>^̻lCPh  %_z ēs,Œ&XhN0fG!5DR\88|38\Qܑ@ ۾X`um~T:w7"e~C$(knGnW2#8Zp<cPë*(8jqX1+yǡX~vCZ$:2ZyO، 6#@Bc ǫDF%8Q;o=n1RC@?;k! k]Pĉ# 8m9U3q-tH÷Es'+{]YECwOpYC\|R ^LeYp5In dfDmƧjni`idsOEwZqe\t\9vQCX\qWw Z-P+a?Hm3?➶}H SxK]C+Cxysip^;k 7Gd8_K8DPEE=~Oj9Bͣ`="4y/R_ \MW;ޝē ='@+P)Gq2XE|:j*xŇD UqrFqjxc=Yܳs9/`ɤ;ʚAJ@<8X~Z6AA6 STř 0h @fh0etLxƗ`QǂGM/f75}~U)٫oȾO}%^GFea1mE} Wѳo8ޥG} \ $H 1#@p6LRҸTyg=ν96$ "˼T y!LjDOm 4& LʧÛGYGC˳)G5i ,K"E(GP\a yʹ ~P!gh7& VZQQ 椹 k`hN*oTMݥp^ßtgCyt*'oAҸiܕU?wN5OfВS)IL@JHC9:X\_s$Fik"8:#H3&1]B3*܀ V[Vlyk"e?ǩ&ݖnjxϵfdC00̀*;tJs}CPlYXBFPq+ɍnN|Dس CRæLjԷsX*\b gbΆlF& D{{ͺv:}3կ7nJvL!}Z|< S[dko2ghTP$r==hJUHn5!ӵpŨʎ/`ayqw")RӒvzV-qbUI:<^h$'CpPd,IT4ki=ݱhlbF*,WrNԫ&$R>vU*\!YWֺVvvz͑;9<VTn[+r$NebTXMU$$ Zr uY{S6 W昧VLa$rK-9eIZ՚ $Zg2z WI$i9# _Sei!H m i2ӑDž1s7ע*ּdT]ImZ%UPUhVUgw8 x*6'D#< W1NUm \z(UN_%BXhVU=mJ_&-fI|N@*=ܜnMZ?ȎGeeb[uə+)$Mc6-:Ѯrz^U7kKFQqiEҵxi=T LDD5_Z]6=桰%6AjPYVA8YEhQᲙHWdD-ZU=ըT]ma'(Ta{dG )S'mgDvi6E)tHwYYH:4#)8“="I Zv*Pn|dݩT8Z"樂k 4"g YvU-\SN`hI`-X`zTLNhi:Պ_$].-AU634DbٔDU5U\%)4R Ȅ1хDviݴIC שn{\Ruq%oy1m*C* ^$\u^9UvOJ߮A(Ey(lf[:Ypw y\\J2q9Hy5ؠQ@Cr* k]%/]uERݽvӫrF@[m G0xxU~3:?rpgL׬›Bp?yCc)2!ǀRuU|̀lv9$޼ի}*"Z_iW|չ$Ib9ss^r<$UHUSlla9fMknX$q$xB'm>-+˕C) %xg8:[xÇx<bGjyBAҴ۽:7˚ P}[F^K5p57~MyĤne|MiN5,pe66jϗ˫A<|Uy>J aG]iit=߽eUMHj$k:t`Tc ؏{W"pIfpk|^|@B>ZB n/**B?)} ;&o_+DO3lL n\STΝrf$I7B.8y\BP$i0,gnVr_Mɫ˳IB}qsAw=lireJ;uU*^WB=XH G;۝]X$(>ݴy UyUUa0]FıQѯZ-\LX $Ѭ)K#Ujo"5MVW)[$Av۝eXn}}n7MGs*x*߽|JE0Yϝv֫9/*嚗b8R!ő ̡R=PWUߠvDLvw je^-ŭß_GFa!Sap6'gf-AզA4Q+`ʎHT 7"U #`f53jc[pIZCu5l=WZ]竫CNs//ϙe2c`ImzpeWVhSĤ*\YTߕHKp8QT0,c#I[4*SWˆjy8U]};E_ZZո НkG^ [cE'nk|"YXŸh^CE';̴h9~G*p,94%Aʆ3Wy)"(cQªT>M a~F8n(Go|p'P0tCJdD|9Tϡ =X{Ra)O{A"hg3+A ǘcהV$IFCsϐVQqr8D"PIiO^P]E*4Y&r<bNx"[ Wyy?/-eGT-wͪ R}; ];4O f2VEkA 05q SP" "|Dpk%&naIYM9N ;´Jfn@ jVUYŐdCʋZ}LƳg{{0uF_CD#MzVהֲ !wjZUu1q|{tnNQ*@9T.g-89X.ܰAA.Qس,Fʺ7B '29<"|?d4Xoo_&Kd87NHݎ5JWWnh$(y!uM>A]9FתG@Ҫ9%N` szqWo68 wՔVW,&#쪯W<Cϲ#$VQ.f*= &<OhjzӴ̧ѭf`Un_QMq«jpx&kt8$ϬJJ$i8uZ?lLE nUJ^2GYnܹ82q45}tf^9-K6竮pmU/[p_Dَ8CdN;.q7 sDC>68rVErF}Q4vkc!D<&zid@ŽSQq|۱>7F9xC5wL^{̄{cdٳhz"N/GDBZ>nDUյxluuMj7mA98.{}P8.*8$TPUmrnj1N^5ݫ8}G=?ypU|55>}4k9~?BIs,Ku%.^MhPvd܍`d#;uV6׬"bGVuVaEHۘ,X _ ިFpZWjU4N|8qT 8):ttdXpWAyK^_SWWSUZZUZWWZSڝ[WWU]Uhf \]~>><|N `C,qtxI~G!}/B."4m|ʫ\L3ʿuAgyrgʱUʧh\O=p)."lHGK0VWWֱ['wLC 揜܅%#' j 908ȋ=OO{ǃW=l4v&4ORQim8+I bب#DGxj&dW)2COƃ8i|s{{__A3qbff6@{FX/)UAt=~횢c468f3A,7,#9a(bzk.elY'`d!c>SKA:b\:9Q߈#z:z DQbCN%ҽ%s~jj;kl.*,loQpbD͵"lZqp*ǴDD͓zF:IGtvEci! S灼ngAq6 8pxv:3d=F R7gQ8\:t?X\l5]lWi&J<\9'F` 3-z.;FmHc:[ƔUƖ\za@ı$n?_YǠk9Dz+ m77tq$9tWZUZġ[=u)\6G7ytw_f| 0~OTby"Kqu O5PJܰ(֛&XǴNԣ0p"gX80uSG&ІveGDtؼ1Ie$ıAw|T6њjvfY0qpJS==YQɥ}>yk45N/(6 +!q\klՑ7Ww}tt62gܹH_>EDLj͌liG4G#y K5.x6irI;v\` /!H 0{Kl+8 tw5qqvR4YdT݈j#7pdL\kss}nK.I;1y`zAf6#$KDJRrCCqkp[DPީljut9.W~v˕=Lk}[hཌྷ{WNX;´xN1ߺ9L9뼊cL/ i1=wWd9R|yhpKq+lvZs. K):boNhxAZ\ cD(Q:D䆾I_z\C&g[{aJqck~ R500~gb9$'AY\k6jS0RxpCV#9qtAEzI8l(KVQI EX,k֔<o)6qݽ;#c@-HBVFq\#!tss!$; S7[.ϵbfqml_c51nc/.S"<pz Sֿ'" t4x2o?޽{PU,C'w\ ETJq4XЇLu Τrrlc@a}lE " gq7ro ,i ,Y<㭷tpv/;2(r6#٠&/͒saHMnd C"v0&ɬ\]gLbㅳ\GoocӄcPM#0b@Ή-Yfs\N !8qAQYϧ#8wJ?DnG LU_]phSޓGUO5K˒G0R99V1EOoKk;]t^7渢b&>)2 ˗?3 7??;SLwf˩߼i]33#g(?҆88jгQXeNLXjF1Z..KvESHjFm=^z/]dyӥo 882l ~t$FOLđOcKJf؍8Mgg\쟺{HGPW 2LXxcdzmi$8Ē $lhlzX.]:4003KqCOޢA 08XTOOO3,ܿ$GOG>,~INɚ7?G60q~C,;P7Z3c-># `)0w\3WS"A|!bÇ8*nn,#1>~k b/W] tNOɚ `Y`kw\N+`56Z%78pۺ*Xu SoRef,yhyYC8y[G@n∣}kjBf7X`7l MG`?}žC" 8( CCohc#,{f1DбI.ǥ>nlv`ރ,#q(0EyqmqzY,=YrqQ>#xݩFޘĩu\yb?j0F%6Z~0l\G/G21^`-G%%C AQlnz%x3ġ/3PITd̓:fZ-+[Jh׻a8FkccxT,9zq~?ߘd )&ֶBYZo,mGǧF< ̲䳘>nMl1"/N"!њR\&r(mqcD6~#pLd8p8=O"ԣCCׯUxCA$nrܙ}.Rݎ6'FDoD&]qfT:]\(4(&sq r>"q27G/_ā<;,e$򳋼7&8>MsI#XF?q:jPDBmZ3` 7V<9:O}nBoI>RTDtFkd_V֣̺HׯOp%33M|*@ Ki?VϹ$H[}hѦXT/..3 0O}=?pmI`"k=|d"cbGq3H7*_Xǚ8"&Dρ #5w8pB< o}+hR@Pa0ts9 k4 @#㙸*Lv *1oT zq.80F{-R8rxO[,ylǷ2*֬qY/Azok9~3k4UlGp&xg nG?`og?C4ǧ2 705J鑹M+ zBVpE8?U!|A/bG+o;“C6%PUޞv:`0Z>o^ ` l$8tb0lrrll?==w8^7YG%zc+\u^“ [Jk|UK}55nn'Anwn;"֜sGA!q?Y]uy28tXl5 aY[HrrN\"VUb+@qEo x$&/K[O`8 &g\8p6#o>"< Gjkrsy@\w^.ivY=9Ɋ8F yKFulS*9og8q` 2 Pd~![e6㸢]ñb<_^i<ieًJYR,H_)j֧1A9iCGfwqI86bC46CNE>= \ُ3o";u}x?peDq!>}46]UȢz7G4W$)#(+-؝NL8ĸ> .=˩P & ,chrl`('uS\jbbЏxp`}{C U~,B&6uWypH ,|6oʪ:X|*/o*4.S__qf}dt>ո:T%82H>&%F?KX)a;Of Z}W>bCC&E-q\\1Íq8m6۪yU5upaU]K4ALGYxE)-.irs:Rڈ&zs@5N}Ǎfy|8?ov \F#M;f~Ht-)F0_8fqҊvnnX4^ 8&}>{Jegsr7/O$8Zxȉ9=bĦx0y4ה8 q 8wlGp{Wxqdg#0~>&P fiL,\1>+si;6!jDu,VяAZ:\bb3!cfol$lŠld !isb\0G`CG%Fpeg#@Gc#"7$g8GQ/_Fı҃IsoTpqT#!Ҩ.lFMm5"Ȳܐ1"Z# Uω'd8@+È{ߋg⏌ V˃˅ %%x_II1}+WY .f/I$끉#6P0L ^+]@đѬGPFcF7SOn|~Sʁ* `o>Vdũ?m|'} _En Q{ OEƳXh" Viw8*pa2\._ʕp#SiCinnT3w,80s|;X`s1o?|] f^*p|&3JP@A2U2* L|Lw|8Dc(i)S{P8P)γϚ5kwchwyf = `b""t0KPa}ʕn//WS,͉f7p$\3D0fg?JPdBıfgp O{:C;Y8ڃՈNO7 &UsX_7rI^UMioˆU38f!TEtUxHuk֭C`?##D,I5q/,oy6~= D^QY,ƻ6@65R G#8 taR76<~E!?F-ѕO\핱#:608(G/-dHW `=M{yÝ{]FˆEϭur^ߟRi3>W~;nVa0Ĺc}=݈:.md+"[s;Sjm"XnWIt^oCM̡@[HbY 18@!>߃BhY'ڎWUWSJ#0Q*Yq<C}S8D,YaY,:́e_G7[e6)U))?h5qoL(J)G78b1q\qs<#h:t6 d϶Wā "sZUC$L ^@7xdˠ_ᢵkKk8>\W!89|SO+?_wZuU掰细}}d0zO8_ODkvxƚ׬Yk|o7057lX>OX<.sM<ğN& zZt!95o#}j*sٱfԧxHF0@xKړCӯFʥi\\zJ>uj.WIw}9bf{Z1`;/qblZk|\ViVI~OY_$ y_g5|rq'".dڡ&'y3Cg*9 q  xV#2/H|W9xDHC}t|)HF.Ԕ%eIW* ZRHDeShȗ$.BƠ2fd`*@0u*UK[V)q@с 6@MWͯ7{|bH?㵢몷Eƫq?u>3\ՏaG$͛8&^_3tg44?asZR^&U,_pw>TK5wQE%Z$7'++ 86*16GD"ѱcbx<*?|9:G'4:0 }BY_YomLf)$ X,,W7-:;}Lһe<,Jڊ+W8Ǻ;P4jlXpҫ7H $bH X?G~A>N@,g2 òEvm}ͱl`4dWɥd2I$ߺDhOC3hdz{gG:cW\֒DU;7>ʸ:dgXC*b-w5хiچtϿ#‡>`X,? $b y<* >GYL #zȡH<`Yb d $CL2]#aqpO봒9L8*ɠw==![P._{8"rq)O\Kb#wNE9EPۋ'1dNDN$ ,:YDH0$)a0e:_(%.g4+1X5YI+#TU5h*`4Uq&kEݜL#@W?]V,`ŗ,*U$/(OJ"B1r8\o.z#I^T*8^IfyN961db%dhNGp ^#^h$ZUНT܋܏~&Gu*4htһ \ru\$]-6vNuJ\x6[;x_,//CY1󯭡s}n[:$WP42'I.zI_ :"1JRJ6(5IhT;c vrjj5ݴkq{+D]8G:s/o|[FOaMJ"g#=Gka>#z>vVMs9Yѱtm"jnH9d`^_`sO<tn<\3t{%0 dmbX+ɠZo˩Y!q[_$FX8WEu4;H2UXR^>!HU_\=V#^;uԻ "T6Gy/| ‰]*1~>qb1y7UNUqD'QEs,'G|qk8>ƻrգT)rɁ|7#j%z>H̆8D/f"rǤĝDJbv~Vc)v-D><6Le2A a3dLD8"qx{g")â`s'آCW,{AѼT81>qw[dIF.O2Rlu`ّ7lR* pcXyF'<)KF͛> oX'Aj,9C97R)0RЂC%rLfŬo3LCR]ҐhUG^ aڶ7*n_ncZ]nu{Y.w/2!&{JA' >~őht>j7T9R)Vfq8vdupyFIrc!\qB~܋ tKYfYQ<]➪ kC97XD[-&3QB \ɊġJQб7^hF3\Z8A c1`&a?ed_sG)࠷&HԿE"*G4 =tőj3¨L ɬ]kAxq 3D73K6N'͵t@5fjH//NAbqNM&eh6,\ɮ$ՠ[3Aȃk9ts|u$@tfI-7N jI--->۬uTBsX\mUqKii}pIwx8䔗̐E oܛc"3+|i3e2 $btlp5຦ ycs7/Fgx\\Ag 0F_s5nRʁW*#N#Uqdyqp%:2hSeqdnxC%bH$J^R*5x-]cTsyWm(j?cԏjt2`$p&$q$MO/[*oްRYJ@4[3g;QJo-ҍcn`&jfKjp+;řXbjS'odq}[%t& ;VINgҒGXlXiMDŽ$%q'QġxN?d r"MR>šTDpȡ 4H[0~ H\@yd2^*.UeE5]G]}_ c8ҩt'zܼ%/Ǧ88-\%ދc6^_/12 M48&*CG~5 8xoU[dHACk G2Ł7ghPZm{ ^{=qmo&/M_.4ilvQxqi|ТSXG̯ҨT o;fifXݕË=;[l#y<0BqsH.l<^Äl2Ԗ,^=:" ;jj 8|0\$=/qQz@c]"ogRDŽNRix&SG&@Ãq&)"F1V//--%;8ixf/H'>br17y.$|⅋W.^e{!3VPe!$TU#6|X9ZP]*SL$Ph\[#ս%rkL 18ȭř8LW<Ο]PVeV L&jMqpOnm&[p+w|n3oi_RT-͛qDGO ? ƽG]a[I($kvïh8pmX6VH ]}+ص"#IĐ7,Bő4:$rf0b;X{yQby rk0q:ǭݻR}H^8B1gm7@4Rp\NTJ8Hr(zW\B_x,V̕b"U[-ablfNĘn;D.mz)/lXq#mG8Cn\y$U.8/l8ЀS\aM 1$88, :XYJk\KɎF.FđđV7OWebkj"U/(wv*:Q*}Scֲ[ZjRUne:%YBgwWe'xsz|",G[8`J ɬdcƷ-tmNj1%)*`,2A8ZnpbMWQPBbyy#+`+6[i'j4RGHv(7J,Z'B&PAqq蛽 Nge}:r.- MmelY(/Y =N"DQ=fEfNFFm6Lx t_7z#R(j}<&eP*^@~j6+5zEB./՝GM㷚\nZ MC8vW7אѩm-cT3^FQ47}Ok j2Cj8(UzTF,-xOMЬrwS90HR`[akQ3{LZc4։I2[V)}YM&5Ҙ+l%+jZ-d@"1UѤV1Z,k#ېDk zdҪ5q\NT<&SED*d+98T|]bԕ^Q:8ė9՗: tuܽ+=qp͂l%Ug8U_*/GUQ]U8UPM@7q DY=|b-\jلM8UjLQJ;iaı7T9s9}9:? fWQ<>vYAGnTOFSa ^uH1S-VanC$;dko#jhg%6'Ã_&;t=bOVg|?)ǏcgdrN]_}T|Я@WlqO}f ,&0qWjS*^_V$;ڿ}P#u\8uG+8Gyqճg=TG<)"Iϯq\\!`TFhlrSU[˪>n8kqˏ٣G!ǽ?8tJTooMis~[tdUn>CpDFQH][t@n"G_~7s88,x\d`$:'X,G>ϕ6gmd).ܿ\ְjkp@&;zCb{=xՓ'S,v/p10[?+ ȕr9gb Hw#M/XyB wjpP!M_^=qc;rqH_}e,.zT9"G 9vgh%68ns<J$*qܐX(~"[:z_zU<^_yѭ#Gx<T??1px{ w4˷sxN]N]jv֓~<Ҋ ך$jamOqB'N+r/݄pǑ7|@9) HT.PM&8nr!hQzppgPgm,wO޻wmׇgn?đDٲ,08&X>8:g_)!M@78Qqm6;S NHվݥxtKP q\<}xqܸlő#86(#8O |`7n> @䄗`%tTSkh -=z=x/n|-ygs-aH_߯wֿpB.ؐ.T{,Ine c܃F.WEhMdFxW:|׿;4OQ["ѯ;z2:~wV&7V --Ϣk;JRmemݕJ9vP#'hGrC}8~_iC/@ /j80AEmZ]dն\s5~> SΖ'8N_cz"2 v-"qߏg gv/ jZLR5cb列}JIElVv/߯Pˮ~rʱc'O69ZG?Wߜ|/hw>GBڏl6;j^FDC(ؗ{F)FdW;vLqz#w|xC}9]> lEФAzmfuLxwQB*Pg_]FFxXŹx릑Mx1x'xFR~^ܔ/git45(.M#x?X#q8`Ϩ[l_=Pp XpDd7Q;^B*y'Μw e$^rڿ iJa ><^TJLdhO<HJFRW xѩ9qn,Vm+1 8\HEJǁ.W l(cҮf;#}P_JS3޻#6j(| `6 !GA͵å~W曲9FXF߷x>kɌ}^؈: lv.ܗ(ZPPN+MR ֟c6vr#,| NkcZL.dwEΩcj8CDT$ #BUgk GBWtm6t8Xtrp (HG.Q:e#ǺCYs/)*4J9S;qGki.xq@:1nuM 6/ͽRe!! HM+t7>m=c9bl5ݸ D̃*tQG6a qf'{op-gI;w2^]+{^>LӾ/:BcPU ջPqIR3 A7.Hh qrhAŒVG8|6ˁbe85MU Fmb |k\Ng s"9\䘀©밮j QPTHN%۰x !@*[(Fu:{ltk ZxO f+KD_dh}q6GʋQ}tEKYG6 [I6Q倪H g{kpTG!v~ܧ;JSuUp "ν#fۻL lvw8NNwT MᏪKU;BbIMa8H5r:jYay~*; ਄ ی0=4p %@w mjZO.خgj3EV JiA'XN-u$P`R*ZB8.,mmT)]86[vM BQ4~?.hJނHД¸c )ؽ hv- O C҆P4.Q U@sjUhJ0KMH9a82\6*U;D` m-C unوU8[ XjhpضU8-pB hC hڡPn`6lt۱]x2bnuZ au`5endstream endobj 67 0 obj << /Length 779 /Filter /FlateDecode >> stream x   & (*- ', 200. ('&6/:'2-/S>k>x+U",NG9cM'IrGVN3F8K+2oo3n&5UHR-QR8jmLk-Qp3gjlQI MK> >> endobj 63 0 obj << /Font << /F19 14 0 R /F20 17 0 R >> /XObject << /Im8 62 0 R >> /ProcSet [ /PDF /Text /ImageC /ImageI ] >> endobj 70 0 obj << /Length 1311 /Filter /FlateDecode >> stream xڵXMs6WVi 7di6VzIsEZ愢dx<.J$G&B&#4$~|zBEͧ1O&9H͛0ROG5A i-YuNrfbgYqd=!L!u&?; ' '0 >2'HP?'W#!>a,C$<G%ՌihwM};\2TF-a AՋ!9;Hc).0R ̡aeHr&;!0[( u3#,%maK{,eOah͜S.dpFiu e3fs1JTjx_w*T33Gu >{DD8-$EnSW{3bm󙁐f&w0q`Iʀuݺn0X1dr8ÃiĤ6׼oлA :¯0bsΰ3G$L JbwwяP'\ . qz`bq6h~3hU*zSLjf !0!f>` cizJ@Wʦ{` t 2ٸG .>sGQ5,?{YC\ _XH.,Ce)ųC;> ۏ>QfWm Qn .7WA=+`l+$i;8LQ$U&|zvFݵR@/\ ٥k!wZG?_;RpgJ*l*㌗T%kgjgG>D NKbI %{JITfTu\T%l EŁE\m0m+YEn +ӄ!mU7 $hY**q^* FB fEDTKپ6VJ :TW뛀fgˣWvA4Lbizvlgg[v #qYX1"t6g٫0x?ڭ4U/-˱3ܙ]}r?,́6)ZvQfыA3>G?E(ۢ [6*^Ut5PoPL/#۷BpF>[``wurgw~tl2L{}ݵk/c+REҾv\!5{:J OH~FxAKE/x>Uxeendstream endobj 69 0 obj << /Type /Page /Contents 70 0 R /Resources 68 0 R /MediaBox [0 0 792 612] /Parent 60 0 R /Annots [ 78 0 R ] >> endobj 78 0 obj << /Type /Annot /Border[0]/H/I/C[1 0 0] /Rect [749.0261 10.4147 756.9963 24.3624] /Subtype /Link /A << /S /GoTo /D (UNDEFINED) >> >> endobj 71 0 obj << /D [69 0 R /XYZ 36 597.056 null] >> endobj 68 0 obj << /Font << /F19 14 0 R /F20 17 0 R /F29 74 0 R /F27 77 0 R >> /ProcSet [ /PDF /Text ] >> endobj 81 0 obj << /Length 2197 /Filter /FlateDecode >> stream xڵZK6ϯRUԦ*]GKG̨LccV6Ed'@7FD HBh( #Z87Wķ\x2Eaa,CKyyITJU,0Һǫ_~4|we߲3<`ĵ"rJtxS_|!15.a&8A t ey[PRBbe+rP̪v-z5 "BvO* ))rqY " iDKEMajRAK%&o0p/|Pl{t\aNԛ{y޻Oͱ]LJ9zZmńwSߕ aEiKJc[nHQm H 1,cR0EگAfaarQ`VS;v8!gcag}?V{r){qnomT͚ujӱ1͐ q.v@w) (+B$Ҍ܁(g"x"E3PڵOL٫ZO}xe0L]ÇP펾Hh >޼6^6Mu0z:.l7WsʂITX.6u d|a0%0vl5?Ql*~g4~zǸ3R!yv? H$֛ͣ}Ӌ+XӐv*{66sm9~1X=${q&7M7cM/?6y';x\U} >#H쪫4E%p͇2I&Tv KDXetrfJ"g9f`/M}{޷Y!kRdm4ެ Z!kIb+aB=N3TbUUdi84*1(M!4̨~yD1!@B)<*M\STLv"9VIO6Bl^kw_-A*JMI1bԑ1Ec`l8Rcmt7΢֣>DKyof܋qM 7 +O`]۴Dn ]îjG`+~o$Or183|' G&ey`~NOcg3jg #*O0 \2asrfPK PE(g')L( d/5Tƈi_z8$ &mư~;P228%JM2ft1!˦*'gd<-LS51m>>xCKs/ҟ@ Bj}tHi:An8T9Ow6!M=v^ʄ Bیtۡlt8cE)t;xۛ[ԝ nd|]~CR搛#x}3f +WٟB^cܕ;N]35P'Tm 550M0Si"&'=/q%,9ZBD+qO}^|<{@㸽7r2)I5Ӧ1*&2t.vebv.5m<1ǰ\5h~Ê\ <_b\Ÿ=bZ.AZO;3 7N{ljdm^5߻67^7Q*'Wt'UP\"5+^1.J]dZt (b̯KQ^ny7Tƣendstream endobj 80 0 obj << /Type /Page /Contents 81 0 R /Resources 79 0 R /MediaBox [0 0 792 612] /Parent 60 0 R /Annots [ 89 0 R ] >> endobj 89 0 obj << /Type /Annot /Border[0]/H/I/C[1 0 0] /Rect [749.0261 10.4147 756.9963 24.3624] /Subtype /Link /A << /S /GoTo /D (UNDEFINED) >> >> endobj 82 0 obj << /D [80 0 R /XYZ 36 597.056 null] >> endobj 79 0 obj << /Font << /F19 14 0 R /F20 17 0 R /F29 74 0 R /F22 20 0 R /F27 77 0 R /F38 85 0 R /F39 88 0 R >> /ProcSet [ /PDF /Text ] >> endobj 93 0 obj << /Length 980 /Filter /FlateDecode >> stream xڭVMs6W`z"gJX|mIfLM/IȬ%Ri;]$x<2}|]Nq"4QP4YnF&5Mxtma#w;9JcbYqK$gZ WFV}W ljX$GNj*t!cd; UPHEr',.a7Se%8zdY>\]BC٬7Of}]O,@J2_8OB/+~+($Lk FSrY)hʄFͤ9GwS`de=Z8oCH=eeP*-1[VYSʍu/`"yͿ#?{enn낲H%)ZB `x~4:?H7(d+ز*l v gs8OO z+{,=LcA MPޔmJL Tn8 h5q\WY.Lp!]SNw*۷?p{ZPК唻];}6jnBxqD~`vV738_J7(ne.0OV3:ui7 ^ !tnSW"*7jf?ѦzT 7}JYu|)uLN׫uEi7~9!Uu d7fkd.HS݇ZsJ/%}YI^CN;k NwUx S; C+|&Qň`}JSʯlH0Ż%3ɥ|qb-k#V9JA *~XYdL&_{_5M7~c?z; ]i/節{^=dJ }Zh+mp`ūxqhbQȇ#?Cendstream endobj 92 0 obj << /Type /Page /Contents 93 0 R /Resources 91 0 R /MediaBox [0 0 792 612] /Parent 60 0 R /Annots [ 95 0 R ] >> endobj 90 0 obj << /Type /XObject /Subtype /Image /Width 430 /Height 354 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 249 96 0 R] /Length 5392 /Filter /FlateDecode >> stream xyEb>(шFTAT4"E5x" .,Itw]=}TM3]٩V}׵`CegRI>RtIidZ @ZX)}~$E)7h9H+A Z3hXc 2`VjB 6a96qZ(Ze/-I%|>ͽ TVzZ chѐHq*Nr؟3KEZhEmmNkTK[i p-VZϫV8Oլŵ_ԃ126KKZ%FBn^1hH+r ۺp kՠ{-bD]*œZҊ pz먦gૢR-~-{iA PJ5 F!8lb;QFsK>e\zZpVk2Djˠqj#\5xt+ Hk$X}ZDZ*h%ba=YG6Z ," ВRf{ 8hU@+ QZCfYZ#ъ1V?`'~l u@ ZRw"uQI@Z$ђpii];:5jRw:3V^PO7J1hВ[H847G¸9+P0Kh[5@2Z4-2@ki݉#^0@)ZHZWv T2ZZ5 --whTZ|K-gehh-C+OuV-wVP,x3Z\DCEZ8;Т* c^])ԉ !N7vbbմPh)"*~Au~,}+CEOKշ(#-[e%+iT-JU3+:ZSZ~dU8h8Dњi\ *U@Z(}Ji>h9C+A˝ohYE2iu*,hY*X:u\6}-qfZ$]_&Ÿ!v]&hô>s۶]^%-V;-Y,zǏ*W600x^Z \|Yn}_\cȕb!BZ mDݒhʗUܜ{6߽=G먴FZZJz(hMXv2uF߼8k_z¦m:EZjfe\͛ٶ"-- n'gqtk?.H9 J|>&sꑰ7=>pqd;;V3cdZhepy _xGx5<5ip <&hj[Ve'پUnY;ˏه=D\?/1ST[o"[Vz?oorr%v)Q›tULt˱bJ??u7JW[{LN#fDN+mx5iT!?2ݣ8ՠ-[L׭{ydZdEj5mkkֿv\qiV#atkǐ֋-YFDVFt_39%K^"EѨsM-^sto~UIW-▖*_yj|k[ \oK1uaw{>A;e=;=}z}]ݲC؉Ay;:Π[LxkKڿQ=U?xVO3{c5S:=?׾>d&/S2]U3f>?)t\yʅ-^, {ܢ.ոLA_%1.H;Dzzo>>1A&15ⓒT[\FknkN(G|cb~O:'<8iYDKO"ܤ3mMC w櫷+8a_VdEd-E<Ί[s7snӃM\a f8,JKJܣ|ѻibԴDiHݬ?e1=.gWB!c* 1qS.|fv;Ol+`vܜ8}y=<5Ὰ-[ \o= KB\nwt{zYVFdsu+?=ںMgS$̪uT XunU^xbiq) ssE݇tT0T;!{Ap38ZHv[Z%LEv59 -2^O-w?\pm ZnQhJk٥aZV?i^ɴiiO賷wb'#@V-͐5Ļ%LZeů‘vaךH ~KHWdr)#-YHR"Ki=Z>=}ERr5^EVofwuվxzR|fW9)fJ-tk4ZDZoܶ:S=.hI cD![isS#<̷-Ђ ou+an}OwV~ndfZFV`Hӭ1UTn$嫑/X4nN͍nE(|bvsb<~cW&sʏ7ʍ\ Ri)Vb[ILtKS63sؚ>6cT?B:s0ɹ2-#jZ`:X ZF70롕GJŽ+VpܕGK(EX*E-|I֬C;#G d-t˜UN+heLyH|-"ްb:xh\-&>gU|n3I5Yy~0Mj5"W̰uU.Vu^uʩ[Z'.^?.#uwcǙm7Һշ~̭wVj{ ~[=LǢf%^yUBZ箘ٵZv(NVwՐNhHy<NaY<]{ชhZu+s'UTՔv,u/sէH3|ƵHCH%72҇x.Be1Ӌ/\72p3xZv ۖSRr0-Y2F $oOZyk $2ʍ$U&)Qb =ᜋp!DRӒt$7Ӣ*h]x6vhQy>e3#Pͷr#}3y9)Vn7V76Bc9/!"cfK@hl*﫛%7RBZ9_u YPqt+=7RaN|X yHK +ude:-=;^Z%ꚬ:Ky- w<@B\h5>IZcp 3sZ(VnRWG\.V.8Z|;s&S\,-eBTRIڤB>h-}Reu!dڥ4n#!CU-MV t+&LEn"*xy&$ۄ ɤIRɷInYZfX' l--e[nK7T̠BV- ZV}#U0I4`-HmcZᾑi] -}#AAn$h?ʾodz$tlʶodZuηK넠uBЂ q[-e \-/<\Z2B[iy-Z9u^Cy7x'gx؋yqV6:yHkQNqk{Lm/~_,s]X t+e$T},\AhWU-ӊ+#ԅ 6niy|;-\ɴtV"-/-/3-VvZq16!gzڄ2<&C5&a8Z 넠uBЂq12зJ-rI@"-'tEĸv.eϘ-tKy6~u+tUnIeno[GAH F:!ha넠[eeo!.8-tKi Iωcݘ 閟1.#-f+#nHʖLveA5[tg6$a<B^ܮޓU:!ߢ[ $ U̧[X' -2з1-rN()^LZXCR?M@݊3dG]"z;IqTxV!\3KVJJMbt-Ө9%a$,HRiq~_F@ "?Ֆi[ -CHQF-o1 q3ĎdHJP5x-8ˌʤGs@5uB@b݂q[#V/SIUgni񕧺ڲjځOtOleRCZtKILuKѷв@8kn$ThQGBnKKٻjE-1ҭnŴentKG*j4-nEЭ$+2|+قJ0 롵_[~vINDkSWW=Z!e;cZ0pV[fϷ- oKZ.hA@ OZY?frhtHc@k|[455%vZ|\(i1b"4t1F͏&Xbx.FmhK]hZBP,/,=RJIjàU-Lt%,J%A5Z}֘@O?$-uendstream endobj 96 0 obj << /Length 761 /Filter /FlateDecode >> stream x   - 5 , '=+ "!F!,)''!&$%-#$45,*)-),&3-6(930@(A;86=869;62 @4JX,MIB*DLF:6HB V2W{(1PHh5QPefdbfwhw {qJL`p`JVQ[q{he`u]PVuto\[mnwdsytsZx!jzetrvorvumq|kt́5{| iuqt{[}DG\ѕQ⪲kxOxiôYϠf沺>׫. ӟ{庵ı^5%.̶K(Ʀ1$ӽ \endstream endobj 95 0 obj << /Type /Annot /Border[0]/H/I/C[1 0 0] /Rect [749.0261 10.4147 756.9963 24.3624] /Subtype /Link /A << /S /GoTo /D (UNDEFINED) >> >> endobj 94 0 obj << /D [92 0 R /XYZ 36 597.056 null] >> endobj 91 0 obj << /Font << /F19 14 0 R /F20 17 0 R /F29 74 0 R /F22 20 0 R >> /XObject << /Im9 90 0 R >> /ProcSet [ /PDF /Text /ImageC /ImageI ] >> endobj 99 0 obj << /Length 2013 /Filter /FlateDecode >> stream xڵYYoF~ׯ 뽏(4mZ;}I K̈́UJboAjyȔS,|!`# 0 a!zMG&M6okMrx7)apN0ADq#m;3%#n4I3BI1z(DLj~ܗous[1L řٳw$#:a\#։ 5 ql:; /ΨWk)zz†q5Q-P,%lëK LmonW" 7dVn69^ވFXC93Ҍj*SE0C`?#صf?0Q859Bc<;S"~.,"J|y`!4a,l2v_T->!oo7.,((7b m@zq5Oa%d_9 Tv̀fbL08 akg@$vw,N^ԃpiK㴵x]جժ7ѸX!НY9_ԖgdzuUuxo^uA2 Uǁ.ny`$zn㔴m(a'^e}QL?7T i+ %TRDMV@c7Q8Ԧ*շZP$f|ZhF8P?pejߣ2B凸x&*9V4^y͸hNὔj&(5RjJ:J]z){Y"J߃]A6V#th0t(+Ս%س8┛#jx/9 r{qny]TeoÓ]zHX5qh-OH\]E,?j!Wn9Vu[G2^D;윸]Xƭ"GU:DR)jx/ "D{Hq*#Yų2hLMK`;ˑ xv$V^N`/E-Q? LG?8F~-% cרHh&k~-7_߯u:~f"질U$O R5T=1qI}AcDTIcf~qk#}˻vfFm8jFNA%@ Ą`t_oz,բBeuDpFɹWwoEcf#D ehUUJ=:oal@&xۘM6woxw ;,0,`lU NL)ʮp+k`;#H;EmAǯ8m\b(иAyp{R94eX! QoWnb+})7TۛB_}B8V=@k YuXf#oFiG=alu;pi!= UV姀lk}휧=~{D6 Is~X$('_1lHD9˄mU(p,7vswQr۫IX^7+{cO{fjj 0?Ľ>w[ߎ41<.pZlWMFm?+B厯]66U#2FOzx⡡(قA6\ Wi?3ga2l~~P /.~ W߷8ZB盛O&37#_טc3+}v:mK{\*iH~AHEmb |%o4endstream endobj 98 0 obj << /Type /Page /Contents 99 0 R /Resources 97 0 R /MediaBox [0 0 792 612] /Parent 60 0 R /Annots [ 101 0 R ] >> endobj 101 0 obj << /Type /Annot /Border[0]/H/I/C[1 0 0] /Rect [749.0261 10.4147 756.9963 24.3624] /Subtype /Link /A << /S /GoTo /D (UNDEFINED) >> >> endobj 100 0 obj << /D [98 0 R /XYZ 36 597.056 null] >> endobj 97 0 obj << /Font << /F19 14 0 R /F22 20 0 R /F20 17 0 R /F29 74 0 R /F27 77 0 R /F38 85 0 R >> /ProcSet [ /PDF /Text ] >> endobj 104 0 obj << /Length 1174 /Filter /FlateDecode >> stream xڭWM6WV~h4iЬ[k %ג7ȿKZMW"9fDQD"!&2C݌`/C ^Ϩߣs6贐rRǩ’3RΘ'nfg9)r5WX(*Pe0{+# 6320,ScFl aDy3[xW[-EyʹH>ϙNzSprc3dX&sFORY}RuHiᙙ#.e5NM9f&%|ޢ6r}ꄫƊ~w4ܔ`lx|ښQITiWm]e knћ5' L& fXREohw`vё<'D0~4hwLٟ'ExrLEd;{,8kfL7𳫧a =L#cSd NOm\$ul4o OuPovѽ!ӃW2#D5>N p ~Cd]C1V/+_߾endstream endobj 103 0 obj << /Type /Page /Contents 104 0 R /Resources 102 0 R /MediaBox [0 0 792 612] /Parent 107 0 R /Annots [ 106 0 R ] >> endobj 106 0 obj << /Type /Annot /Border[0]/H/I/C[1 0 0] /Rect [749.0261 10.4147 756.9963 24.3624] /Subtype /Link /A << /S /GoTo /D (UNDEFINED) >> >> endobj 105 0 obj << /D [103 0 R /XYZ 36 597.056 null] >> endobj 102 0 obj << /Font << /F19 14 0 R /F20 17 0 R /F29 74 0 R >> /ProcSet [ /PDF /Text ] >> endobj 110 0 obj << /Length 918 /Filter /FlateDecode >> stream xڭV[O0}ϯc"-^۠(-Tk֔}m4TUr|QVc"L O(^&Zk-'r7Sz{X̥5('Z_A<A`a Eӄ!$"D!Fl64?n,\*8)( `c?PZq0F#))X$oҳ2i:,yro @~4˝aQչL`Krpl] ȶ[6LaµM3H᪡ֵSPО/3iQYN=f9"}q #)뻣 &1$$Z֙6*%j=t#SLBݐET~O50-z=NCs]qhމΨJdx>|˘N'r#YB0ߛ6jN:BV{pU,*)>;y.%A{Á,(tK$Y2%f%Z,*H`nٍv[.\dx0!qIoAm.윤Y.H]]QnP(&Snqn[{Knc_} WPۦ3x [{g+ ane0|Q&u\38u^lp.(XQln#y {.]Ţ A1 Wx W E RTvP6Z7P{Soe`ʋ9ybv!:.aEw=ZxqW8_~vٍrxpkJ[)Y.O@ӟH3#R?gه`ώlќ_NDwQgOդ ͥwSaӌZk9na^jXislzTG^ /˟4endstream endobj 109 0 obj << /Type /Page /Contents 110 0 R /Resources 108 0 R /MediaBox [0 0 792 612] /Parent 107 0 R /Annots [ 112 0 R ] >> endobj 112 0 obj << /Type /Annot /Border[0]/H/I/C[1 0 0] /Rect [749.0261 10.4147 756.9963 24.3624] /Subtype /Link /A << /S /GoTo /D (UNDEFINED) >> >> endobj 111 0 obj << /D [109 0 R /XYZ 36 597.056 null] >> endobj 108 0 obj << /Font << /F19 14 0 R /F20 17 0 R /F29 74 0 R /F22 20 0 R /F27 77 0 R >> /ProcSet [ /PDF /Text ] >> endobj 115 0 obj << /Length 798 /Filter /FlateDecode >> stream xڭKS0:3[fZڒLbC6+Kv 1 2L]J>D?BjLB2"!Shyo {4s_@mc+~=FJlbnꆠ)8U~Ä`a EeĩRۮ̣kgN!Flo#lnYJWpRP,hqttB-bknw ,ӫ$$^e\xTͲjyUy4wPS ~;6Z v33 lRpPRJ)Qp\b 9DWwH20;nIUҸMh!lR~"K(^ʠxߚe>Ar˾㼮C7:sb )E59NEcõ:t/!ٸ6}N0k;K@n%O ;wNaQ> ,~ݧ5s0lv)RБ[SwĄ[m5p  bUwxAytyFIKP%M&)> endobj 117 0 obj << /Type /Annot /Border[0]/H/I/C[1 0 0] /Rect [749.0261 10.4147 756.9963 24.3624] /Subtype /Link /A << /S /GoTo /D (UNDEFINED) >> >> endobj 116 0 obj << /D [114 0 R /XYZ 36 597.056 null] >> endobj 113 0 obj << /Font << /F19 14 0 R /F20 17 0 R /F29 74 0 R >> /ProcSet [ /PDF /Text ] >> endobj 23 0 obj [6 0 R /Fit] endobj 87 0 obj << /Length1 809 /Length2 1997 /Length3 532 /Length 2602 /Filter /FlateDecode >> stream xWy!U`vbKq #V/BuTErk7\tj b% L,tL#,):\pC:,S(Q`<#YgVUk7(K EX1=IMŤEΧGH2> Zy^S΍b]tD}0%e~hrwQ$RF+ KzQ\n,WѢ6{߷k,bodVrs5`c m+֖  (Wu.#g:- [0j"HtmJJ۳%v6aIPrybn;?4e|_o"nN|2풠=R_ќ YAI 2= L5gOV;f=7p/[#Xa!2t*T!#r] m[g!dF];L[Ukάڷ'Yß͋ZOWNיu#QٶrL(no jS㾲64p9V~)IwlE&X͵Oԭ6(מ UPg>r^] # ~u}S^ Z̵Y) K\P}:x+p=!-۩@ͳ \bJ \tg2})rR(T_o?r>≜dMvendstream endobj 88 0 obj << /Type /Font /Subtype /Type1 /Encoding 118 0 R /FirstChar 167 /LastChar 169 /Widths 119 0 R /BaseFont /RVHLNQ+MarVoSym /FontDescriptor 86 0 R >> endobj 86 0 obj << /Ascent 757 /CapHeight 1004 /Descent -9 /FontName /RVHLNQ+MarVoSym /ItalicAngle 0 /StemV 6 /XHeight 400 /FontBBox [-572 -515 1531 1004] /Flags 4 /CharSet (/section/copyright) /FontFile 87 0 R >> endobj 119 0 obj [614 0 615 ] endobj 118 0 obj << /Type /Encoding /Differences [ 0 /.notdef 167/section 168/.notdef 169/copyright 170/.notdef] >> endobj 84 0 obj << /Length1 1113 /Length2 3578 /Length3 532 /Length 4293 /Filter /FlateDecode >> stream xy<Ǖ"#ke+a,Q kHj cFcC{(~H(dAR~y~g{9&wa 2Y*edn r 11-< N@pJ@M O=VP= Z8WHĵ$~$AMc#8B@19FH&PxwRH4@9 KX{F]nTS8դ@a1$qZ(S1c_m CW ŕH@#32;#Mt>A#4 DО()p78 j~h[[ISS8K ??=£=rrrj"t$u?xC4PT`Y,@PCUp< A~B5aErA#p*7(w D0) rT{}Hڇ~Rk!U.:0vRq0~R C%L؇#C ?(O-D5m֑PB`x7}=U`'ᢎDqYk{C'K!`76fUb־YUwy?%dz^rJ+yXdc|T7v+6c-%%7 iP͒u)רݞ!!-\JenoNGrͤZԷ>b]HwViL!t eM}Τ佅jЭ{VNPrزIGf`R24oNhq 7*2ds܍ITk͝Oo}N Uh->2VO~5"wkIŁ_{mZm4Һ97y²_M8NpWG}lc+@1j̏.Y upsuGo}Ӄܔ(kTLy~=`K~ D6Br ts&v9 : 9ը/4{Ҧ5ibaQS{L>FO"Y#ƤMRowOh?z4.2~iuD|~nbJJ,5va>u b0!۹-3JϨ^sm7ӱx7l{^zz͋{WpIf2؊uVvB!OI3 Ah +ٺS*zV=K∧@|k3zDBI NXih4f1t'}ڶl $GomBH5)M&ޭrzwK+fV, ^Zd6-s[-;ߣyN, ye&DfGu(VmBnK S@T sla9zThTX ?|l?lV(!XI=^޼φu ](; H:C1`2ew,}v,M1l4Axx% eo2/$vm<$t^մ>=UgGN{ӉG%'"䢴NND*8"9OLCʼn>-a;nI6DrK K R1du&|܋e?J•Pu霸>rP#́3skC9xSۻ٢2(ѱ:pwTҜ?*!Tgѕpp4偝c:E͍p@L͓+!pÍjuE#{k!:30q[[ǔΉ1[ mb܍ǻ`Z5: ȷ.vwsj$Ud;˩PMm`u+뾛Ma;}`nqU͘Q- AÁ72"uGȈeνDJ3'x"61ПFcd_r =Gx׷D0c!Ҽg#,eAi)͒WR` S[ή1m3NA2)+~+1xXEv%tllb}Ge{ [SvY/D+sQzg l4ڵ&'PJ^ߴlRKXćo,-cF̊1~8iTzg I}%lyU, b?(`w{wOYATKZjh~ߤf!я0l!f6Ѵ}Vʯ>]:Ng-Ό 9Cx(Wʵ&eQn9κt ?UޣwuZ&z65.}-_i>aJ:r-'SIjkݺG\kFgs $\ 曝Q˩P-+Z{(T8 d.饤AT O$~PTRR?6)1p娮,o_ R _[FrW}T3 8T}ºOɀ ] jqHLHn 84zV'ȸ"Uk~)BWk*1O?ZƔ&H2] ^3yeCqY""!Xg+jFNI@S<]Q].CbV{#a]M, 2Ga\gS K[*#dSBu G 0(8sARendstream endobj 85 0 obj << /Type /Font /Subtype /Type1 /Encoding 120 0 R /FirstChar 40 /LastChar 121 /Widths 121 0 R /BaseFont /OCKNZX+CMSSI10 /FontDescriptor 83 0 R >> endobj 83 0 obj << /Ascent 694 /CapHeight 694 /Descent -194 /FontName /OCKNZX+CMSSI10 /ItalicAngle -12 /StemV 80 /XHeight 444 /FontBBox [-97 -250 1077 759] /Flags 4 /CharSet (/parenleft/parenright/semicolon/question/K/O/a/b/c/d/e/f/i/l/m/n/o/p/r/s/t/u/v/y) /FontFile 84 0 R >> endobj 121 0 obj [389 389 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 278 0 0 0 472 0 0 0 0 0 0 0 0 0 0 0 694 0 0 0 736 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 481 517 444 517 444 306 0 0 239 0 0 239 794 517 500 517 0 342 383 361 517 461 0 0 461 ] endobj 120 0 obj << /Type /Encoding /Differences [ 0 /.notdef 40/parenleft/parenright 42/.notdef 59/semicolon 60/.notdef 63/question 64/.notdef 75/K 76/.notdef 79/O 80/.notdef 97/a/b/c/d/e/f 103/.notdef 105/i 106/.notdef 108/l/m/n/o/p 113/.notdef 114/r/s/t/u/v 119/.notdef 121/y 122/.notdef] >> endobj 76 0 obj << /Length1 755 /Length2 1034 /Length3 532 /Length 1587 /Filter /FlateDecode >> stream xRmXSeVDSr4lk#e4_XlameȇŔO(( j! [W)&~֯x9Je"aqX" f(I8Sw{q=Msb\#(S > "r HdTQ\ d8BR"N݈0`8@섑(FsE?Vĩ4DiL@TR0S ߐ5?N 觌[YB?JGBHp$pF*8j )WTBd=gh? )J"Q By)f+V*BW v&A'x:SlclgY\brhlʝ b PK vca8I]1ANЦnT/cgFi/M`Ww.sG#ʒgqJ"Vx'GT&YR6;|n_E7kqy|>z4zgn*Ѯk 2}ѯY1,; W.zaKS7lP?ߚ%jp9L 7|tnɃo-n\=ʼnV+cڭ yj]M^goY1ϫ8FecχV*WgO%޶Z_gޜf44Yl|Ҳ6׼ՖvڏcMi&m$1 f4g( _Ka%'΍7/wP|G=p{JN䷆%1ra3Y%~4RH3/w)͕偮%}|K*k>𦡺[/mqFSjj9cCK!QZԽWss܏/ʛӔp-f3 E -a}a=K G?x!}):-] sv]ҋBLR^4Zh˶4?, _PlYycP4А$\]nu0W5o64X>EX>f]ٽ[\ I߻7%I.s)+FUWO^s1sϑ C{7B'>U5Y֛|z YG+_+K.^[q]- pȝHyC.]6.5!►+G}痾uK[7>}޶Yo 2޸ת"S"XSD2^!et ɝqcxA=w_V_۶ {Ɣ̶#6T|%'7v[t:$B[.NFÅ?O/Z99$pֆMnѣaF9uG-4cO nd$VK;O,{0VuD*K76S.Ŵ 9Z` + ?s0ݷ͞s4Q2ғEK -lc3. D D w bendstream endobj 77 0 obj << /Type /Font /Subtype /Type1 /Encoding 122 0 R /FirstChar 46 /LastChar 46 /Widths 123 0 R /BaseFont /HPSSSR+CMMI7 /FontDescriptor 75 0 R >> endobj 75 0 obj << /Ascent 694 /CapHeight 683 /Descent -194 /FontName /HPSSSR+CMMI7 /ItalicAngle -14.04 /StemV 81 /XHeight 431 /FontBBox [0 -250 1171 750] /Flags 4 /CharSet (/triangleright) /FontFile 76 0 R >> endobj 123 0 obj [585 ] endobj 122 0 obj << /Type /Encoding /Differences [ 0 /.notdef 46/triangleright 47/.notdef] >> endobj 73 0 obj << /Length1 770 /Length2 723 /Length3 532 /Length 1275 /Filter /FlateDecode >> stream xRkPWE$V>U! D$A` J &V)< `-RqF hGgBPiU:Ng?|=\T 8( BDQM9  AQ> R IT Jg4x)m BPDLԲ* # cF6&2 , 9pc@ L%Hf(TS `׭,HgkqQ4ŞY'az&m!F ACJޢ NS !09@ ~F $, QM&!O¦7aD&$2.?Չ%H&֨@%}~a <^tr1r(ZqXG^S~Ghq~*zȩ=OA/g?&GcJ]m.wF(4s]AߧK}_S-'8^Xr;q0d]i%oeeUIX6q^JbS㌕vJ}S}:_\jzq˻O,*/Ly[|(ýmPtɲ/p NbyÞ7cLYWv_u׽gĄVVz &48盫:dխ,S7W׆^r*n[?wEi3$uQ%ϵ𴝷j=vP+<bۅ;0MPs֮ȏCO'3bMŋ}G|A5>;|rѶ֎ =trttn)Pkpk;9I8q2.xl4Ź3͵>%cIIsZ,P_QHIcsYb|\FjVi3qS'Ɨ-QSpMGܴ.oIݭOnR~%Ǝ7B LQ(% endstream endobj 74 0 obj << /Type /Font /Subtype /Type1 /Encoding 124 0 R /FirstChar 15 /LastChar 33 /Widths 125 0 R /BaseFont /GHBJWQ+CMSY7 /FontDescriptor 72 0 R >> endobj 72 0 obj << /Ascent 750 /CapHeight 683 /Descent -194 /FontName /GHBJWQ+CMSY7 /ItalicAngle -14.035 /StemV 93 /XHeight 431 /FontBBox [-15 -951 1252 782] /Flags 4 /CharSet (/bullet/arrowright) /FontFile 73 0 R >> endobj 125 0 obj [585 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1139 ] endobj 124 0 obj << /Type /Encoding /Differences [ 0 /.notdef 15/bullet 16/.notdef 33/arrowright 34/.notdef] >> endobj 19 0 obj << /Length1 1743 /Length2 10238 /Length3 532 /Length 11247 /Filter /FlateDecode >> stream xU\-Xn-%@c Mc=wwBܝs9s5~Zߪ֪Z]] l;Aٹ9R*\n..Itzz) ;ICn! g%?K%N;{A@6P_A G diP1a9,Z`Kpph+@ 8йV K(hrB˓5 o܁W)ӿl2`&N^+5:*6N.jWU7w9x'B*`+ Cu69d)dp[ʂ-V%oE`'=sx ^ L + 3 t+,f !8 ;]] 06so$l.K+7 +`l  lb`Y, @? n8-`?"w?;᜿{.oٶ`n@W@؄6 _ ]?V;?ra#r:0W?ـ?fl0 ̆o| lx)?~=ᆽ+8]-A (5߯+II7;/%lpv ?, s׫v[`# D_[%ׇ䏗!K4ĪtLb; )T*OMqO1*{2n+yז3uvsC>xɕӾ\u&ip!zi: <Ԛ8).x×=p>"-م6.[~?Z> z)50f'N*l$mseWSIo^ٙZ9|> n~͂et_ e\U\K@XBS|(b ՎɌ%o(Sԇ'âk]l2ҝ |{h?ސ÷H>}پ;L2{)%Q_ľfȲ%Vb!b` .(+˟4A#y Zn٤"ׂa!NgrCg.@ib7Y Nl!p]CŔVͦ4l+}+jhdG8q>ua?}_"ԱճyS<9_ ~%f3׶;/N[yYdaG=S0X=U.( }(6b&Ђ'uPlgUqq-E'̫|Cݭ{ y4K3S oJ8Z:֋(5b$st2eX}ƤPGpT=R;jRlFp5$1\Hk=]$4W{%߀fxshEUEogGV3k63jWX1}":I<; 4 .秭`/$'#)b[ʛ`c7N֧Ϋ?{yH[WJϴU_TIj3E|nutxFO!GJ 3n>XO71uΝaC-lw2-V4$+i_0NA']:Wx&*>Gc7mϓL{Hlڜ_%JS.Px#sK'^zgkxNv߈H~ 0d~x/Qv`ǺPvi`r)Ā:|$'cըL #V!+.(䝰 FGL 9tI5|HHtWFM*kZI+ڲW3eE(CX]?U{̓\#@m/׬񼵚sv]T58UW&M=p/D[6k3dbFZ!8Q(4@lTp<+U"q Pl" ;+q0:{?*=2iR;Ivˊ4 p\w>FՍ(PJ42ZW8\t 8.!óKk"TSRnA/r E3?c&jNbNAA.) bӬtni{u6[O7:~" 'WR*VG4֫o]1{:D|>>>r(5B7ZI &ψMUԾ!eGwqgHiL _XSJ; SsӴ׋A<)Wtq(GyXt;ElZN>۝W%3˺6-K7NS1Kn5{H]&Drjnf/l#s4K,^HMePq@y=cʌ;_'mkdaIAD56rsb5qNA}XȊ]>f0h.aTA~e+bTLf'NR,$ΐ,5vH[c&m/3?cňzA-_ګ)S \?eT.iGmY[ 1|\_iLQMɣf1h3icLi!ulw[Z"bnWo':?^WM z?Ʊ_oF n }q;6cJ##mKV\܉U>fҥ E| @< &u݄L5aj/xwg?)CD3"jvF{;^ƯkliW۵z[ ɠigMs6|䆡prcy^g]l O.a(rg`x&j\gӤpചL5Aqo=>7fxqU( V/ ?tgߜ07Z84YxaiEhX `LJ:&@T%bt+|S޳wMP>eKc- ƠÕˌYA/j"Z66$rDzW$|MGԴP2]\y vON}]YH,Z#Z#8&4RԾ!Yf 3if#fB(W0q>Řo&IeZՏu?d*}~vGZx7/j4V {٫.<FӾ|YWK`Iruaс6r3&~>,0[JwC^75s,.o@Bλ]Yr:̟G i3flEy"o/y23oXg6H 2d6X I[s8aIKoQ,.2`V*IXj:] "T΋1Km'$ k9vsLO cU6'HQT0=o^!=|Z/C)ZCɄ( wSA0R!خ_ 2˽A N(f'k_ޓV|;ol ^:45}P ]7JY=Hӹrff%IWf_ m81Zm0j47qTp{ʪ Gj?v(hz/h;QX`]`TKC|#yWZi9xx5U~ss*Kє} {/zv { %qD.$&^5އХ`ӟ\8!ʦ"zt\S%8d2 lm.e+ro^Zr鮇<(}# qGݾ˨Ù{JalU9!.^yk {Qq2?a43ViA6:*uju;a:7Z)шE|%%Wo> 5ZE;aI%}cb2P6@Bdތw<#^Š[Dr:|em~'@f%r't1sgD>\,E}i\5/7I,ב.'=>40Pg/;iшO;SIw'&h YEd5 `]{Q++//Wvw""2q_3zML]yQӨ1/ɭ? 9kLv Tجn (gQd|;?q9h0%Y@a0ȊI^N%* s^ k2fUV0ԯ.%\:(!8E;ck͈"3.s cpUR0,#<%|o3lI?jx(E 4 ́^섭|5ݏj0B;@>~`ti#u̔M8Pbq҃ޙ@BmWȆ8#[연 #?|u~ Ź r?>ʵ(@OEyE5cibdWfXYH̒2IwܯkU} ӍkeH Py2ﮢm>pWuzڒVm,a_"iqDqNY0RѽCRWZbCޏgD+ՅʉIo+*)L{m;X)Q-ڍ+) tYjOYE~BeS2hI'x-:%{M' ֦ǹH:d+ 8%Y\v`xܝv/N:KǫmA?Qe)R~v3`F֥L[3Wb".ڻyn7LD5}Bk?FH|jT_rK}N6s%$1J)KaƉ ?7NQR 8]suI?WjE;pM#w*-wKhet6}ob$~&V; `*Z064ÑW\_|򼲗ͤ9|ܶ'"B-)t;IBg4_":n ޙ'֜[FC8P;>G?{7.d- eOe%@ǹ\Rp\&Ḇ![L ^FKr|&9MfuDy0KM;7ړQ-qֻx'.f!B;%bSg9-% {okRPBu`2{ѹo?ը{yWx"xzA0rYd̑ۇqmbu-*r8x*8⫝ Wcj`vALpzJ8GiFB%'(}%VgUnM6FVRx|#2/P;&_gslxֹi"+xrz;fkõ=`*KkJDL5S W(bV=Q,.m]*/1qpS@/-ZeGLs|͡*hT-x~ H>@fK>,(K>MLw壜ҍl'Ry˽yM!kjab5ھ ?jIU*O8¡d΃M8&W9gW$1foT4Y7 vGE1*?NG? +{eX 5 /{|bWxzU2Ysى0V F9]C\=J~ؼPc"2(d3X:xQ+;złJeLS k8)~׫gɆF|VzKPEDSnXWUƿϟ:@kYx3@?%E3Ul.>pi@,Yo34ؚ7l l2Fgݑ$˕J4H1T%mhJ L)؎l*ZZ;[&_ǠͯHE`[Azؠ8A.ț-kKILe.ߗOd#) _SxYtc.FxloC|݌ČiS|EVk2PG H6K/]c3j4k]uiRܸ=U|x«4V=)Q}1͙||R=K0s87͉7X:3-T͊<ޮ7 ¤#s䔖 r9J5uYxE9I p>8"fJTdvꡬO5%#jbײ7|XwPƱ8s!+F Oe|'g`8ld$< Ѹ؉ڇM}o6'|Σ&7s؝A#$js{/dCgv: dJ:M]Zeh)"<叐T_"r&Y _؛gS(|QTP3Q<_t#6fYiRH3$fg9e)"(%h('G1`vg(ZIW1C%a|DP";0fts>a:`bU fJ U|,'?jG}V&x}/YdUSn>DmXr;FC"4!t;}HT3J#a"^El7U~ҩMDZj'oc)m%v &K2x ef죎>N!? h 9^,يH֘lbYC.Ηn79F1E+}\.s 8)i;e\`$ctO+p{AV=lo7[766(uD/?-qiX洯vfD2x*? ץY!s5$TxkmGWpf3$Pѩ<Si[esP ]PDCyPa;y.V6"Cd6S>bZ 86G=2޳f񜮞+\*.e/p:a~ln6|mIF6'۷ yjs D,cN7mS)>8=OU6$q >Pݗˤ 4 /DԻЃ^ VsLqf~t=<7po/웅oDC';φ.>܍IVv6ӵgAxĊ= ԧo,Diٷpsm^l]-/Hc'hJ}񥶌ᚷZxGH=)8<~Mw=byuBW΀LoWW;9b`T|57p N-WUѹCj"<`4Y9pʫ1-ŭFquR%2/˗ /iߔb>0t苮J̩5!uԞ7}If 5 }8((dD x􎪔鄺 h X5|>yȃ(Dtb"/u9r_omسFjW2fZOCegT~YDYk`̜N`˩:[_z-|>X=TP9P捩d6zD#d/AbuU')cnjs9W!6JwnZd08$F!C6 kp>7 ht&iWg LɈO:e|[y4~%t%2T Q6J ߕ|eI6%U6">'$yt@'HS]э5S߀C|)% SuK: N&#x|FcxtUMܕj-"<{O$Igzx@+-f}$|jNCy"uo'vj~( @T}jX&tf4=v$8.;kiIGPP؇l sgf1.Õf:⢅O+x&c#Y!'7i8qOU~}I[4md"{HGt$ӡx.fR]B faٱzϛ~q  E E}3}[fe:6`>$cyd%60\$Ld,=pUU-ߏGsm N!GYDZPEװ*_t:C8!Ym2Ӫt멟>$ T|_p0w«!*_,Ywjg+wLբ h[v'G#^H1{r1Ker~'X:!P9Fendstream endobj 20 0 obj << /Type /Font /Subtype /Type1 /Encoding 126 0 R /FirstChar 34 /LastChar 126 /Widths 127 0 R /BaseFont /UNLNYI+CMTT10 /FontDescriptor 18 0 R >> endobj 18 0 obj << /Ascent 611 /CapHeight 611 /Descent -222 /FontName /UNLNYI+CMTT10 /ItalicAngle 0 /StemV 69 /XHeight 431 /FontBBox [-4 -235 731 800] /Flags 4 /CharSet (/quotedbl/dollar/parenleft/parenright/comma/hyphen/period/slash/zero/one/two/three/four/five/six/seven/eight/nine/colon/semicolon/less/greater/B/C/E/G/H/I/P/S/bracketleft/backslash/bracketright/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/r/s/t/u/v/w/x/y/braceleft/braceright/asciitilde) /FontFile 19 0 R >> endobj 127 0 obj [525 0 525 0 0 0 525 525 0 0 525 525 525 525 525 525 525 525 525 525 525 525 525 525 525 525 525 0 525 0 0 0 525 525 0 525 0 525 525 525 0 0 0 0 0 0 525 0 0 525 0 0 0 0 0 0 0 525 525 525 0 0 0 525 525 525 525 525 525 525 525 525 525 525 525 525 525 525 525 0 525 525 525 525 525 525 525 525 0 525 0 525 525 ] endobj 126 0 obj << /Type /Encoding /Differences [ 0 /.notdef 34/quotedbl 35/.notdef 36/dollar 37/.notdef 40/parenleft/parenright 42/.notdef 44/comma/hyphen/period/slash/zero/one/two/three/four/five/six/seven/eight/nine/colon/semicolon/less 61/.notdef 62/greater 63/.notdef 66/B/C 68/.notdef 69/E 70/.notdef 71/G/H/I 74/.notdef 80/P 81/.notdef 83/S 84/.notdef 91/bracketleft/backslash/bracketright 94/.notdef 97/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p 113/.notdef 114/r/s/t/u/v/w/x/y 122/.notdef 123/braceleft 124/.notdef 125/braceright/asciitilde 127/.notdef] >> endobj 16 0 obj << /Length1 1875 /Length2 8629 /Length3 532 /Length 9650 /Filter /FlateDecode >> stream xeX[֨Ⅲ ]{qZ;)N)ŵHw/M~>7w̱xטs$b`34] `gaCM]l.@;??;@ BH=!6V.: I1{ db an hm.,1 g:qZ,l]f@+TFr`rBR:$=hvy,`h- ?'vMOW׸ `{GW B/9%?G\LA6bV _!gi5 +tq)))Ii3{ET5qpt{1C賱C|2G1)stKpL!SOTހ7``=Ƭ,`%hS|`9_!m'4 e'W {#b:B QG looZ{:ZBЊbO&DJO-#DR/OA DP '޿A+>A+?DzODzOw'V~"h7?Ӧm=X?T쉠O6 + Tz[P?86uRmad3ëX$CsÑ2hqlVeby$IC_Qn*9L[}wf0tH+,\3ͩ$+;fR" ?0fcp+{w/; /Nmu4l^ݞ%%75RIjĥ0%tf.Aajx8H5k8MC^SrV&<"scEP3a_qQ^xkgweU[sh*^]2ޤDVO޲,V:{"KSG f)I$c/=1 rZqybѱS4ӫ)sSFvH`t뎴oJ,CXVБT7ah1r~=yӖKWH M]u<@Hx fkrãhDR=Ogm|HzVI1tSA#Vn|l.E1\fNlĊ*S}?Čc!R<;Yߚ^cӉEӖi#_#Eo_? دtl+$=2d Funys0ޙ ߪ#b܏^vm!Tē*"a~V8$; 3`ac:#4KE-Ɲ{aE;*`v8ŋ5 B{C#غN}\>&L>1}Uz{M]x}ǁ+(RĜ{yvLx .IΚ9gOkJd9 ~̖ޛU+0y5qZtk@o2#?{_O/=֕aR3j`L5* v 1}_@ѫ*d4YH:a$ߎ)G4Q}Ũ.wzJ-oЮY\ nIg 0p6QkG)ȢTwb&R~ ͭwHOj[k\A]Q_pγiD#W2UΊ- ́+R~>x vȤǭ7 +DvJ) 9]-)wJ z>QVR,e7yIg?w޶!}gݞd^-Kc-(p, 5lC1YaaH߰MP1jBùv'"UdFVdsP#^XpXt_00ͷ$L_ivdު9Kf:j%qr.a*igjؔf{㉶^һo*Ƚ3 {~!RO0@}Ard"%}ݥGlB}C0׌HATŶ>$2-yYs:* J_i b0  ֏y#Xw~yB3`%\:C9d-CZqmװ1FT60PC2DXX^JF Xףj !pPzW<~E^u*<|@0V |J'Nb&IJmKb CLXZ7'%K< /D:PN3lE ^]H8Zbz܆Vど-(Nꟸl{ICC,uzDkKiY,7>|ևA TX;fNK=>Lg ^>z(붾~Æ1ZyGTCY' :nKۣbgZk9p/&GB{VF߷[nHT ]S!RSf nЊ xz)#c1y%O,~)t%ܩ|BA.j%cy Hxa]qo=^]xohG]\8Րi5?۶O4F؏Br)rǼ5@~kY`W4nW.jg5i/(sXf9s96)C"B_abhLLüt|fKߊ8̘,[Mjڵ{=fI8;A~So>~@ j~lCd+P䜲^(QPڤ(5;~^1{yx^Oؾt?fFvK9|Ɣm׊0{ȭ|Dz%bh-#>U"{h,4ޣi/?7RT$[RϦBx]uNi#$ڝ~QwG3ӋH#rsjs> ;%h3V~+ThdrW+oc JO)ѾhOu'``$ kv5S|N#4>W-{ô2T]DN#]PyBt<Ӓk`\!E\ndhA"E4PU?  LǰQPt !90~h6xBPSڼ3g5r3)DL: nE!*u&=1tA7y~Fc_Hۍ4TF23lb&! RdKk2l 5.YBCJEiA+Yt6*-6kJiOQ?NᏍa4<D8$vPl>k_5y+ń"$C!:R)!4-pu~ou-_6PY~X$ (,m3ߴDڼϤ3d!dZLƭѷIS@]M%'DXuLt }9\%yGn{7Ǚ e 6!|ze@Bϒ^rxmmd&Dn$b'kьžV41A#%W λ\OZҷwut] _eK#w} { q9s"a8qqӬMi;{f@<?/oΟk>L\dNcG@٥kMqZT?/5 Rg3xkN} BQʛ6 ]fù=W\f;Oà({-ZjZ\&C^D!Lw9 hwUi+vX-/F#k.nm5mҁPR\I8߫}s <4+x[Б3JGJ~^˺Oۜ#-]نqQ_wqs#9@GZ5lw rҸDZ'Sԭ LJ 0w/=.6c՟4iFUМ+ГpYwk[f*E8bؠLR%:m<ȇv8qqαNxh"݂Y Zm9|OEwi`1Xv|_3ڽ%ܣ߆,RR ;~zO쉣zfį5.LnZ25']˽Ʋy9'~>>!fe:6>1P3&Y=~쪘֍Xߞex2NBR+HV:ŗLV"sתyk*gho)O׻5r=ZP4-ס_GIԛ8&,x;C iD?n~9W:U-~AEi$62ъrKUD|k^?,PGxai&n""X=cO0pi6?8+j e3 ̉- 7ß[;7}8UPcTny2ا΀SO~ v[nLMc+dakP6^yU@ӾɳCʚRЋ+ܗrds*p WjXHvbɵ1 lrG|uJPX.[lmj Tg-k&R[F>=2/;zZ ȅ (ށVW=/T֨ }bS ƬN%{x~+b|эQRݰ3|x3 @ $a圍u>BC/Qwg I?+vS1Fi7UYh4#3Y"]+s{<.],)EŁ".Ur£ޛiZ YEX|UXC vL칉R axIР`]l U yr>C2#u$vݰn.V^eC&S@U4o`4GDo5=?; (;`P.X?9A`Cd-SEx.L~$ilKhmT$1.Kn1CrcpG-ʟ:\J}b i==z=DabZ/ .w/b_MbST`?1O4t8jQh7a*8Hh"oAkaD#9|zQ\|IxK9OjI/\_PhAh5֥t·CgɵFƣ`+ih4Pg%Z2- }#Jq2pZ*󘪮Dm$F%G9ķa$+͎%OmemW8{a[}h%x#f yѰTrOg.9xNEqUWdB51)?[lsWqX6C 0\=5418&V*-]; 4EtIe6$yأ̛$esvzՂ+-ϘjMk|-{$n dz~QQ#'SlˍgRZ&1<`)&`n_*,(]@ގ T}،eʂ߃bݰ7y<ˁ|-_Wd4*Í:a,a"FnIV5n[Wksqˆ,J3o. C1a5u,-|0I"ȗ2?^ZCp.e(U =?SuALzT?gM8LLϔ o(wUQC0!wQÎPBXqv2=o64o55%7{'ynp8g{߆XڊQ(y9 o}% Mk/o:yVĉGy2*{Q43d:`mTЫ]~{1i}+ͷBbҟ\կ~,IItʃrӓ?{y:0ESԥx-n 9I蟣fA)Xh$Xp[ް|ax[VQ"Dذ)w@aiSt;GO d]cσ OZ#FK&cs/V'ݍ4v$mdzS"k)/T/y4r7^S݁q_gXou4\Yu Koq#QΞ; 01.N?A& ni7n0ult.JO*JT ߣF-vV9,\r{~/(1(ucnVQ\x}GqýLQr{-&3F=/vm|Xdw:+d`,Tr-ȏĦA3s{X5}_ba共^)"ߚN~>v)GE r|#΂#LTNFYB o`fhfb}<1j)r.~VmJz&0ư E{+d" (!*USvMv}$o;IR{<=0ȑ"fCgŗ1nFtex ܵ#LAn0ȼOp+GU^t>V\cĮ,\ydO^ d*6 Z'8_/wk*i(3.lɮڦ v<\Yb9cfVx SVTEļ+YiEAY%? * q@*%2C:.LTa_e0sm]W Xp/Affd8њ9tgR m-0qOng+Z_'&0M!.`{Sѐ|lendstream endobj 17 0 obj << /Type /Font /Subtype /Type1 /Encoding 128 0 R /FirstChar 11 /LastChar 121 /Widths 129 0 R /BaseFont /QMMEZV+CMSS10 /FontDescriptor 15 0 R >> endobj 15 0 obj << /Ascent 694 /CapHeight 694 /Descent -194 /FontName /QMMEZV+CMSS10 /ItalicAngle 0 /StemV 78 /XHeight 444 /FontBBox [-61 -250 999 759] /Flags 4 /CharSet (/ff/fi/fl/ffi/quotedblright/quoteright/parenleft/parenright/comma/hyphen/period/slash/zero/one/two/three/four/five/six/seven/eight/nine/colon/semicolon/question/A/B/C/D/E/F/G/H/I/J/L/M/N/O/P/R/S/T/U/V/W/quotedblleft/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y) /FontFile 16 0 R >> endobj 129 0 obj [583 536 536 814 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 500 0 0 0 0 278 389 389 0 0 278 333 278 500 500 500 500 500 500 500 500 500 500 500 278 278 0 0 0 472 0 667 667 639 722 597 569 667 708 278 472 0 542 875 708 736 639 0 646 556 681 688 667 944 0 0 0 0 500 0 0 0 0 481 517 444 517 444 306 500 517 239 267 489 239 794 517 500 517 517 342 383 361 517 461 683 461 461 ] endobj 128 0 obj << /Type /Encoding /Differences [ 0 /.notdef 11/ff/fi/fl/ffi 15/.notdef 34/quotedblright 35/.notdef 39/quoteright/parenleft/parenright 42/.notdef 44/comma/hyphen/period/slash/zero/one/two/three/four/five/six/seven/eight/nine/colon/semicolon 60/.notdef 63/question 64/.notdef 65/A/B/C/D/E/F/G/H/I/J 75/.notdef 76/L/M/N/O/P 81/.notdef 82/R/S/T/U/V/W 88/.notdef 92/quotedblleft 93/.notdef 97/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y 122/.notdef] >> endobj 13 0 obj << /Length1 1149 /Length2 4872 /Length3 532 /Length 5594 /Filter /FlateDecode >> stream xg\ݖiRttAJMH/!@WEDQ@:4Az")ҋқH&{sǙOKzOx߅ +amX ^XTDT)"`2 ;a1P8)*z8!} [b2` Psrp~I]8'ԁ.  aNHP:4@#qH;( "0ЯL{,PvD @BD;,C@X#ԿzѺp_T]>z8{10NE c]iw pȿ֑@h_@0=c5p' o_o&t 4B_,N9aC!.ppa:$:a@7!3H M cq_W*!)ZIAJIR$@o4~ot~AEo"MB~AnAo"LILE H0q .  7 F?l @B <-%%(P"I()o&_lDg$LbΉ*/ ْ{#ʓeEgdqUHzg"Zo@vs,~0P8} @V b naEҙBFVM`݊: /.]`3a4tep1d~{(!v+[G!5m:]mWW''/M3۟K{COla@a[c,M7їoC)jܡ;!pO;vя%B]~ke9-+e&[X,>Eqc^ڭ{o%b'CNg4?~oiJgDtq ׳׮7KAj,U㴟2r F?/Ҋ/ ^Hǔ`|&~'Ҧvv&.#[̒'ٷzos<8-}Ox=r`/}e,8@wXnŧL\O`o뫙AiWy{ɱ`*wPD܏Hr8!Y8[j9JZ+%c^qdq{cл_ MΒΎpxJ)_۔)Ϛ$\sp~fL=~ PH~ }ye^H0dGc1Spr%c0OVr )˾.t'',SvLGӁ'Q[(c}pUy6e B>SPjf.5uN]Gze=/0Wt"v*Qaq% ;0ы-QY_T6 nMlBcKk˝ffaaYFۼ4]mi.>mԏRٕyBEmyCj ]qs'J}> 2kTbTydFA{@ ([Gn =*dsF ĸx G`ϣos)4u̟trWg  P 7,S VhEu\ .3t3~YX=i_ŋͽb8Zx¬E-mOh4 `|ڕ# >&X Vᗨ $5ֶ~`LjPx^r?{Uܱ{)P+%"Z6R?ͤA&]VS_Uv"ߩ̩ %T Giv`ny8uҍI'Dm<ĒC),>lo=vD,aTF9>7*UęQT~V8J&gSPG˷\_VLS:[a» z%/7(ELqr*a'gu}И˅}ovhv9Muq-%6;`?,:#|AWjٶU4D{ ^_vUz9?HfiƅPg\gә'xN211&HziOߧjjNxG|eqrr=-o8i\SB#[ M"< J;/Xº}̽QhG8Vg ۆ#6Jԥ` 'yRw_!+~:Znj\:ku^-5jAS޽UP)fjK"'ZQG%3Мśԧ $)J @db.-t[hT3-wtV%G=5BBNw& {wI0JMc.H,a>coIO[s)<] NmL?~a{27wl]yT'@WW.}a<͟SѕciITӴ&č!'Cq`swL? R*Cɨ[쳫i RGdTr?xxkTXT;ݸmQմ~AoU^Gl[oXh|1^dq " Vn&s]e^kmpt/2:("H5uZ/,_*Wdae<˂~1#>'3[9P0X! {KwLwMDQCRn^ihd3/y?C\ $k$ӆad ].GF9/6%nkd~(^~nl[`bϣc(ߚDAlXj7x2+Dyϴt~m|dbx9օP,̡7¥jNNJln΅oU欻CZ~ F%'^2;Gy qK$;Ohu 2|EkhrSY NZcݝ-U|c3US퉮̩g"E^G$e7i\ɣSyt^ǔϼβa| 0 |_#zX7d:+/J!W"F Go(Ekȓ6% d:3Q;|,wXg:׶e &^CUjcߑ׏_~bbIcR,Ulkuc{6Ul|BָK?yq;nrXF{ ړ[^ʎmHlM9u{7nhl֕3A,yHzܵl7lVU?Z]׋ U0h<)4"OίíНj?ݾA5GBsVe"(%HF,vTz(xicIۧ_GuvZ;=") pՃZh b?W5˿7oK+l˼Wz @M>^!FSTwI6ѵuDGsiQ~e$Z9/pr v[hH/$;\%KRi3ۺgXKf.ѽ\R b.ŲK7&. {Vtw B$UUZM`K4㑉[a#QƘ~(?)xdX`k(4E/ ~W9|#_rK"S*sd![E=EӪ |Vg3呕pqpUnS哔'h$Ǻq(tsGendstream endobj 14 0 obj << /Type /Font /Subtype /Type1 /Encoding 130 0 R /FirstChar 65 /LastChar 117 /Widths 131 0 R /BaseFont /RCSOWG+CMSSBX10 /FontDescriptor 12 0 R >> endobj 12 0 obj << /Ascent 694 /CapHeight 694 /Descent -194 /FontName /RCSOWG+CMSSBX10 /ItalicAngle 0 /StemV 136 /XHeight 458 /FontBBox [-71 -250 1099 780] /Flags 4 /CharSet (/A/B/E/F/I/L/M/O/P/R/S/T/U/a/c/d/e/g/h/i/l/m/n/o/p/r/s/t/u) /FontFile 13 0 R >> endobj 131 0 obj [733 733 0 0 642 611 0 0 331 0 0 581 978 0 794 703 0 703 611 733 764 0 0 0 0 0 0 0 0 0 0 0 525 0 489 561 511 0 550 561 256 0 0 256 867 561 550 561 0 372 422 404 561 ] endobj 130 0 obj << /Type /Encoding /Differences [ 0 /.notdef 65/A/B 67/.notdef 69/E/F 71/.notdef 73/I 74/.notdef 76/L/M 78/.notdef 79/O/P 81/.notdef 82/R/S/T/U 86/.notdef 97/a 98/.notdef 99/c/d/e 102/.notdef 103/g/h/i 106/.notdef 108/l/m/n/o/p 113/.notdef 114/r/s/t/u 118/.notdef] >> endobj 22 0 obj << /Type /Pages /Count 6 /Parent 132 0 R /Kids [6 0 R 26 0 R 33 0 R 39 0 R 45 0 R 51 0 R] >> endobj 60 0 obj << /Type /Pages /Count 6 /Parent 132 0 R /Kids [57 0 R 64 0 R 69 0 R 80 0 R 92 0 R 98 0 R] >> endobj 107 0 obj << /Type /Pages /Count 3 /Parent 132 0 R /Kids [103 0 R 109 0 R 114 0 R] >> endobj 132 0 obj << /Type /Pages /Count 15 /Kids [22 0 R 60 0 R 107 0 R] >> endobj 133 0 obj << /Names [(Doc-Start) 11 0 R (UNDEFINED) 23 0 R (page.1) 10 0 R (page.2) 28 0 R (page.3) 71 0 R (page.4) 82 0 R (page.5) 94 0 R (page.6) 100 0 R (page.7) 105 0 R (page.8) 111 0 R (page.9) 116 0 R] /Limits [(Doc-Start) (page.9)] >> endobj 134 0 obj << /Kids [133 0 R] >> endobj 135 0 obj << /Dests 134 0 R >> endobj 136 0 obj << /Type /Catalog /Pages 132 0 R /Names 135 0 R /PageMode /None /OpenAction 5 0 R >> endobj 137 0 obj << /Author()/Title()/Subject()/Creator(LaTeX with hyperref package)/Producer(pdfeTeX-1.21a)/Keywords() /CreationDate (D:20091110083250Z) /PTEX.Fullbanner (This is pdfeTeX, Version 3.141592-1.21a-2.2 (Web2C 7.5.4) kpathsea version 3.5.4) >> endobj xref 0 138 0000000001 65535 f 0000000002 00000 f 0000000003 00000 f 0000000004 00000 f 0000000000 00000 f 0000000009 00000 n 0000000883 00000 n 0000001007 00000 n 0000416555 00000 n 0000000057 00000 n 0000416446 00000 n 0000416500 00000 n 0000818805 00000 n 0000812929 00000 n 0000818643 00000 n 0000811613 00000 n 0000801683 00000 n 0000811453 00000 n 0000800336 00000 n 0000788808 00000 n 0000800176 00000 n 0000416297 00000 n 0000819529 00000 n 0000776022 00000 n 0000417489 00000 n 0000442771 00000 n 0000417362 00000 n 0000416682 00000 n 0000442716 00000 n 0000442567 00000 n 0000441709 00000 n 0000443784 00000 n 0000473393 00000 n 0000443657 00000 n 0000442896 00000 n 0000473244 00000 n 0000472386 00000 n 0000474506 00000 n 0000517428 00000 n 0000474379 00000 n 0000473518 00000 n 0000517279 00000 n 0000516421 00000 n 0000518580 00000 n 0000573952 00000 n 0000518453 00000 n 0000517553 00000 n 0000573803 00000 n 0000572945 00000 n 0000575185 00000 n 0000632161 00000 n 0000575058 00000 n 0000574077 00000 n 0000632012 00000 n 0000631154 00000 n 0000633518 00000 n 0000693483 00000 n 0000633391 00000 n 0000632286 00000 n 0000693334 00000 n 0000819638 00000 n 0000692476 00000 n 0000694905 00000 n 0000756340 00000 n 0000694778 00000 n 0000693608 00000 n 0000756191 00000 n 0000755333 00000 n 0000758186 00000 n 0000757855 00000 n 0000756465 00000 n 0000758131 00000 n 0000788416 00000 n 0000786865 00000 n 0000788258 00000 n 0000786537 00000 n 0000784673 00000 n 0000786379 00000 n 0000757982 00000 n 0000760899 00000 n 0000760568 00000 n 0000758292 00000 n 0000760844 00000 n 0000783869 00000 n 0000779295 00000 n 0000783708 00000 n 0000778935 00000 n 0000776051 00000 n 0000778772 00000 n 0000760695 00000 n 0000762227 00000 n 0000768861 00000 n 0000762100 00000 n 0000761041 00000 n 0000768806 00000 n 0000768657 00000 n 0000767817 00000 n 0000771436 00000 n 0000771102 00000 n 0000769010 00000 n 0000771380 00000 n 0000771230 00000 n 0000773159 00000 n 0000772820 00000 n 0000771566 00000 n 0000773102 00000 n 0000772952 00000 n 0000819748 00000 n 0000774591 00000 n 0000774252 00000 n 0000773254 00000 n 0000774534 00000 n 0000774384 00000 n 0000775927 00000 n 0000775588 00000 n 0000774710 00000 n 0000775870 00000 n 0000775720 00000 n 0000779179 00000 n 0000779149 00000 n 0000784378 00000 n 0000784146 00000 n 0000786771 00000 n 0000786747 00000 n 0000788696 00000 n 0000788633 00000 n 0000801127 00000 n 0000800801 00000 n 0000812457 00000 n 0000812071 00000 n 0000819244 00000 n 0000819060 00000 n 0000819841 00000 n 0000819917 00000 n 0000820166 00000 n 0000820205 00000 n 0000820243 00000 n 0000820346 00000 n trailer << /Size 138 /Root 136 0 R /Info 137 0 R /ID [ ] >> startxref 820603 %%EOF jsamp/src/site/resources/css/0000775000175000017500000000000012730747754016045 5ustar sladensladenjsamp/src/site/resources/css/site.css0000664000175000017500000000056012730747754017524 0ustar sladensladen/* For some reason, the maven-theme.css file that maven puts in place * includes "p {font-size: small;}". This is just stupid as far as I * can see (presumably happened to look good on Mr Maven's screen/browser * combination), and it looks terrible when P and UL/OL/DL elements * appear adjacently. So, reset it to normality here. */ p { font-size: inherit; } jsamp/src/site/resources/images/0000775000175000017500000000000012730747754016522 5ustar sladensladenjsamp/src/site/resources/images/SystrayMenu.png0000664000175000017500000001401312730747754021532 0ustar sladensladenPNG  IHDRji;ibKGD pHYs  tIME "FtEXtCommentCreated with The GIMPd%noIDATx{dU}?;=3=;첳ME |l%jYKSRE(ecT|`L>,A RE&bĈqKa1ι={j; x~:%z~@))Pr=%E qV.~))6[/BG- FA(F) }R29988m(PJq’:;3bis;ɓ'IR+"ZX̎awڑL&ٰaCD%v:G-1iG?1%ߛ_3ҬNӘO~,Dxљ:31. "A-ӂě^>nXZ̃Fojj2Lz0mE u5Q/_hZ uNKU< 4_V#Zk%FcY6n_a N˿]|+rS~֋ ֦7h0DLQ`#‘S^PڕWMYaJy?^5; ?T(WiZ)}"($ba]_iwZ?\;5,0 IPWzŰ#\BBB}W]<k=fU#ͥ_T }H;V-XHhgsG8y:01j˵JՓGкZi֓gnZH_jјOx6,ZD^%\|o$Ͱ" 0/P9ւW/h~z%\'Ab`"axڝMOЙvN[G-ғ1̺+S MϻtfXa9W|d "ւܸ,ټy3tuua۶L n,d2I__xqdjA޷o_rkjADjADjAZ(-&0==]2' B]r4mjժ4Vr}w-1Գ킶soxzGy'xH$eYh s# +>3,.wj.[^| LmضM4%HH$ꢳS'lÝݎ.+9f0T:F\ATjXRg}{d(+˲,H$BWWb1jqv3v>vxv!+xc˟|1L9=L”R#G0<n7ɷ1 n˲p]Z*pT*$xRQ\bIAhcO@XzD"A<#nIz˖-d2Ck-'B,bAa0T /,Cq,U;˲>߻^qqnn>'Bj#υ޿UV"̾ -rK!)|+|:%ÜP\%J1>>ӧX,V;%C166&c}B+bYh Hh4$!bW*ĬhA.l?==輅ǺuD:ߕ< D[%E  B- P P KeXaY EozWY[)FFF8t'N`zzZ~[!^kŽڏmbh]RR-Bjb1$x\a Jjqfݺud-07of8DB&ԖeىmH#G :{quξȄaldZT[&p Z=cE:Dcg3X D-A^XB].Jf[=zChhMXT+43pF_D6FC";\=Da{<+} P(-73OAr #c}B:zCa/ Z0#B 11r:AZjwy#7z BU8=z } P "Ԃ "Ԃ   B- B- P P "Ԃ  QJ5;z7 *2((|afALEbk9uuA5#AXʯLMZAͥ nM{)'+6*>Ŋw%^ms}8ѳVKIew _sbv%zz gc>eK,yr·.U&]x괰vEL|++:H ,^P' αDV #+wiYTԩCM ]eSu&`KM﵎ゅ[e} ,o6o̜+XX0KF `21`i"cMî3!3ukrV(-Z}ɕ VXհ*L,!jŨXkfM8\'m5m0s31)\W}5+6Y[]n*>\c@@E- _u 1 ,P.]+KQvT%Uȕϥ{(rq B;0+޿(;Mdנ3imǼߵG-fL+EXg$\X|w8>;0n_]Ë!H$P.wsŨ6mRÏji%ukAW{n[K_NtN _{fxEmX3y!~geOWWxqxo!^սT muG2Rṙ>D-Ѫfw_F T,g2l,%.Ƿ-?ޮ u]XGωk.c۶`ƍ?~^$\pb??و過κsiTHKR(c0-ͨMYގ u7V'Fo0 3@Fe_ú;FF?CqWs/|}TFϛ:u]%k֬ga޽R3]53GÿI"rпklY![f;Uo=@2W9mmㅣ?2096eg=WG B{s㊏~(+[.xAK FgEYŌ1pRe2('{47˟D\vy?۞~CQ;'lٲgy##ì_NNtn4VBvc7d{wiO y}MZ-}:꾝' Զ$w: JaaT6t"+O1tԹmzMs& c7SZc1LËt#=b!lkmo*Iث/ϼY5O~4^x|Mn0J&k0931_7M}&ql[ci|{V{w( 7<=N>m짛뚟w Y ʘ% [Y|?./S&AgRba8\q%D:}ŤM[S)ňicHi@Zl̮9oٴi <֭/2cccaD9 n-@M2AO;DqXoJu5(e\^(Auı.wV U~zɘ z΀j k>z[ _s1d9 1>ٕ qy<TGE{=c2s >ذae144D2WG^5JrvOɌN%>7N mtbG"{ugX4f`ty5^H]s^w.t8EG=6QE^IxwFOիٴ{7\;ZBz9)oM 355$Iz)n/9R!g#T|E!/`4@6:;Sq1Ү8궹-T r0 "JczTkz¸Q|ڛwinŢ[v 4lz N>9jxGtPe79^ʮ /Đǡ^ynͅ>* ok9Α#G8x|}ǰ@ᮋ8RGw|?HAaZOmfl4`Q11ۖm!1궸 S'n,38eٛKN,xk-hPt{ްzv(^ƍcnb[NR&;ʛK塛?R`WM>{tRя p7)wp)-tC_as^-j@ҳs%ۆ?߃ПN>B*V kJuR(m E-bZ!(s}=\z9䵽9pGǂ)yw{1c0麜dйgtN" tjыֿҳ`9xO[ρ1 q%3GhN Nke6waРgܼ) (L:Ϊa87kF5ov w.:{h$Ddnu1r~:]=Y3OoIyZߕ ĤT oD0^w ޸S+D[6zp* cB>f7:<*17a aNaY*6(KĽ>/ P:"p@C""Ukֹ~H4Z_D]6Nkvey&(A[dIENDB`jsamp/src/site/resources/images/ProfileMenu.png0000664000175000017500000002575012730747754021466 0ustar sladensladenPNG  IHDR Ge{bKGD pHYs  tIME  tEXtCommentCreated with The GIMPd%n IDATxyս==0 #K$*`@!!^"sh\E^^Ԩȍ}c@4#+.0G1e@tPA`0;39=յwUuu\Uۅ Dն8g FNB! ~{F1) cg&f}hK#@E"ăD@=g ;c=/ӕ/!ζ%uOě$"Bݫ& Im;#&]'? MM#Smu@).?9%y!E ژf쌪x!GCs5OxLp!;ɛ}g߂ط<^f_YB;6W0K+ݻl WåOclYZӖ"Caab0d4 큋~;i1fjǡ NR_ޅ`Q1hhv~P}CJ!$b!]( L4DjZu6poޫ !=~p[xTn F`4.^?L~O~5opi 6N&* [X0qZh qBL䴵k_ONt48!$! ; ȏ!(,%!*ӑ!ĺ֮U a Bpu!NꍸPDjuqaBgcK4I'z녠` [؊.HqRX3đZąT5t]ލt&!"Ly1EP[>)}bu R TkP"JSw>^5p:LbeYqsOB8 '!ߓNژtybZk#%uV}ЇϫVУAx'r`{{;.ߟc8EHX4\?˳1}A9o6I`ѬxcX8+P7c0zt%o='b ط9rjZ2cWj|g`ػw/4Y~p^UL_Pw qiV\BSSHhj}R0Tږ$zMr:{+ʡgx38OR$MIMXgA* !%vqˏihhݿy6nF{$cF~)}TW8x1g>؎>뮝?棝;D!(%/BkK[_,mR۬^ϭ݁#oC5Ce8{>zkge eM+ݑUR;#ؘWBޅV` Թcbu){^WGQ]]u6MzB7b֭&eP28TUwwѥK>tǬ_Ϳ5üYޮ5;}myd#"AYJł RDcQ”î;c.E݁F{UFmmho 'jr9ĶC'w|o#Xxoo}>vB!y{0UTT(5jT²BmWzՎi%s$/F UW^P뜠%-‚.1MMѽ;t.`8ǀ~z?J[[o? S~> ;vN*a`V,2yDL&ɗ U.a cǖMˣiF򽱓|(d}_}AI(T5 .ҲaРsOSP--p>zHP+NWHῚt!`U,E S(8)>xJlX0J]RR,_̒1JRj@Ć9I[vQ'!G>}lWۺz!wA00dNoL}pY"H}^LEmw 5l !f EPԧخhzNvij``Yn&2'!cἙA*hbyQ!HJyI[/=;YOiBQvJsPbM%/) V/?VI$-\QChuk#=HޘF6s59GBX:NO,K`K4ݦ1φ#ɧ!^NJΤ]}[ Bq %aL~/-.Rٚǿ~qԎJZ(΁jw,Bd=y /(` 19)n 1cꐬXtB`PA:ipN_P'BHf l BH^B!a(rUzI${7> "ij*onp p8)\i'uFg4;T_eʦɭs>yp@EEf Ѝ#Ia~!SL3RcdXJ/T~aoˣi?sJ"STǪ&"=!dϧg^ZTkUyx%H<B()){/Zff]cFY9yfFx>1|^-e7sw^x=f5 3/?֟LfҋAAݬWSdgs.b| >KZ襥2ٮnzWhZ}hvw^P0{^71˧&?RV+Os C"@+tb'^}Wx^P0d9mt՞ӭYȬ\ e>VZq a#x:UN%O Mc%#u)󚑷Esxz,^sv׏[A{dO'Vn&A x ds(}tNf6Ms/(%Np8xI UT#y{!MLȅx3TT]BL+m0{eӮ-}N@垗K+ySKy&hy;9'x늇@'v F!Jw:> Q3MCB!v$&6_ B7]7EqD?U 9)Rcn81Y1s2YF 0f8ʷ W1AcQ0o׀JVW#uNe-V̜wFϫtm^W-OvJ4O%.)){/ZfkMVPs2ZVIȄFzS B7:6{#^F{8iDʠAk˷y:o+̝> p t4z nfZ}ʗvk;y_z' LfX;{^.-fLW^|sORM8>ˣ&H$mYE8KvhU7a5" Ο'"8_ W|MHaɒ%ؽ!lKB!Qupy9|#.3vBKBs4 Ҵ9u ]v;gKB@ ϣxE ` 7sUW^yӦMW B!$Sq=^ª" O۱(=Űx(rC*c[$ _ڗBAz~x 1 U:юk  @N(Õѐw@i$r^B( t,|5Ghvxn>`0! ݺJѫ'PTx\$gHĂ gB !P08} >/Drڱ 𓋀?>h9.͊ )<+nJ/#$uUZ'ELxZ)F3vT7t߼P03f8\ueEnnmbP`oE"@AA4і(n[r b!{RG TS6rcmox;Q:Vmv>B+o{Q @^ј8v,_,Ùg5 qz' zv߈!2`*C<|!HSLAmm-.6]юr:rCvuZL00N)q '<,ʏH= 4$릨P7pfj^IyӃaٳgK.CWeFX`Kq) A jP(Y !uSPŒvM{D>?t qY0 jUrZjZzFK[TFc髕W/ƩɖHJ @IDATf45#ܨʈ@zw^!''OlhUj+a'4==#լaĀ=?߁P^!S7_|  zQԜrwS3u_h:v5!C%<.({?0t `!h*?iE^cл7Z})ځ~֠jWGówZ9 a LA@c=q6瞈ϚzBʛ%= vdYMS˵-|T ,3Xith $ۀWDN{74ci}0h@T¶Xewn{>Vs19#ĂR<q;b XRf5(@FK\\K\)7ОS_| r?rsB8?3Ab{q݄.]2!F5sIB5h(6{:{*N?4|i%k !SƞOva l`U v]U:$< B>܋~{їipZ O> 7,9O~ŏagk7]|bʵr#7mV )ѳ@@!> _ ڱ/aq(83[̘v=q6h ς]0׬eAҗHZygp = żpi~TTT(Cj3J0׺ B\8 ռxib^{Z¨ \֘;<ꙶYIRJCO|-FߛWXY '~= -|]kO%q!iyC&>32{^OH/tD plhb|kLAjܵEfBU3jhV.B@;q|r/uilMZ!J(7& $k8 -P@ NF(= $ihixz4%|0j0)YKd<_ )#Dt0zIuP04Yo<'fפ}#i0ꬦ㭖C{H uXx[Rc6]7ym:˩c/< B(ф0B!B(Q0B!г@! 1 B@!i  BTq"Ai#ͼ!DD2'Gx;xiTm|~/Γ B@PWN*<%jZ˫tTTT(k-$ݦ'ܼNJeIGVH%a. !R{T&ce!L?G _W1!?O%$Jd99A@!չ$UWW+(RT:<}OO%.)){/ZZFhdo0B!6Bq01 d IENDB`jsamp/src/site/resources/images/HubMonitor.png0000664000175000017500000004257412730747754021332 0ustar sladensladenPNG  IHDR[>|bKGD pHYs 7˭tIME %& C2 IDATxyxUƟI6MiK)"(md}]DЊ(hEQQ_ECuCADqgSi&;hMG4ef2LwqqMgΜ99sg<Ìх 3-7MJ'NyHc'6R|a`\Uk|߿N}N$ } '.&:`4e6튕SVd!Ap=f2Bcf߿Lb@3{5 ]{/,oxwދm[ğ<5)^2be? v@L 3nx[s%4QOS{2뗳u h@vwK{-)s[i" 3if2'"xjE7\]{/GwI8JK؏x$RP^+nA+vrcH/ bNTWdrM8ULpDfHOPz|A0 mNR*>@AX'N=1u06L m.`EKhP;Đ9I H\eV^70 Eie7UB@yٷT_\rƘ&"X@"A8e z)$O N;U .!O@走XwynPQ(2k{0JQ9#Ur"*Rtw d@ dAO3b8k\#΄K՘#pގ9qظʁ\Dvv;,&bT%"# b&.͆N]YY  @~]@v /@آYG-iI^^ ++#''Oz\`Lb.gll68S=0pҮf 9MEvCSpY=2G5R Aw,6&=C]@bJBbFخJWu0ؑHѠbz9Vecr9͐nW2h:9D!1Z~bTh7u.Uk1k4 sU30tzN S(e suCKB(q5Xv|'Ǹ!t<1Oy9[$д򬔾W'm8 afK!Ye{% cCt(2>1!#i]dgg@s!P } 4@v \TRkAw&Þ={/_^RRҰaѣG7j(** _@ 䯩dn_uU3͘`KG>qڼqӎt|a ///'''++ ] cUUUo4?;-">1̯[";φ .&++ @v5`׮]l\00 ^Sʬaa>E?B CyysL 0gϻtѨaL ٮ1n>uTJJ z?v%8ͪD!Cr]_∈-gniϹ=j޹GfVXخV{//U{&͎;:n^w{(f֭~=*a\.{߱a*β(qQ"(O6ף^E'Ąp8if5|[~\eI&N'ynEER?{&R&MTh(k(.G`@ MzIdӇ Ot 6 ]cl DDZЦ_roQKD&L8^Uw?;Yƍl^|s}&JTfUR6U= Hv}NON>O?okiذaNN{FU^_4[)%S&eMdW^fsfffff&"G@@{^ەsO2op]iGg6ڭkܹKؐn:1e_]RdJ.]jE&muFSRcicGdG=r8R=a'XͫZ8n}}wt= c@v}K-[{=+MI'iq%(b6+rA{;#fz̆-ճ;jч>!}jĮ8Ym^fpyn'Ϩ;72Qn1QE۶SCk;:`d @n_*.'J{=kWg;nL^\R4̧fqґ458 .^ID5OLm6]єӧ^IDni%N;]_+<2Uz ;b"7mqIQaq&|:=.=%s7> \hvKm?{*c^DԠakkOKc2w8=8*b 0> v}+mr<Γ1-?''|[p=< v5c.1n v3: f`s]w& |uhqgY!ELXȮ+ 7ܺsq mgda vwh;n7-}&w_;?xmfD&YU1 0+fs +;~'&Y־y~eZk2fU\.  ]&G2 É%iS?NwJi:6mE:׏~eD UZ ._,o#4|Lr.UP1]@պWy+A= AtfqTR~aMPm&9_,:䓪Cmo[և#c|)ȋ%qiwQ44l%eKW19yȜ[j)^pPϟ8Y!~qn"͑DHʏ = z}s;"O Ey|Ɓ}.Gjp_  Bd׭DTa]R<;iګ_?f gwVHej<jd[KQ ݬޏrT;ՎmuQŌ._e$t5x=C( AآZ&סKX#w&)3s?<;7,+G#lL`h%m&1xnyͽ1w۲ICn|L.>g%0{{% MHp҅%*JrFZ _FWzVkKJAESX2 cb*2ZU_ܮ;C^ yS@>ȷ ! .^׳}# /knߵn2 = 9ڵ?283bwJp?{n䈻~h(R=z7zC**]C0D Q"ƍwd=yWCbeT1H_գW{]vlBBtLDO7o{u֘믳jlSCk>>4O 1&eZcAnQΜ")~f[֬1fYpBI5߰K-Z[٥hs>YHss{b.+6+yUe߶2|w@T>2!|g"BVaUtp}{l O\>Wz[[%ozx @Gȷ y;k@.֣?^ Pz\ ǤUdcw.@X|Sb srpzB bː]>o)i& gpr!'Md+e s vϫKJpR)={X1N \V@P\vAb9c x /b蚋ay! ݄\OeDlj n8r+_Bjyr2~tu^,x^y! ݄a>8sA]$^ b] pr~=Z2 vϋ` 4 ~ʷZd~ENIYM2?}-5pc] !s9peLLbJSήI.ubƯD i%-4|]F>s#Le ޝz&0NOWsT@K7&}.b Qە:/JXGy~$B8%bWʝt 8Y }N A&a 6ty!d膊.;8#&;ޯD+t%b-Tj7dja`ʏ3ȿ-&v3kzrM]= H| (D`fCv> l@`h b!@7ʷd@_ =/R1|).7 @O~]Gp@+{^] %JjƙDa >o *&R6B3lU s'7č.U 4 ] p;Lx^=J~hU* 7HX] UkbJp_JE!@&\bbh+^T_5:TN.av!@O ;oWcb@:E]sʃA  pxg;.>fX;5WAvAc.dP'rsuF f2=q@a͖^d ׈UnXIr*_,:E,xGvѽ>9'6_GWf(:49Q*W9-/U^ X. uR  2V {삠4LN`W,t`X/8d} qޮ'm3?bwddyEus$l u (}Ȃ7d"w~6[F#d]Fw @5^5P>VcŦmr2ŋ @X vYceI]E`4ΖSr]| &c ":ԀnWsϋads @wl6J# vqMtrss嗑. ~'|7.v'aK篞e2ع'Szv͂c&fO1&'be85ٝ Bߎ tnʼ-Q&y݋‚-T/rHf7Ed˷ KfG.}KD mH,rrB2 $2[*\ T2.\(aq^^^^^[fddw,#dUtns:0Wޜl]8`mL((=}n g@vF|^#}!A8x^-%\`}*+,  D P^..5vq FdZv?h.dUt! ]'vKxP aNBvAx^.߮sA!pT*B A?!@ϳBiS!:{^+vncx7ϕx81dȜ @vdw>@v8.L  8o@vOCXb {t W.JhnVVvVVvNNrl4]ؼ]z> *oVe=;;ٚe,rYf. M#hAN>u<l6 >ezehβ{2<ή|!1o7777;;O|VV IDATaـnv9&W&0~$ nSlPȀnoo%  vd\ A].Q]s삐]q Ug`8\"W TԦmd5KMri!@vA\ dt͜.JwB2 vn{Dy,̍JiO-&T9 prɭ>b^ESulAC5H?9(]b1Ͷcl6l$lP+ bH`d]zUE6@yg\ws$ɽ=@Pgx*wJ85( h/v%H0鵰zbH4@QyH- hsnZ+ v.io'2 hWƏ7@vA0]Avv|Ǡzd #SN] aA?@v#r\.d\dI}Il%6 +a.(03AvAp.?ݢ#_<PFK,Dm2 HҰ. %˙_`~͂[ɬMpCM4.@郠Ԁn]j:́k[D @ϋ烁q̒\AvNvQw.@z^!.0楘e.@a 5-CvA v>c`G`e. (D0BvΨ2͹x5 )JK4]^7ie_^NrcT: ;7Ꜯ[.C.4JFmWj= 'b>KsxĎѯL A._B/;LW,Z8_! ytt n4ElP psiMK:.'o-5cT.'b>{-4r ΩPc(X @b AN`K1v<&s!b(os|" s0 ]`K1vW<Gl Y_a.𼗝!_XIK<-_N Y}.b %'DsI5t'wįPR bS'b>W@`4lNI{T uHsmb4IK5PK1v<]sKzGzXNa#ey!\0ΠK"Cob>)!\ˑQ_r{MsN Ful74ҜK&()ZmӜ˩_~c+4ԥb=}.b 1HN9b] !`L98|]"9\E%z$it^2]{^9'Ҝo`%b5K丑l4]f#!]9SwIT(ߘ&_#!>W4tW+7AV tCuliΡA]ܞOV4LҜk|0Bv:4^ .Dt%G$s.ZP\@vA\9ZtsE-Xk 2s5 !.]P .s산].Q]s4+.<Ġː]iIvs%ke&)lBv_r2Ҝ+ &5)!s-+_e2 R>iΕ֣Ukh?.4FnׯɘOi97HkU(o 삠yHstw02.bKHs..9uz͌Nf@/ܱݼ77n&RAy]s!@7v{K<]*PrKnN"پ]u=r2KtȔYM4 sr)2yK7. 23+kFs~yE ځj]'{4\]yNJuA|Ps! (=H̨Ex# ]Zv]kTfܢzT-W @ tCu]vlG'-4˞ \+rŠq](WgP*[zCYe֪R]QQ @n4ڨȷkQ{ Y|5QvŞS@5QWv3 .F1d| > K @v d@<u ew`?겲y sq!-592 "[M8|ƒ]˵wޟѣ%%%f^zW^yeN5jڮ3k^MZL6[΂Kؐ-Ln[Fڱc{ _ɓڵ9r6m*6 snܯsN-zDѯ, ϲu0,j骪6ii=V*]kSZsV,S堓髍exrfކ ^x:lu-ukްi< vŶw_l]P+gw|&NJDvg`zpmCusek*[GDMU6"jXiY)Z &%&Sbb {!)ٝ8y^{?rM MX)95gzƌ96wd25ؑ_5q@VSׁ#EU72#3"TPܼ%QbrV86.zcέ][ܩJ?uЬANm[69zcjciSLs7&-)ԵZ̭~߳<3mm|j;IsOnYaϝX/^MD-VM[4MhUp7j|stttu5[ott%4-"~鏶k&W ڵk̙7/̼0R8eGZkcGNjժuz=%T$8cvà?,uou#1 Rf|mڋъ{{S+D׉aބ7djvOމIu,f!HJoӮݦo9⮥} xrcMbZѢE̙/NwX8ݹs&{kÇ~?lY89#͸^xu#{)Spϯ⸅$\MD赧KD]^T+GC#cbǪN?az'Z.:ʑ_F8_Z?a:g ?9v YWz {:liO*wȆN~y$hߙ3fUMذW?7zCJ_ϒ: 5Dg*m\/":z5n}F9aǾnݺuABs}a0:~ 6|՚ͨ2={޵:xEDWdH 3lӃrժU#FHII]ˋ}Ǚ#`jQ%DD~ k/o_=4p"GQ9(/9AD+Wl޼W4=qe5\."j&pm2ټ s5k.#ʑ_5CeLAYtAYKS~ڴnعkWU:8])&gvcbW4aIĮ.Qя-׵X]zZӰֲ6v|x'8^,Sw'I.st٘J*ᗤznY :yr̭\~ %Dt;fz'_^ Q0yݻjsEs=zy晙{%&{ȑg۷snp^>'^#uԡ+??_7&kkɔ9\CoBJӱfUMn#ϛw5 gvB"F~;=7Gcg֭([d%.$4H-_n/`0O72c|$QΜAD}"͑nv9{=|F#^q1-["`)*)M"=Ovݺ55mڴ~qҥ01bfW LzKr|,X9*V!1J国` ޚ?~ĉS/3ڄdjni,tk""{AenJDu.ygՑȮ6soG4G6i׮!o\lgzLTZ޻y30 t:IVe4hm X,vJLL0SFdq~k_F97kbG􅌌 Ι3gȐ!۫W/uޕ+W'٭JLpg.DEX>\(r8? Uk_rQUp5DǦ󏧞_wo>86 nwoցnN$]YK~%ouu"׺ig)U_,-)tT#7ocF#ۍzN{"9K4̩ /)*&S9CnsU~Wu K7|;dEg]лロXRRI CZ5LuIv19m[oֹsgi%쎏OABB)XρSz%g47wKL[P~б:y>ͳmLZW6N1E0磦>;3EWLySSSCN^d7--m7Lj;ud2or}沕m)_(.}17 m^yuFZä&JD~,7ߣhkt2;!C2u\gN;n˯xeD4;1q\5V-\}yp,SUEV+Kڗina]]{]uۥv]?eDe)s۶xu6K 7nӞ>-??IT"JHJ,PDDMzjhw&_Tťr:;}l K9AD$ӈ/}|.=)3+Nny'>QIT)0IDATmݠ7=njTHuao٬'*\'0 bzܝ/?R9{dN:G;:mn\\ܭ:v`)"2MGBs\UUڛj 80))[1Gv_CDk?oy/8&}i׮=nmDԺu3&V]\l/|Ʈ;Ѭ=GDgO^Ӿ\zn%uWf&% -9utn).Ԕx8A{An G>m7ңoF ӯ]*<~lLLLBbW_~QbWjXr\1]/]ܦMxl+'&2r9hsź3eL\sOVbDTY6>z@\N(vyalgTyi|"2YLCQي ۋچR_EDDL%9#Dw@i05wS//D4i@ |nU}>'..BiENCph W\yiG}fo6lةS~y~I}HSETy].N>p.x:yʓbVUWFMD3cnl= ϧpmfR2˯l;'+yf*4MR\>٫n:Ss7Tn7=7AZdiVsIK}֭;a„=Ld6sƕ^}͚7f:=ͣb˫5Mu6J{Dޟά[\y>xgWfk՚KL?j? {kRIV!)55uҤI>7\֞<ӵ'WaU]*,vm|hsp?broɕ<]Rc;b^ /hL3Zr%Vmo6.欉6_?Dx1]3~vAΪJ~ͥsW=0 M[RN4_ G ew2D##8*k!)˰}HVz/Z٪&Q;Jbw\Ի &¼[:s6E1Ǖب?.kJK|#gDoY{ɘqwaL],snjW]fΓ; s]]:kϡTz^ө%JO#&&w pZ-,Jj.&&ri0 澒kMٝ&3% ȅ'V+l@g.)ڴBIKEœēh&S xK*BR[bPd[ *ߊHAkcSoU ҌqiJFq9N(Z@ Es z"YJ=k)/X,EKͻ@.R@. @.@|(c# Ғ?SncgGS\G5>L`_>/Û6p$0WGmmWo@߀OO/lS%}8b4efstx9$ݵ(|**߽%/4?IMMۮy>-03xqoEZ %;`Sg4gQ{&{&Ƈ#1ż˵uij/;_dㆎ||~ܵZM$Ϣ]VwzW[gd>7oO_7172lû(,^RLڷ%aܻg >ѷfφWMYxi'-~']/ 5vq桓Ozb6}|}̲ۗ ޮG2)hHz.+/;f;zESVO0-?,X]V7i-m0YC~0!taw)c3/zoWԶ>=TyH{A`goץU^K*׭u6zv>]aSIҖ+5>WwMQ>*.}}D}4af< .@g"Q ּ X(oVs Ʀ7 ՠm]Dr+kT{|k|T>e/%=ѻH/~zmUFcoxMww1de,Z={VШQW_ililiX|vǢW 0MKoI@kO(ޥzW_=r{8]qz[C5?\SSϖPFh4zt_# mĕֲsǔT^:z[nM-T`8Hբta]J}WxK:S=pWoqQqu{g6)p )gZT4 /,7Xmr ޥFc}`J殮;}͍U^=]}==gϞ5z), P޻oP1>.?hdQWoxg4cKh_Q]7oz;믺KAwhFgu'U{|ROG7E_Na&*%]B4a 5׮wED v?`j_#մ]7.q1lwuk+*u;7ڛ#ƆTU5W) ~k_34w%=V^(.,?ئR;69÷_=]Qvm M~/6%Nj`  by{{{ss3è+Fx3]'b/rWGD DQߡIYu8 '?VYpKm˼e;EwF6xDa+n.GvVjQe]ؼ.fb0PkWxZAg. ]  ]L9rV ] `jnG4hn߭.:uƦU&#/z^t 77rCh%99X25*؝S}kN[$''=|x˖-iiiV~ϐIղ;Eh%yLLȢ+)rᥚ Ȼk=J4zcUUUD*&66)++kmm b:^EyeL;%[-f*,]y^ݵ*bbb322֮]YQQ卋VR%,9DF+K暺l"13ft3W߫nxyu5b\gu]51jjzzzFFٳCDÅ ab:!LOt9DDDDGG7<y\N8LsF82r/X!xN 5ඤ@IB='S̒%K̝IMMt.BeVzn$`A:nʕ_pS𘦳%c!DZ!*}qtN1ZpM[+yt,`Pp{xoZ\<VQ^k`Gʔhr,XLZ{^yy~ll+l&<QjlDi IENDB`jsamp/src/site/resources/images/bridge1post.png0000664000175000017500000003053712730747754021463 0ustar sladensladenPNG  IHDRTbKGD pHYs  tIME 3+tEXtCommentCreated with The GIMPd%n IDATxw|߳}ٔMBz!!BQzWDET(Vl(Ӌ~PE,J"7 5&d9?Bi@Py^ˋ̜9s9ϙ̣ѷo_Htt4ɵ̡CXlpBvލ / Zh@UUYKkEQ0LڵKyw%&&ҸqcdH$(**"66V$&&*ʏ?(eH$U. UcB!eee䤥uBvQPˋxZ-(^( # )H4Zmie* A2c>Q=idh a42fĝO7<#|^\BNUƟfZXَtpj(˗˹w'zFGYxw/d2zw$Y8o>'vyw?y+.wam[rX2Vci,Z"d AOOx`޵ TǞ=(B , [3eY=;`6jйp[ Fo +==uL_j\"FkBodGٖ2Ձ]WOY5 \İZI%/^溓hĎ%ϡ"Q~XrKδ.S՛NsA7;W:jBr vYr^]13a Xѫ.NJOYpK TU.FDR{ô \OQ@AqAAQ-=lޱc1#>v: l~:v'# KwUj"r>?GWb*hdB "QUl$aKq4byO;ؽGL6 E s Mŧ1OC_=>i4oSI*p:Trp.;އӡi(+Kg瓙qƶoNoF#>"(8iegHh_o:-p*p1pLeWBU-ॢ\'ׁMՖH7kOAG 28bl>}NhdמS<\PHI)SԸ,Ebp%'m;!Qu')(.Dv6GjmD9995<ɀb_x#y(3oS|"yEutzLzNIʮFGҲ*iBEE8Kk$"E*Xd2FA^^YYYw)E2qDq>hZ4{3"3Mx)SuR<<%hOңG!A"' $-[r=ȚH`ƌ* 5"fHOOsZ.<֮]+E"$''rJvyM"Hr-SPPݻٻw/U/"\ر={^M$4-[$!! E"Tj}ݛ`4?3r:h4* (Bxx8^^^ݻ҈D… ٲe iiiDEEѨQ#nFݻ7ݻw^`…ӦMy$ tС[ݛG}7oNZZٲeK̜9?^.=zTa%{_~Ceao xLj?KE$|7KbWÇO?uɭފfYfXRO>IΝٰao&SLI&̟?kגf#22 y%H/Lhcǎu'|ذal69SO=ŨQ*}뭷_~!((~zΝ;3tPV^Mbb"G_~dgg#ﴤJ֭[G~~ŋDQs~=fB H~HII}ٳ4w=zO߾}ٴ;ĞFf3nW]NǯJrr2QQQ:uaǎ\cǎUk&*(9…T{C8p 7nNo4 OΈ#hԨ>,:&F;IruQWJjj*K.%..xVk1}ttrAyDDO^s.H,Y!C-GR-رyN[(h42~x&OȂ,XךGLL 7x#oW)**NehԨ[nmժU8NvJttwԊ`E$$$ԯ0rH+|MV+M6%22'93gb6QFĜDFFtR|A4h@LL Qgi]2 7%*đJHy=AǶY*^xQrNx%Ξ oEIi $)*x{FמWܽMJ5Qi-/EA_eh"B$/_+N)@B`Ͱ ڜ^d0udy,A 37]gG⪃lnF5r,Af~l!wh+ޘ]c*$.ŷcEƮ=a] =l(x@ii)'N^Rf͚k.;w38ؽ{+msGx+^erH[#3c^}mۄWG!aC\Jݻv1쮡4jԀfe+%Wwu8pp4 TXSN؛7ofΜ9;w}о}!"D߻ WӶV#jw~_GĐ(EEEl޾L Bq~v;zT6ZnMFFWɓ$&&ͼyعs'sѣXI8SNeժU,Z&M>~ܹL:Ǐcہu?3易*!9|brpYW_G թϾXDG5:^<;e}vDxhCQRZ* OVݼ/jzjѤIJ!Ē%KDDD(O>ĉ PѶm[1|%KHAPtE䈖-[ @mV1B{&f^͓z\e&=Q4-IB#3;^ĞA@@@xyya6+mРl߾ jUi6+)L1U!kژ1cF9]t0MyXAiY B/my!tZbZ֛@* 300Tu\Uwv THu\범;L 셼I$SÊ%ym?#0iӦa2ݗ"_eq}w^nI$'@B21 X}m FjTrDct[--ߨly:ޙE!k_"[ӏqcpRz|۸,<9^u:fʕg~mz-:${3y,z[08l0OMmOC#^{p;:&&&UU$W`þߓTkϳSiZ%mAo`T#{3t Dׂοo֭[YhlIfսQ /O=4s 3I 3n^ Ҕ qPUɓ`IDAT~<47ou2rHG7l裏2p@z޿{nzje˖5$R$“{7@"7ـ AIY;|m|'t#1||8f„ z.\(**"11L̙FaС*[UBn$Ckk0*>2eoNk^^===YlbfϞݻ@׳f23IIIaϞ=s=<,[>_}v]p{… 9q#G}׏7x]v [Ij YI:~[PơT&9E޲,9PUR@~Zv& =zq{gh$##C.)Q퐂t(ŜI|g:PՅ 'dqL U8q呗Q())qδY$rU:(({JXv#1 VULL -iӦ.]`23g,],BCCh۶->>>L:V˺u(--u馛cɴjՊN:}v㉍-L$uI2 JDDIߓR%\Y5k'|Bn(++c„ u>d21f233y'IHH$0V ,`ԨQX,|ƼyhРw}7<#;vL.ٓT# !eӮHJJ8XD%} \eNedwgRmpcsϡhW^C:tNǪUHOOk׮]ve޽("KD7h45(HKI%ORByp8 5 Yu 4iԨǏ?'lQR$8>m+(h>V>>4+V`۶m8pΝ;'[Ʌ\uKϞ=ٳl1pH$'9UUI@9˸ NWkW$BE -=za2Prl5iHP` oN/\3"Q>:-wcŪ"' l߼ߗyjSh%XV}\Hǎmݺ֭[_6 r׮]4i tI;?EtH0xx@RޞķwyiР9o_~eEau>cڴi5CQJ'L(iӦogȑ]0駟{eРA\u=IVVkoaС4kьNbmt:f4&6FMq8o ϙ3<ϴ.U pwbŊ˪ ‡~ȀXdٓ|;;Chܤ1'K?ʲk嗼8|K~% y{۵kǐ!CIW@-(((p߸$Ohhh%7ѺFٚ7oM4]vlܸRORst4;qƑ1Q[7nYf<ѲeK,YO?Mfػw;^xgyEQ?~|o߾5VXXۏbW59UTVrK޽ݳhĦM/xꩧΙ!\"ջHz؝۶rx!Kuu@_r_?A QVVV3'9n^رc۷/_Hp&22mۜoO9rrr9s&fe˖cw;'x lrss:E2(b$:ʖ?ʨGj p)iҤ ;v`ԩ 8)ճ#_U 7܀n;'F:.4WMӺuJsUuP*RgyP-ZsC N'+ۧOѶmۋj7^ ]rv]֚.--mx^ iii:~N]^9>Cxϫ.={<8_hhժξl][??jMAZILO7.Fr8uӅcʔ)XիWWn2X|96}~9 CR;_,X2ݻ_vuDQ Drჲ%R$*mn5/"HܽY UnzY]v]2)DrrHIIfjVhpu6uihҙIN8e*bZ0܌NUpTVΝk]OTo^Ve2d 0@E$V1Z]#ƔXSDÆTvZ v9sf]3ٌ9l6fΝ@Pg#B.ղ!5Kgx||}#EZKN:Ѹqcfߋ5k֜3љk{11w\xba6Űaêu:kJ+aB*~w^PUU M&\.9 ks&j1`zzhժ ZtfEr @S]mx߁w}/^:tpR]Czcƌ!..(v}^?ҙI$u&-5 nvxKrEEEL<y|}} PWg"aٿ?:@U34/;rrrDDDPEhZѮ];~sl8䈖-[ @mV1B/Tu93I I"''Ju&r?##C8ZgkQ$WRy\}]8|3UOw;U HngR WD"E"HH$)(,,d̙nݚ3gRXX(E"O?K/DDD˗/ҍ@~RHrMG1t0{FFFDr&ƍCsםw( Ǐ"HΤ[nQZZJfffnR$k<<<˫6,4%Z"HH$)DD""HH$)DD""H$R$D"E"HH$R$D"E"HH$)DD""HH$)DD""HH$D"E"HH$R$D"E"HH$R$D""HH$)DD""HH$)DD"HH$R$D"E"HH$R$D"E"HH$)DD""HH$)DD""HH$D"E"HH$R$D"E"HH$R$DD""HH$)DD""HH$)D"E"HH$R$D"E"HH$R$D"E"HH$)DD""HH$)DD""H$R$D"E"HH$R$D"E"HH$)DD""H.9&TE֖D:$r%HH$)DD""HH$+3dMH$ՉfZHj.6 WIENDB`jsamp/src/site/resources/images/MessageTrackerHub-table.png0000664000175000017500000003124312730747754023657 0ustar sladensladenPNG  IHDRQPE pHYs 7˭tIME 9p IDATx{Ek1Q$x1AF= <.%:,%Dú<&BGQ9G"r Y ۦwuXN_W׿ڬ (5]BEלg9Xd$s-gQ) lW{p`sdQ'@.ul Pz/j:>Pnk]n+BL#rq<(v\Ԡ< x ]fϺxvq]!.3>}ZX1͟L <_&laOP\s ~_k Fd^I!Soyv_ J|X PVN?^_`μsz9arXJ&bkkoǭ!J|>i@8NGAzҸzk׫vJMҿh8rq44: ajT^czEOB!Rb8JR.mrOI_ ``MӻX #_1ʱkn3 (WI#bc(9W&_t\HЏ2ZfadyฐÁغܖAk̬L{ M4mIh?_Ņo[bhSUW6Na%11UDLARg[P{wZ6gZӜ9o'?> Ȕʺlԙ.*4wY("?j΢kΓ_9?(Ey8@ȑ-k (n5ZTnH4β߿NPDip(Y[Je|y:z_o[{$8VZKxȺt!PJ__C~}A.~L^-2Db5}k+kۉ-1!-C< }_ߏ JpY7 PD\yC'^PNvhZBVK!ZtfzS.2@ o6IٛŋKQ -З^H1_\zӕ^{{{m/AW?YTZo.~jzb0 QY\ ~}\-Vry|wP.\$3l jq`H޹ϔj.il>Xlx6*Ov.w^|KO,d"dzFd%lg\k}8LvrM‚$'cx.<>vv*Ɂ2.|s6q{Lpj,_05ʝWog{#0LOȣ7̓eby) ydZ_~wȍ:&_^[gbQX ɐ12.zQYBeG-+===US^cb3!fFs. j^_F5pOiK\r$^ƃ_6%-3jV,FF /aOF;KY!(Mi*Xb8n1W%Ғef4y %0NX,%XWcC@V徯׷o Ie϶R.+B_Rd%Gxi,lEA9\ˣ'xyw4sToF#Bxa]{ܶTV5Nzem6kb kG~6=*J?b9VʻNSgVjƠ'{F.=]PcF޳ߏ~?|7ou| ?P={5w_!,]&,iS3{`\x=˴#?zԉ˺_XsE`b?kEWm?tֈGoy;坢8[5Ҁ{̋nJH2_#K4lypO _1i;|ԛZ{~hM=bbߺ9缕ݝb2z@&Bo,Ћ?Jm6d2rgHߟU>;Ȯ?0vxmڞ#{ |m=> XB"9?fa{ *dGNS(;՞o_ƏNӫ}]{B>YwI'=C/Vl&o('"sll}Jp*rs#i}cNqǞo_~U7L8yЛ/oxW~?"}+oc(3VYRI,_ok}۳F[m]K6;qkN:Se !_<>+jFvװ&}حw]e$j"j(]2OS22 / 3>o7\km6hg qᨏ>?5N…]._"Zhx}ض!XoV)o}ͭo)Ko|w_yƌ>~k^=3A޽FNVP7@V4=lۦ_߾rw-%7kdIgG:*VʅٳL*8bG^{3 ykƌ5|hib@l|Xq};G#M.h>+P$}Sj̪gUǭh4^ (SDVn`(ךȟ !ɍ7~N<.Ӕ'pFcPAW#H.K|cuY_VGVuK쨔{[܏cw:rDZ>4ԇ7{b͓m m6_xC|?n-o:- @G:X)w> ouky6=r³=c_꽻K~1$@-gun0>x}ojE9;jǛqBjg.󜟵rOvaFDT޺)T΅5^/}W->hGyҰ3JJ3"4[ rk AZuZ}?.|ndD?BU<mҪm*Z_z#2Jr?߷5n?e&xn/ttϙs/ԘL>,7{,(-ls7_ /_/zyn%dXJO0 $'ro8t 8~+|7]B;NZG8] ?2vaB,]^7_j&Cʪ۽%, &QSW!Jɽ+M?GŶbH;Bj7=2tH5k6lI|:"z# ?6q#ɸ24f`~3~pRn*(h=_y&}!=`O^ח%=`*<8i :֯_~Z6~ bPR{NC(gqƂ}pCOmPo8lmze~h8=1S_+8I6.y|Wg<{^wG$k\3>~7o]weü/P^ǭoo(~ISnGmm…gtj6X}/waH{?|)/?M?僿{nH>gŷ8V%T ~b@WsJɦBaEߚ,VuGCUweKA|dY#YG~c7mBvY0!S_x۲s9'Yn [_}Ф54[٣7~zzOQiOX`&́E>#k PǤ;"W&}WDJS!O;d;⎍#jVȓMoo[`m~{7N +B~7rDh}wC~T U_cџiB#2=NG>@k>;KaoYw'`6?o{ ;6cֻׄq|#Űa>uw5}$'绬}Vv%LY8vмM0&Xr]EWo/_8m_o]Ex]Mh vLw[jz[֏Oy{Qǯ&- !^oZ$%|O06~ewIhINy&LX>lwZ8o݊jx5sλW}F7iۛ每kMX Dzʶ?4wYgUazïi8w⥊[`;}d{<1o^pOMu R&'=9!|ptutI'tʋñ6lЃ~= {B({:l4vA9׷Zq?83_.?G7w.:ЙwY- =qހFxbh=3ukw-W_7 qv2eڋyb !fKgHDʽ pos`+8[6.y_.8cyOLw^}n3ah=|:#딧qް2/4lj=KVyԉ˺_XsE`b7߶Ӣ:bh{ w.嶤z&'Icܼג_^kDk[ %߿'9'¯7&auf\D-3ybKfLY.N@=G^sO[?eñrxNG][[/V='Jq8!Ĵ=GN}o9e⾟=|X!ν֯{ꩡm. A(5=ЋX94zYM?E,FNs4oG->nu}`03g'bsks>wC|?f߁;+yCA{}n1< | Hʞ@EpDxYBT\1z۶^Mۿ-Tuޏ[9]{/}W0i-Co~"^ ʓ>'zAz+T-^s_zu%u:∵k !?K/d7IIu]y 3qReE_חXSƌV!|s=wі?ǸOcbV.k*/_* G+/1!Gv!=B_OfCzߟwNci gQ7i=P~):EBh4fmvpۦg6lζ!Y3]tyBBEA!ĒFdmf۝mfǃ#^Smf} ǂ_K|@' !N笳ޣs `GgE*={6pUmh=A>C5@Azwwwwwwv yӛ(S# үgɤ*(i'moT/@RɎ`]P4k%A(BzڮD jV|Jn5NÖA(R ' i : kCm;A(I 'ljJ$ݑZOtA_22rE_(Lu~}0W\;۶ΎPEޮ73c ҇h`k4=e=ʓJ%A(+ocoΎ}dgF0ÎU 8B1NMk3PN(b8h=>ua$D޾M\A$^Lal^3 ,ps3*D.hd ndS1WU!}l>8Ί 4DbXy:$xXjIVVd+ʝRNe1idyړ;r6IDAT6F!_RX>LW yIgWg" b+Nb~.N>+G$d9471rvz[Hʝ9;ё>2lU4A+Wg>:>&隭8E $瘉{$cK}+ywQT[+3Þ;◪0J?,v"˧+t t$O_ \a뿛Ez;5m%țra<%e%J6 +~nO/(.Y3]tyBBEA!ĒԩSfϞTsVL ǡ#(=z5tnDk6ol~<]VN.)ʭ8(U{B3T֗r@ ܇x>$۷)k>(1P| +t'RP@9^R*gUլ PxޏrI i)ڪʗKCX%H , A u"+ l0@9n'^9 ;j` P(_ܠmʼn.K2e "[t"!(_o[y(Opl~KsQ Rm@i^#m=_ĕ,&Fpr$VI,Mh}aQ*(pz@},8HOh}(?h=Z'I^ o%cpB`윳ۓLlJV)&\W=ӧY/ R{$|ҺN׶ ;*7)>6/a dOZX,z"Z%4%E@!^ >%m J h=$ZG_F(K9#E0iz*=\Ҟ2Gt i!`c1/ט/XL5e/ @G/`f%q'PrW#ѤȽEY:"kθro!%.I7BYYC}b JsoHu6__ v$pB5#W +#E3 Ϙd{Czp*/9,JZh=Zh=WyY> ~}Xyp9l(,١9\@~$/J%|jv<<\`A땎vChw>k6qD뽰f!V)b(ycܯbbN;Aį7 KA 2”Hv<ӑ}7pAd s &@fit q}~}?;n;@K2VnK ,H^|ЩWBjptoa_ 4QPڤoJ(Rb:_YþJ%h6VD8lra7#wjfE^:HXOb։ WZh=PDrZ3HK#tR4Q{UBMj#9ET:߸V_*rt4NF:P]iRe"3ʦ&gL7n7:Er3~}9j*7oU5Jy4N|ĭ O-UN=4꣕2~=I`Z&L94xH4[Tk^a+[n=,,G؍98î׏۸1T\UJ}ZWK 7I/pF9P_,ʚbz1$NdqmTIbj' OXQyR6Os7&Wkkf14e>w$`4:Ls5!rPŽ"9[W>1f-'+XcOneɒÉwwzG/lfBڔY_c",\il&:VMUvИT?`f+N< 3?L19"2{4|uA㽽ͱꬲr}b7<5{ S:G˼ҩV͑60āH$ud my&a=@Lh=@i" Mw:yX ֣exg7O6?Snc[V .ۇg@z@zz@<Ӹlb5j}OOOXx1V(1b8F{ܯ^%*Z6lvZ6lv ͚ykB B,j !,]ݮwԩgz^mf%l'tem.wЯ֯c~=ܡ'U/1OaAr}}}mכ~g0? MyHׇ {;yM|;@ZzّNwXy +7T`\"yXz' k:"ʨZ!j.`By81v6+W֠AYyJh?Z1qܩ?9m ֓&/^t"zܩGGzj=wYj,k}VkZKsUzsmQ@{ OڸNYg3{~QnּgZ-RwwcR >aTS7:bn_omYҩ" s`D4 I$F ! $ad5dj9z)a~}V)M/1t6}T۞Az(֗#_na _ XTίW> gE땮PF(֟.@&a%Z/:W~$'UiVO+9dՔaQN Z/$ܙQNc="*qt ~Ke柹O7dv)Zh=Zh=Zh=@ّxμ4H^G-;Eל/"ȧ+0 @i:pZZh=Zh=7kBjFxbPrB!(_c&|(%m~}oo/(ֿqLPZ_ty+3oz*Evx[i+ )|_?8n+ų(^8"Y3Ų ]IENDB`jsamp/src/site/resources/images/bridge1pre.png0000664000175000017500000002111212730747754021251 0ustar sladensladenPNG  IHDRTbKGDC pHYs  tIME %^tEXtCommentCreated with The GIMPd%n IDATxwxTU;5:I =Q:}WŲ6VTݵ׺ HE!4!TBڤLf) }=03wΜ{|= 9a„nW^y/9UTTd9rd͒%KRlƏ-2nwx<!KI\4MnSYY?m޼ysn|t8l&77=sDDĠ*Y"B 5Ȭor<'s'lDuDѣpEEaX4 \3 OqM!!ԙN>2ssX|IIY1J)tlݙed7]e˖!-u9g"f#E$((8z|ʪ}tlF`m߭He3 YiEhlhEGMyvChv+/c-DF"%~ܼl -s먻whe^B~90lX=x;Îej Su|GI? fev7|oz=]ɰց (#87c7֧sE<]ou!0syZ&$f'lz9|;.O~SDu 2Z2BDH@ֽ)q=(,eX3щFעєD P B4TYZQ,n7eVWx*0`zÂfOk`1Pj70s#eF(nb7 q퀢W(ݼv4k<}3^q3`bzT.`18;R]{Sֳ]$ {:{ܠN4hzM$ (mbdRaw03_N\O(er:R!:ӏV|֋~0$kW2W=4B 甁͆f5[8KP΍.do͂aEjB@S( 4&4OFS%1.Cح8-vvh 4@W9XDgqZ99G[rϤי1!r`"|N ]zPb郞$׌Ie%$3c-{7e6XM,&+Ɍ,ɏ6aӧ唗V6r/[w,t#ZȒ_9qA<~0u?&MC?0uիl~-I)oSwnikbM֭z+SnU2;9Lי90wY|=z$ZI[&_«}W%$?~5l(`QVkuH #oU@/GR^2!M1v$u5dڮ,v&MiL`T;Xs.Cs=@WIy m&P!݉c4+a22>7Aay RƾNTa&9ޓH>M76/sͧs]`&2MQpB޹4n^=jCR^Sf}~;=o] {Ʈ'R{&הhG)D>}z躎ŸAѷۈIJ$6<-IIy9Yd Kg?\[fyJ.mZV3QunvY%x`QAƇEe8FvN)}HLp&ˬeӊh²r~#3Clx8vʧ$nHvwE^hS KD9*9(^,ĦQ̢&Ӛ@9pYӺC-h`P FφZQQ 8m:槬ԃd/wkӦMSMM:;XnhFyy9Z)(sSPPЬW[f=LHPJJ̉{ Eeg]fPuRPuL5[[h<x+WnCU cc(_Or/HH~mլl2aۉ 44㡨cǎQYYyVJ"ԩS|@uL&SѳJU> *e ,b S)S\[/\b-I\\,! InݸeIqg\e˖DE#77Ӛdq QuIH8adddrJN"vݻisqQ۾};vjjnݺѵkWVD3 O> 6hL&|L3V@iݻ/LHR,\-[CRR)))\q0l0ĉXp!S~!B߾}waÆqSYYIΝ駟f˖-g̜9sꫯˌeffra$O>$d˖-DFF9<@6mxWڒ;Oھޙq{ 7܀CX޴SLg%11qF{9/_i׮`ݺu >Ebb"yyyK&${V_&O>g#Gp؉snj"== &C՛'//E / 0`7|3k֬a<#5VZEhh֯_Oqqi=j.+yA-[ƨQ8z(}a׮]Ms3yd:uȑ#ٴ:āl6uf 222HJJL"9oʕ+9rHÃfIT@RRy)^znq߾}ٿ?QQQm۶nzԚ5kcǎ%%%?<3FI祹!1 l.]JǎԩAAAoަMÙ5k<2;qĺȒ%KꫯIHH#TUU|5&l<쳼uY0  w&HNN+^۪QZZڬ:uz^e]F֭|w$J.]ڵ7z{ ڷoObb"SNmvspLJJ ɧ5}dffҥK{hѢDFFrJ+ vˢ:t(}!'O/o35MkV_>33RڷoN7tݔYYYYDEEՍkjD/kg&[;6D`` ٘S(ѿ&#!!BB"D ?]g 7R?Z?rYM_=K)?#,!tF3UqlIΆ?Ƴ]>3-l ~_X\|c#4z{`5mR\5LEFLwNbGAC[re.$%Yi[GOboLPU}Ov4/E㹿xl5oOOt=WFn\ڶy\/=]&$Ur]:ܣtA)}lv5 V>[lJRtߞ|sOן5(;_'=6ӦRs:пcqayoPyo?n hXvwL|y꽟iW<6Ʃ 0w83_˺]BY|C}O.u5t,f11\w#nE0ZIϭV"!97~`)7:"ކqU)toz[8 e~*O= 3&OY9g*ܘL0OZ֦ U 6Ы4c4>q՗&yLZ8!wgsM 9TW4]\+ѽgMp, ~l&*;0a\VEVNz HbqC۝MeUwk{5 u>sY_xq⵹q%`39oM": (~Jk4.ʒ!.$e\q|N1;> Mz׆0+v)n}(#&a0iiM2i_f=_o >b 4S\0"vBI n?x<5 &$*Ň(..3*_I@ąݒԭ/ylx9Jc/S9U>mLϻdɯ ~~!i!!&\\Wzdp,~f!'quܕn]2DY,i4Uv1sv8s )DǖM$ЩU:Oż`^w. ^q< 7^֦9 |3VɤC^q|:K?(o0LZA\\(m_iջ~ GwоeFAhh(<#TVVk^{-ӧO'))VZgսA"""dܸqt_OZZZ?/QAӶثVY"H922]/%*3=[}<ߥ,JnC~3n5|5*x5=RJ+>H5WZZWaaaj޼y*77WrƏ_H^eXTttRJrvzԇ~x R<7o/PcƌiTi_V5$"HU UPM44Uvn,UYt2,J)nYo]n>ѱcǺR}ԸqRJ\.ZՅ$66VnRcƌQڱcP3UݻwW111 C|Br^jjQ^Nڦ/yeWNz nsN,>i-;v)_up!uTk׮TUUup}u;v}~5 ;Ν;N2&i D ('2A)f<{JX`gԪ~bfRRZвc{?<ﯽ)@tt4Dg>[-͛M/rwRRRB>}3(4,, <`V-d8LYYmxa>-Bi~octYה=zǚ5k`ᄄ駟|Ç=z46^pf̘իYx1ڵ#>>cƌdffj*n7P}˺?{RRRByy!d~ULu*7Hv2|UVl?<ڶ}zak*>R%\fj׮[oRjɒ%*!!AJ45bUozϟ_%KTbbs?uիW/5vXozǘ}avRw{B=Yߋf':$TEVa^"_b/! $=//zw%88Qo-ZͶm#<<]Xw҂'$$IW8S=^s۔ٳg*|e|kx mP*o%J).Jbb1u#6Sf˖-O ș>wԾw]|kY/ CR)qDf|<K6~sN-W3@iYh^/[Y"P0jڢyvGY P1-pa`\C,GPeZzò{ AC,4fVXr f2g-%yطgHKw_&4{';{VWΤ\H;q\UWJP(=8K]--$"^IW 6>lu6p«nR,)ٹj yY(@ KpO,|PG<" lb1utZUc({_"ɔNi3 b~0K=:H%-9^!&D%V<$(oUqkq+UJ&2 {C 1̬H_jq{ dz&@r2 =` c+x'X!]M/Hea#ئEYގ0-mU'YR1y+A?YA ݉nd^ef 1l&CҭV˷g``900pk(n9uK1?us5j\*+j s;6䪳Dx ^Nb+vdU/ Dcu['Z l=o܆ PXcrKbעEȱmmPd0Kv qlQsq)δ (KJq-E6_,V`(Ko_5PJ9.LQ^'@`3` wwwsjrbWw9{Htﰧo![q___^oo- UPn9v-A.9eD+vcX&86@Zė}aqN3r^0p&:{{{]0ʥT:f'p뗽-rgXP5DU@]|U!^Ope})=  49$ Ͷ9BXoj1=R^}y%j Pm)wC'KdR[؝w(+|7ڠ/!;1Ng#A[kU7dE| SFc&# `^911 `V$`3I3W{==㪀1ɰ7Z:<+J/rMz;q qpJ`PìtS$>ﵚHEɐƆMI`QaCt~Dq~9s?AFnیcx {9ٚa$݂nNw|饗>6;kO*D[.!hIw/Y i W}/uI,čF!j!+=i1O1%Ǐ>袅peGWh4+ns1BI63Vr-6bG;]Qfd{7'eU|==LU? :0',C\;]IUh8z4ǚ#[pTf#ٳN;-8~_Oڝ n/ ! !h]w5tQ:-'mG֏WD.r% j.O/B|ɻsAG5V{G'pz4w{KO߱B _w,h6 ,8蠃{ŏ_~n8?g$9ظ/@2wܹsPMVcxdߏ9ٹx@3N{gϿS]ܥ:ºWoŮ'|ͩۍjqigqlfEiu2[r,e;v\?cts[5[;nd~ıcܔ%m5k|ٗyfx^|nm4zh ;={h nYJ]B Y~0Jx y M& >#m:[zɳC;o}f[fs`PnY#g}g0(˧yا>0qW]KDn7|X sψurwF;vqxo|S=w7==/~ҟϛ.x=:#u]h4?uj=>SN /_uߊ'_76ǫ?j% btRד/-∼"Ih4g~{_';ퟛBLO?o+2c\?ރ66&-/yɗjWVPwP"U9Ť"(C=g+~089k޸U'wsё5!DO?Ջ7}9cV[cQyE[K#H6r,^|nklj|eK?cWdO?SOUc>|o__p{_|KZϱCy2.ͮ齛FToՠ(5㵣:ƦnX?k4i^;ޫ^Y5M)Zx?umN5ž 2Blc͛f:l&SXuB2g_ӎc#c+o^7~+_jvt~xh>g?s/~O'42:Lp##m8KnWWު}{yulvu5?M7ܺ7Xv {5~oy_ >=<Bc\BtC?:cO0nwE\F'͜ߥjSX <iƾg|_.k'.sPL.|yɱÌv_zwUhE <29"q!1-ȱgf7g\8ƉscJ390 9>.k oZ⡥7zџG33r7*VM-oϜk g_gQvXd5 uGu_bBt}#vKCu*PN-|C#_1i:qvd'}}ᔉA<2h6]R~3){ߣ?DJ鎏^ۄ w,[?q^Q!/x'fhr zYUaNZvc}b۩[KhcbFy-L$Č3V^] }=U##|܉Z3w {m<!{Na{%ƍ׿\N!/^͌IOHIUU&㩓'f_h]Egv{v_OK\ 9){0 _9quIضӦ{mn~kw6GFFeoo_ ̊[򞉓jVKģ:yʋe6VyoWS&{͵k_yqtxh}W^K}һ%jG)Phض# rp:m8ejsKw!  V9~z )Z-79n:e2gyCE8c!ر+Ў\/]*WR,_!`@,X[}ֻVO|5+ZPv-rH6c05clj7R ci~x:CH|5 g|}c}k.;wI3!LRbTU!9vſewۻ<_C6kpKVlLc5 ~+o9_XzɫS?y9/[֍;;DZ}TgL)ır'y*gE6)d$;vǟxg\u^lCOZb9wqjC߹sSbgX&  Nx\sܺ7&O:2:D$.[8!bD\)+S$5VKmJj.>XjY !>ksCѼ#p !n o0׻/MzH& g#v/-KBxkd!Ě;yԟLBtMÄ < )r|9‰?Du.7}dtё߹'^12:~ZH܈gLC_ K8}NC9`hC^ŁgLO|;aFZ*TLoUʱZyϬx1"eS>x zo !f<缻{+sK=2 @ߞr#O7&_zkvۉ'{.(ga+|S}]O3Qs!YP0qhpO=aSOvneV l5B6N]a͵_ ק?$P&W?;B;0w(Gr-HIw BZX$IxRFs]ae& $nQ,E 1I;$omSul[&~1d5^י,E}\Ӣ<ָ,Zw!PŎ\mKF _\^-D|XŇ_S @8EV&\+Xܹg?|:c(G.[#qh/X3A%p`d9+oDžRS?0q,c(/8S@?'M"W[K( `tw*$`Lbn1+[y`@.y;څm3ЉWv Ym#ǀ/΀㹻j:cfcǎVz3[&LwO~GF 3 T> +Jj9J tcǚz;|Ր1T*;/9$3FbMŠy[1T' nIff؟UvCBQbP ĎЅf,oh;<2r uA3 ʮdZcGFR=\BjG,4xϲ v A"89U*\Jz'@o?Zz&bbLt$Y;.&^db}KV-藏˺E3YxdX푑c@7VUFhvb6 /6$;X|P}L;<8lbP#yk#c@'1lw b[|ENl˭mY {ftd63|SĎc#WoZnҤ9Н%Zٙb.!;t=ݓ>֬(49O#E֩?<y\]G* :ݩxyǀ/ȣy3Swm $@d ҳW~d'VT0u^+/n.lSLdHVO7u`<#CdV`Ŝ0blyP ;l0tUexOr%^z7Oiud*D_} S:==}:9 zd튝:'f OU_ #Asf1TrVArV@|1L$\61r c(u`1#`AЁyP^cAjͼc/<1TbPw9D+}JXlXi`MnM(2w}$ޗǥett܀C##PA GFae tdu 1둃1TjNJWYb"Z\}$wd֍6%H鑑cG˽X]S*+y*X푳n`x'Y,~ru~brV @L }&X?'Mcз; t#9+$:lbP#_y}c>;X5V{ v رC=00XL;=V"!l&ͪf??-$k(,vy_layw5'vU葷ݠ'2a(q@vw q[^8 01)7:%xv 3 Gn=m)BZV9 +Y0{wĕ`XDرevp}SOHJלbWSET9Qwy `/N\A }N\@ r P( rVHYJ)J{둃\p~C|`!wHMezY%ّc/v{S>@\N!Ohk,vN|etޑ~'qi`&cqaw%/tRd+=VXySyw c̰a< &sX6 [`0g) 6c#s ` ǀ/ b`[̗ٷ[9q }2t|䧓SF:٪xKةVGveZQ3L'XP4Ď[nW}ޗR}ƉeH0N)Թ.ӮSQЯ蔳ǻ0΅Y<5Չ$(;C,Y@06kYw׌&)+zdCp S̸KC1Ԕ; W/B70ՙ>NLRpO/y )&v !v&[һy^EK3wC?r}SO,,#[Xi3cW\Hۊa e2#`G6|T#&+ɞȶ!LGX3"FLB ,%NDBClB*;<;[Klᑋl1C,^Bo+$$`rNw P_lC 4>Ee*u-{c݂ q[qt-"G)"?1T7G" tՁ5ܥ"[7{c]ccUԎl%kcuݵJLlE $rE׊]7 w9+gʼ^U1C#눦`h:Miݝ3,9CT!WWQ꜇>򽽽1#s+kȊu`oD'ՙ-GDBĎ0䬨oʇ/cG+Lr VcU ;ؔ  ĎWFj6/% #sf #`bP^E%r fPOCB w}t?nr xdxW&}ՆVΗB+"$yET>VVsE`Ch_lXʭ6`dcK|en` 1X=ƎmMy1 631y'XBE}+?][7 \(VrpGn!\a-NI==^1;~0,!֊p9+~ʓ*(W+E?4GSN2lE 9jzB kšDUN]g Zy_--ZEF YKpEFjd;㑫1U vl+1&}8֓x[|q!9 o+[1&yǑ_*-f-F8WFcY;(;:k 0qxGO׫>-6ۦ|x#A DIoti1m}1̻ceAŇ=jw cPI"xlFkN"6jj{zzx> Q1S`^=©0,l¬0Nޚ֑@hw<"-y `4Vۇ*54 `pKFIENDB`jsamp/src/site/resources/images/WebProfilePopup.png0000664000175000017500000001643312730747754022321 0ustar sladensladenPNG  IHDRqfϟ pHYsMWtIME $crmIDATxߋGvkܱ'R_< !oflckdmdp?H $E@B($yAl:Vdm6?%,CCv}bƲ`wQxc@= D?U$,:OxpD,=z"(MOxP:H* PK\3˿k.^җG ;O_mrk[_42˗BhIDc9HU/kuz[E#UY(A3i> Q<0ČnhZXkkXA76wsXGkQO2TE,{1ѥK/Ocr`uԒmᩋ-} ]x1xMPv_,UWjnB91fME/{PjGHٿ{ $5*ݺGXk4j߰`j Uw}qiF&fjs0I',PnNIۿb6@au |!f0EdEH ҅vK`ckגyQзA=oju7P+!*%|7GCzSc=_ϲߐL.)w[KVK!yKdOC@ۊ%m ); bI^ YðTT6&S3\J+! v$!qNT ^ {) JT 60.tKT@RT$I$I@RfMLTRoߺبW'a]u%¡FC/°g85fr%V|'UߔHOZeig^߰g#lF%-Հغ]䖘&y#;amպj覴c&XIf$?5f%=Dտ?:<o-DϦBG6IDC6|Cs u6=Js2"F 618(L"ϼg s};w|w܉;T:9ݣT]_Z% KhI l.b$e(677cr(c12ӧ*)==&tla#{Uooo=z>ՒI([9D1=۰`|Tpb}ȑcǎ?~8~cǎ9Rg(\ߒ%>2x^ fowx1!.Np?0%bWIOVϣEԢ~ZԄa(dY+ɫZ/+\@D>0cQ cT$I$I@RT@RT$T$I@RI@RT@RT$`,qAB>'8`<˿` d H*j|e& cZ&E^V1'ժ 5i^%w\* E(5>3C6qMqU *bmFZ kaT[r}&;j&TRqoQ7^< SXΧK HTЩUQI_E n~8{fgR5ޫqMҦd.?m'?H`LRP'H*gS,(U?DRY@?@2T$IJ#xkfou%z@ɕd$5˜w#鸑$ ]É$5LOWeFnjRۭ݆ H9hրzk_ ZZՍ+^ |s~̾QyR`>S ͳ_  d%U{j KrvXQf5RpmAxZ]8?ۚ'િ=Cq~sK5!g]RKq_ 0jH@Z*$@R! `DE'G, Hjg[ ɚ'r_Fq a9,ORfB0(Ur_#oCxq\c1n8?Uq~AG9q$Kb|X-t)13NK%q~z?rhcޯ3hdHƹY`l Im8a-tUј{@;U.9`$U!Ҭ><̶sFcڭ d3ș3:t>e; 8ο>׭ci rh/︞+Xr{Kq$"QjLm=3+8?OHTފ 69'Bf~zf.ZNGތdͨ uVU7c^m6EV{&"e5z:E"d刿~YlCЙ \>QާLi>a0+첱0TQ%uZ{M96/NK }[:JzWϭNa_H&3E":\27v7N`,4>Hބ:հSHDE#>"vyɍ,2 +kէao07jl=wQ)upRjRj~7>^  #eiSsZ3+A|yVO9m+qJ Awy#3ic'8;$J%XlLLB0/MaRFx\2%9%UyGt!/qW>jA8ky#Se^Y}Lq1Hf `@%H?!J@RT$T$I@RI@RT@RT$I$I@RI@RT$TXJVG[JXNGу?{/wcr֒^W2(ʼnMd|t IM =?8gYu T k°ajIe[<6ak힞5 ].su*]zEzڅc{bLy/us׵`J@NJb,l畠Iu~0y jqv3]aW8$%cI-z|o'=s?$yE")7UV6ܸK'[3R̴9\m~`z聴#m2d Vl=wQ)upRjRj[Ɏ}2|vϟ6œTd\}sI8=O|ՍKlKTH*   #o:}{>1{yg Ӷ~o3v: TOrrq))]+G~xbuyVnl$Wʖ'ߏێ ]hۖygMyEOC0 p^¿`P.X*Az= iD]fD_9ڦ OaybɧN=IX{3d> 'y&_!V+{I&9 ꙍӻǵ%9 s6SLM5}o]֨ӏ:"E^g |ɘSi;̵R~iسU71ccJ$9ZVՈPZv4ӥwtCԲ10֜W Qޥ+9o/*uG̊UK^wUm3h5>X# 3zYU&Ǎ]]XCPX0 Rڄ`׭U7q }Ta[3<]uܿ'*P 1ϪS@R'P t@A(,bhMg̭d$U* ѿFJ_\7JXqcLkUKT Ae왝JהRR{^R1vϟ6œ')$S 1T H* H*  's> T+/XGT¹ww]ommaH* I$F+ o㸒{3ˆž~?MT@RaʒqH* 0Å"5r# skyo}hQrč=4=;J=>p2 %OR|v6ΞI?T}j]]IENDB`jsamp/src/site/resources/images/bridge2post.png0000664000175000017500000001232312730747754021455 0ustar sladensladenPNG  IHDRhbx pHYsM&ᅳtIME _srIDATx{xUƿTs%@$ 8^ ( A#>dcEDPdYАa\QDvg2 *U$E.BBBBBr/?mkɓԩs{N>M$8A&EwI/#$T$l1V絒~Xa剭nRd,dɫG̔ՍQFW#OEmnDXXjp'{>ܓae›9Q%]A<:1FwfnMd~AL\ԟO{%E3>Ó[3"|ibbY: c9^.mG#bU7˭5" M̊A n'0 bzF3.`) ]7Dټ ^'R FjjS1Bл (wt(YdgSSSQ.SZ Vq{I4̶[kN5N t:W:qŐ.V'or%e/qcK b<]xK^\U`y#$;@ ٬q VP.\\8H ~M 񻞎M14n!cMXTҟGX:1 }պ\_>QUa{Ot]OjIZ\+֓? `0H. ?s{uJn(osiǧMϥν|},}$;;c\K[o'kM;$T.]=dt*+3 n1\j"}g;Yر6Koq-][9d<^pI(BCeu:$׼ zW(c={hX-mv ©+hH3t{qwY.&(f\;&Yg/:ֽS@%сDTţwd.>+]:_wLL[y98{B1YG=YuBTXU]1pgv}4}S+)u+aXx03ѰP͎E[ڤ>cc͸{:vCOndͷ*0 v-mY{ O.]"qecH+^P/8',T Qִщ&yP_6aw(w㯄'i{U?wJYM= UrW̼u2>]\w>{ [g ϙ}K-'jbқ0vY)OƘ'Y>*&f3YçjO2>>,{.R9/c I_+;ޅ͆.ԖX- zuq!P&T 3/ =?c f<RAhnmlW+W^],@1ҟ2iBdLLk{M7Z;;i<>|DmN=&`s ~wOkn:ƚ\0K|dEQEQA%绬Y!!qXD\o.~fv]/_Pr|nja+VCrE7ZʯlrQRT.,5~}'X,_:$)+A&$wtt;з>blll1Ueg wY`XB0ɹ7׼T{… ޵^TKL{$K^ I5??I>}&2X?^tSǾABjZQ4 y~UEަ٠?TTDX3\E*^0vv~wKx)']bq5qVS#=^wZEAMKs]!Cg.*j?^+/,n%?(•_^_Z؋6^fގΜ s_iYwFnm(=#%Ǐ~wIb[menaɛA,Yw'^X j#z;DGGGGGcwAwy&EeEXЁ="\ȵ䣧ɻK.}#eG.#wi_~"ܔ.#'wCYCYCxǷXF,!p7bĸp=pӷigƚN%]VH @{7/֭װy)?wx bVny>|PJVX0B `$wMšS; b?lT qD]Ix'_.zC`z$HVkkeeS]djki 7ujDTwQ(U?o-;Gȸ%Eߑ]ޱFoiԀHiZxR͓jjG+g+j/J5YJox`~z&[7RO6Zc$tAd.m4?ƼwÚ9)Rs3pB}[׻u+@\qU.ʨɱĨz+[jHZTCvX$YkQVxOboC^wxB( :ZCtCj4 QN(J+/Z[35b @G&mx[GTcg+w M{k3) (ZeUN۔S].әLF0Q\BueK>L S*00ctk`\aRքQU}BBș޺/cHw]F?11*zz `""H횧s5 'Cz\qY.o}EƨmK+1yg&Y32]^1J %wm}]V쥢D|Ի 8`\qt%?&F57/nl}%o{y%Y+FIBńisg/H.V\uvDr¾_B `G4/x5,w.2m#(n #&*:C>Ky+4K<.>/˼ ~;Ẍw9 >gyvѻju/7*eTOWȁ4a#&N՗YŻ.'w}BYz-G~?*KIfyaLb1z=;uPfԇe4 Nw;we=s38vemAk-oJ{.čԊ7nzvĞmd;cHwRvW.H.HЎ\.z A ].z A ].z A ].z A ].z A ].z A ].z A ].z A ].z A ].z A ].z A ].z  rB:'_zVctADȂֻq]ʥK- K+! ]P.w ":\傸lD7 ߬H.z4M+яKr| Z]===(āVN8R X[Vz=5lݺ;Foai%ehK_^KЌ[rZ|\m]^+s@@+͟ n^ 999Ν+--lˬ#zm&;`LLU8sQm_n999yyy$xbz@>Ŷ[F='''11133sŠ!wٻt:f}--g̹'J=sːu2b܋7yWƎ7.] bرl;˻4w;+m@0ly㼽V?SuvSaBjjj~~~nnnFF233iC[!os{cd?.W&6%33Hry[ɂ[s1fܸqz~; "*7Vۤz G$^tinnIY]7K, SL|||JJJk3rx1 f ။ (ᅬvxE *B/j=(37ړr3w%ʘ!x/cB,̜9ށl^m,]f``/kN -ZEŮ=0+ q' |^sCEjp+I۳>'0V#&G;KW|pCPʨ?J %(qǜϤ倍QP@맥-\Е|H JJJpGah,@&IENDB`jsamp/src/site/site.xml0000664000175000017500000000245212730747754014734 0ustar sladensladen jsamp/src/site/xdoc/0000775000175000017500000000000012730747754014200 5ustar sladensladenjsamp/src/site/xdoc/downloads.xml.vm0000664000175000017500000000344212730747754017340 0ustar sladensladen Downloads Mark Taylor jsamp/src/site/xdoc/commands.xml0000664000175000017500000006311612730747754016532 0ustar sladensladen Command-line Tools Mark Taylor

JSAMP provides a number of command-line applications for standalone use in various contexts. These come with their own main() methods so can be invoked directly. A convenience class org.astrogrid.samp.JSamp is also provided (with its own main() method) which might save you a bit of typing when running these. So for instance to run the hub, which is in class org.astrogrid.samp.hub.Hub, you can execute either:

   java -classpath jsamp.jar org.astrogrid.samp.hub.Hub
or, more simply:
   java -jar jsamp.jar hub
As a special case for convenience, simply running "java -jar jsamp.jar", or clicking on the jar file in some OSes/graphical environments, will start the hub, along with a short usage message to standard error.

In all cases, supplying the "-h" or "-help" flag on the command line will print a usage message.

The JSamp usage message (java -jar jsamp.jar -help) says:

The individual command-line applications are described below. They have their own specific command-line flags to control use, but most share the following common flags:

-help
Outputs the usage message. May be abbreviated to -h.
-/+verbose
Increases/decreases verbosity. This controls what level of logging is performed by the application. By default, WARNING (and higher) messages are output, while INFO (and lower) messages are suppressed. -verbose increases the verbosity by one level and +verbose decreases it by one level. These flags may be supplied more than once. May be abbreviated to -v or +v.

The various system properties listed affect communications for these applications in the same way as for JSAMP applications in general; see the section on System Properties for more detail.

The GUI window, which is used by several of these tools to display the clients currently registered with the hub along with their metadata and subscriptions, looks something like the screenshot below - though see the GUI Features section for more detail.

HubMonitor screenshot

The org.astrogrid.samp.hub.Hub class runs a SAMP hub. A graphical window showing currently registered clients and their attributes (metadata and subscribed MTypes) may optionally be displayed. By default the hub operates using the SAMP Standard Profile, but there are options to use different profiles, including user-defined ones, as well or instead. Some of the flags relate to particular profiles.

Hub usage is:

-mode no-gui|client-gui|msg-gui|facade
Determines what hub implementation is used; currently this affects whether and how the hub status is displayed graphically. The following options are available:
no-gui
There is no graphical display.
client-gui
A window is displayed showing which clients are registered and their metadata and subscriptions.
msg-gui
A window is displayed showing clients with metadata and subscriptions; it also gives a graphical representation of what messages are being sent and received between clients. In the case of heavy messaging traffic, the extra processing required for this display can slow down hub operations a bit.
facade
This option piggy-backs the hub on an existing hub. The hub passes any registration requests to an underlying hub. The underlying hub is located using the usual arrangements for client hub discovery (influenced by the SAMP_HUB environment variable). This can be used to provide additional access to an existing hub using a profile which the existing hub does not implement. There is no graphical representation in this case.
In the case of the GUI options, they attempt to insert an icon into the "System Tray" area of the desktop, if one exists, and if a java version >=1.6 is being used. A menu available from this icon can be used to display or hide the window. If the system tray is not accessible (java version <1.6 or not supported on desktop), then the window will be displayed directly, and closing the window will shut down the hub.
-profiles std|web|<hubprofile-class>[,...]
-extraprofiles std|web|<hubprofile-class>[,...]
These two flags identify which profiles the hub will run. The -profiles flag determines which profiles will be running initially, and the -extraprofiles flag specifies additional profiles which may be available to start manually, for instance from the Profiles menu of the hub window. In each case a comma-separated list of profile identifiers is used. If no -profiles flag is specified, then the default set of profiles is used; at present this equivalent to -profiles std,web and no -extraprofiles. The flags may be given more than once, in which case all the named profiles will run. The following options are available:
std
Standard Profile. The -std: flags below relate only to this profile.
web
Web Profile. The -web: flags below relate only to this profile.
<hubprofile-class>
If the name of a class which implements the HubProfile interface and which has a no-arg constructor is given, that profile is run.
These flags have a similar usage to the jsamp.hub.profiles and jsamp.hub.profiles.extra system properties, but overrides them, and must be used to take advantage of the corresponding profile-specific flags.

The following flags are used only if the Standard Profile (-profiles std) is in operation:

-std:secret <secret>
Optional flag to supply the samp.secret string which will be written to the Standard Profile lockfile and which clients must present on registration. If not supplied, a random string will be chosen.
-std:httplock
If this flag is supplied, the Standard Profile lockfile is not written to a file, but is made available via HTTP instead. The hub will print the location, in a form suitable for use with the SAMP_HUB environment variable, when it starts up.

The following flags are used only if the Web Profile (-profiles web) is in operation:

-web:log none|http|xml|rpc
Provides different levels of logging of the Web Profile communications. This logging is to standard error, and not performed through the Java logging system. The following options are available:
none
No logging.
http
All HTTP communications, including HTTP headers and in most cases HTTP request/response bodies, to the web server hosting the XML-RPC server, are logged. This can be very useful for debugging communication issues, since both HTTP headers and non-XML-RPC HTTP requests are essential parts of the various sandbox-busting technologies required by the Web Profile.
xml
The XML text of all XML-RPC communications is logged.
rpc
An interpreted summary of the content of the remote procedure calls is logged.
-web:auth swing|true|false|extreme
Configures how the Web Profile hub determines whether clients are permitted to connect. The options are:
swing
A popup dialogue is shown to the user for every registration request. Registration is only granted if the user explicitly authorizes it. This option is the default and should usually be used.
true
All requests to register are granted without reference to the user. This can be convenient during testing, but should be used with care, since it can facilitate access for potentially hostile browser-based clients.
false
All requests to register are refused without reference to the user. This rather silly option means that no clients can register via the Web Profile.
extreme
Solicits confirmation via a popup dialogue from the user as for swing, but takes extra measures to try to discourage the user from accepting requests.
-web:[no]cors
Configures whether the Web Profile HTTP server will allow access from browser-based clients using W3C Cross-Origin Resource Sharing. By default this is currently turned on.
-web:[no]flash
Configures whether the Web Profile HTTP server will allow access from browser-based clients using Flash-style crossdomain.xml files. By default this is currently turned on.
-web:[no]silverlight
Configures whether the Web Profile HTTP server will allow access from browser-based clients using Silverlight-style clientaccesspolicy.xml files. By default this is currently turned off.
-web:[no]urlcontrol
Configures whether restrictions are applied to the URLs that the Web Profile translation service will translate on behalf of clients. If set on, local URLs will only be translated if they have been mentioned in earlier messages or responses to a web profile client. By default this is currently turned on.
-web:[no]restrictmtypes
Configures whether restrictions are applied to the MTypes that Web Profile clients are permitted to send. If set on, only common MTypes with known semantics are permitted (this includes samp.app.*, table.*, image.* etc); attempts to send messages with unknown MTypes (with possibly harmful semantics) are blocked by the hub. By default this is currently turned on.

For convenience, the hub can also be started simply by invoking the jar file with no arguments (for instance clicking on it).

To run a hub with default settings (standard profile only) do this:

    java -jar jsamp.jar hub
and to run it with both Standard and Web Profiles, do this:
    java -jar jsamp.jar hub -profiles std,web

The org.astrogrid.samp.gui.HubMonitor class runs a SAMP client which connects to any available hub and displays a window showing currently registered clients along with their attributes (metadata and subscribed MTypes). If no hub is available at startup, or the hub shuts down while the monitor is running, the monitor will poll for a hub at regular intervals and reconnect if a new one starts up.

A button at the bottom of the window allows you to disconnect from a running hub or to reconnect. While disconnected, no automatic connection attempts are made.

The HubMonitor class itself is a very simple application which uses the facilities provided by the other classes in the toolkit. See the source code for details.

HubMonitor usage is:

-/+verbose
See above for the description of verbosity setting. If -verbose is used, each message sent and received will be logged to standard error through the logging system.
-auto <secs>
Sets the number of seconds between reconnection attempts when the monitor is inactive but unregistered. If <=0, autoconnection is not attempted.
-nogui
The monitor registers as a client, but no window is displayed.
-nomsg
Normally the window displays an indication of pending messages sent and received by the monitor itself. If the -nomsg flag is given, these will not be shown.
-mtype <pattern>
Gives an MType or wildcarded MType pattern to subscribe to. This flag may be repeated to subscribe to several different MType patterns. Like the Snooper command, it does not actually understand MTypes subscribed to in this way, so it sends a response with samp.status=samp.warning. If omitted, only the administrative MTypes (required for the monitor to keep track of clients) are subscribed to.

The org.astrogrid.samp.test.Snooper class runs a SAMP client which subscribes to some or all MTypes and logs each message it receives to the terminal. This can be useful for debugging, especially for testing whether your application is sending messages which look right. Since it does not actually understand the messages which have been sent, it sends a Response with samp.status=samp.warning.

Note that the HubMonitor command can also be used in this way; Snooper is useful if you would rather have information presented on standard output than in a GUI.

Snooper usage is:

-mtype <pattern>
Gives an MType or wildcarded MType pattern to subscribe to. Subscription to the given MType is with an empty annotation map, so this is identical to "-subs <pattern> {}". This flag may be repeated to subscribe to several different MType patterns. If both this and the -subs flags are omitted, a value of "*", i.e. subscription to all MTypes, will be assumed.
-subs <pattern> <subsinfo>
Allows subscription with a given MType or wildcarded MType pattern and a non-empty annotation map. The <subsinfo> parameter should be a JSON representation of a map representing the annotation of the MType(s) given by <pattern>.
-clientname <appname>
Specifies the samp.name metadata item which the sending aplication should give for its application name following registration.
-clientmeta <metaname> <metavalue>
Specifies additional items of metadata for the sending application to give following registration. The metavalue items may be in SAMP-friendly JSON format, or just plain strings.

The org.astrogrid.samp.test.MessageSender class can send a simple SAMP message from the command line and display any responses received in response.

MessageSender usage is:

-mtype <mtype>
Gives the MType for the message to send.
-param <name> <value>
Gives a named parameter for the message. This flag may be repeated to pass more than one parameter. <value> will be interpreted as a JSON structure if possible (note double-quoted strings are the only permitted scalars), otherwise the value will be interpreted as a plain string.
-targetid <receiverId>
Specifies the SAMP public ID for a client to which this message will be sent. This flag may be repeated, or combined with -targetname, to send the same messsage to more than one recipient. If neither this nor -targetname is supplied, the message is broadcast to all clients which are subscribed to the MType.
-targetname <receiverName>
Specifies an application name (samp.name metadata item) identifying a client to which this message will be sent. Names are matched case-insensitively. If multiple clients with the same name are registered, only one will be messaged. This flag may be repeated, or combined with -targetid, to send the same messsage to more than one recipient. If neither this nor -targetid is supplied, the message is broadcast to all clients which are subscribed to the MType.
-mode sync|async|notify
Specifies the delivery pattern to be used to send the message. In the case of notify, no responses will be received. The sender only declares itself callable if async mode is used. The default is sync.
-sendername <appname>
Specifies the samp.name metadata item which the sending aplication should give for its application name following registration.
-sendermeta <metaname> <metavalue>
Specifies additional items of metadata for the sending application to give following registration. The metavalues may be in JSON form or plain strings.

The org.astrogrid.samp.test.HubTester class runs a series of tests on an existing SAMP hub. Most aspects of hub operation are tested, along with some moderate load testing. In case of a test failure, an exception will be thrown, and under normal circumstances the stackdump will be shown on standard error. These exceptions may not be particularly informative; hub authors debugging hubs will have to examine the HubTester source code to see what was was being attempted when the failure occurred.

Normally, if a hub passes all the tests there will be no output to standard output or standard error. Under some circumstances however a WARNING log message will be output. This corresponds to behaviour that a hub implementation SHOULD, but not MUST, display according to the SAMP standard.

It's OK for other clients to be registered while the test runs, but such clients should not either register or unregister while the test is under way - this will confuse the test and probably result in spurious test failures.

HubTester usage is:

-gui
If supplied, a HubMonitor window will be shown for the duration of the test.

The org.astrogrid.samp.test.CalcStorm class runs a number of clients simultaneously, which all connect to the hub and then send each other messages. A private set of MTypes which provide simple integer arithmetic are used. Checks are made that all the expected responses are obtained and have the correct content. On termination, a short message indicating the number of messages sent and how long they took is output. This application can therefore be used as a load test and/or benchmark for a given hub implementation.

CalcStorm usage is:

-nclient <n>
Gives the number of clients which will run at once.
-nquery <n>
Gives the number of queries which each client will send to other clients during the run.
-mode sync|async|notify|random
Specifies the delivery pattern by which messages are sent. The default is random, which means that a mixture of modes (approximately a third each of synchronous call/response, asynchronous call/response and notification) will be used.
-gui
If supplied, a HubMonitor window will be shown for the duration of the test.

The org.astrogrid.samp.bridge.Bridge class provides a connection between two or more different hubs. If run between two hubs, A and B, every client on A also appears as a 'proxy' client on B, and vice versa. A bridge client also runs on both hubs A and B to keep track of which clients are currently registered, so that it can generate new proxies as required. The effect of this is that clients registered with one hub can send and receive messages to and from clients registered on a different hub, just as if they were local. This can be used to facilitate collaborative working, though you may be able to think of other uses.

To run it, you must specify which hubs to bridge between. In most cases you'll want one to be the default (standard profile) hub, so this is assumed by default. You therefore only need to specify how to connect to the non-default hub(s). You can do this by using one or more of the command-line options described below.

Bridge usage is:

-exporturls/-noexporturls
With -exporturls, an attempt is made to translate URLs in message bodies and responses from localhost-specific forms to remotely accessible ones, for instance renaming loopback addresses like "127.0.0.1" as the fully qualified domain name. With -noexporturls this is not done. The default is to export URLs if the bridge is apparently running between hubs on different hosts.
-standard/-nostandard
-standard indicates that one of the hubs to bridge is the Standard Profile hub. This is default behaviour; -nostandard means do not include the standard profile hub.
-sampfile <lockfile>
Names a file in standard-profile format which describes the location of a hub. This flag may be given more than once to specify multiple non-standard hubs.
-keys <xmlrpc-url> <secret>
Gives the XML-RPC endpoint and "samp-secret" string required for communicating with a running hub. These correspond to the samp.hub.xmlrpc.url and samp.secret values from the standard profile lockfile. This flag may be given more than once to specify multiple non-standard hubs.
-sampdir <lockfile-dir>
Names a directory containing a file ".samp" in standard-profile format which describes the location of a hub. This flag may be given more than once to specify multiple non-standard hubs.
-profile <clientprofile-class>
Gives the fully-qualified classname of a java class which implements the ClientProfile interface and which has a no-arg constructor. A newly-constructed instance of this class will be used to contact a hub. This flag may be given more than once to specify multiple non-standard hubs.

The bridge is a bit experimental, and there are a few subtleties concerning its use. Some more discussion can be found on the bridge page.

jsamp/src/site/xdoc/debug.xml0000664000175000017500000000545012730747754016014 0ustar sladensladen Debugging with JSAMP Mark Taylor

JSAMP provides many facilities which are useful for the application author when adding SAMP capabilities to new or existing applications, or when testing SAMP communications between tools and trying to see what is or isn't working. In many cases this applies whether or not you are using JSAMP for your own SAMP implementation.

Here is a short summary of some of the most useful of these facilities. It is not exhaustive.

JSAMP's GUI hub view represents pretty much the entire state of the hub in graphical terms, including all the messages which are passing through and their responses. If you start the JSAMP hub

   java -jar jsamp.jar hub -mode msg-gui

then you can see all the messages, including their text and any errors and so on in the responses. See the GUI section for examples.

You can get a similar view from a client's-eye view using the HubMonitor in GUI/message tracker mode to view a third party hub, though it doesn't have access to as much information as the Hub GUI itself (it can only see the messages it participates in itself).

You can see all the details of of the XML-RPC exchanges between SAMP clients if you set the jsamp.xmlrpc.impl system property appropriately on one of the clients or the hub or both (but note the output can get quite verbose). This is good for diagnosing bad XML-RPC sends/receives, and also for getting concrete examples of what the POSTed XML text looks like if you are constructing your own messages by hand.

When using the Web Profile, running the hub with one of the -web:log options can be very helpful, since it shows all of the traffic to and from the Hub Web Profile HTTP server.

If your application is waiting for messages with a given MType, but you don't have another one to hand which can generate such messages, you can use the MessageSender tool to generate messages to order and send them from the command line.

Conversely, if your client is sending messages but you don't have a recipient for them, you can run Snooper subscribed to the MType(s) in question to receive the messages and see what they look like.

jsamp/src/site/xdoc/bridge.xml0000664000175000017500000002155712730747754016170 0ustar sladensladen Bridge Mark Taylor

JSAMP provides a special client called the Bridge. Its purpose is to link up two or more hubs, so that clients registered with one of the linked hubs can communicate with clients registered with any of the other hubs as if they were registered on the same one. Usage instructions are given in the commands reference.

The bridge works by registering a proxy client on all the remote hubs for each client on a local hub. These proxy clients can be communicated with just like non-proxy ones, but the client metadata is adjusted so that a user can see that they are proxies; the icons are mangled, the names have "(proxy)" appended, and a metadata entry with the key "bridge.proxy.source" is added to indicate which hub the client is proxied from.

You can see some slides from October 2008 explaining how it works here: Bridge presentation.

In order to set it up, you need to specify which hubs will be connected. There are various ways you can do this, as explained in the usage documentation. In most cases, you will want to bridge the local 'standard' hub with a remote one. If you can see a .samp file (for instance from another user's home directory) you can specify it by name like this:

   jsamp bridge -sampfile /home/foo/.samp

or by directory like this (the name ".samp" is assumed):

   jsamp bridge -sampdir /home/foo

or by URL like this:

   jsamp bridge -sampurl file://localhost/home/foo/.samp

If you can't see the SAMP lockfile (for instance if it is on a remote machine), you can specify the XML-RPC endpoint and samp.secret string directly. You might exchange this information with a collaborator by email or instant messaging if you want to set up a collaboration. In this case, one (not both) parties would execute a command like:

   jsamp bridge -keys http://foo.star.com:46557/xmlrpc 3c3135435de76ef5

The keys here are obtained by looking in the remote user's .samp lockfile (the samp.hub.xmlrpc.url and samp.secret entries respectively). Note that if the URL uses a loopback hostname like "localhost" or "127.0.0.1" you may need to replace it with the fully qualified domain name.

By default the local standard-profile hub is used in addition to any others specified on the command line, but you can inhibit this with the "-nostandard" flag; for instance the following sets up a three-way bridge between the standard profile hubs running in the home directories of users foo, bar and baz:

   jsamp bridge -nostandard
                -sampdir /home/foo -sampdir /home/bar -sampdir /home/baz

without the -nostandard flag it would have been a four-way bridge between the hubs of foo, bar, baz, and the user running the command.

By use of the -profile flag, non-standard profiles can be used as well. The bridge can thus enable two communities of SAMP clients using different profiles to communicate together, with no other changes to the client source code or configurations.

The bridge can be run programmatically using the org.astrogrid.samp.bridge.Bridge class as well - see the javadocs.

If you want to experiment with the bridge without having multiple accounts or multiple hosts you can do so by using the SAMP_HUB environment variable to set up different hubs and communities of clients for connection with bridges.

   // Start up a hub and a client using the standard profile.
   java -jar jsamp.jar hub
   java -jar jsamp.jar hubmonitor

   // Start a hub and client using a non-standard lockfile location.
   setenv SAMP_HUB std-lockurl:file://localhost/tmp/samp1
   java -jar jsamp.jar hub &
   java -jar topcat.jar &
   unsetenv SAMP_HUB

   // Bridge them together.
   java -jar jsamp.jar bridge -sampfile /tmp/samp1

The syntax here is C-shell-like - something similar can be done in other environments.

In principle, once set up the bridge should work with no further intervention. However, in the case of a bridge between different machines, there may be some communications issues related to URLs. Although URLs are formally intended for locating a resource regardless of where you are, in practice there may be problems accessing a URL from a machine other than the one it was constructed on. These problems fall into two categories.

  1. SAMP clients and the hub usually use HTTP over high-numbered ports for communications. If either machine is behind a firewall which blocks such ports, the bridge won't work. There's not much you can do about this, other than reconfiguring or disabling the firewall.
  2. Many of SAMP's MTypes work by exchanging URLs between clients, for instance table.load.votable messages pass the location of the table to load as a URL. Although in general a URL is intended to specify the absolute location of a resource, in some cases a URL can only be resolved on the machine on which it was generated. This is true in two main cases: firstly if it is a "file://..."-type URL, which is specific to the current host's filesystem, and secondly if it names the host in some way other than with its fully-qualified domain name, for instance using a loopback name such as localhost or 127.0.0.1. Exchanging such URLs between bridged hubs on different hosts will not work. The bridge can mitigate these problems to some extent, by attempting to export problematic URLs. In this mode, strings in message bodies and responses which appear to be URLs are examined, and if they appear to be host-specific for a remote host, they are rephrased to make them remotely readable. This involves replacing loopback hostnames with fully-qualified ones, and where possible exporting file-protocol URLs with HTTP ones. File URL exporting can only work from the host that the bridge is running on though, not in the other direction. For this reason it may be a good idea to have the bridge running on the machine providing most of the data rather than the one receiving most of the data, if there's a choice. This URL exporting is done by default if there appear to be multiple hosts involved in the bridge, but may be controlled by supplying the -[no]exporturls flag on the bridge command line. This kind of message manipulation necessarily involves a bit of guesswork, so it is possible that it may cause problems.

The bridge is somewhat experimental; what (if anything) it's useful for and whether it needs adjustment or new features are still under consideration. Please contact the author or discuss it on the apps-samp list if you've got any comments or queries.

Here is an illustration of how it looks when two hubs have been connected together using a bridge.

Prior to the bridge we have two separate hubs, as shown in the images below. The left hand one shows a JSAMP hub (displayed using the JSAMP graphical hub view) with TOPCAT registered. The right hand one shows the state of a SAMPy hub (displayed using the JSAMP hubmonitor), running on a different machine, with registered clients HubMonitor and SPLAT.

Following a suitable bridge command, they look like this:

As well as the original, directly registered clients, each hub now has the bridge client itself, and a proxy copy of each (non-hub) client registered on the other hub. The proxy ones are easy to identify because their names (samp.name metadata) have "(proxy)" appended, and their icons (samp.icon.url metadata) have been adjusted (currently the bottom right corner is cut off, though some other visual idiom may be used in future releases). In other respects these proxy clients appear to other clients on their remote hubs just the same as any other client, and can be treated accordingly.

jsamp/src/site/xdoc/deployment.xml0000664000175000017500000001461512730747754017111 0ustar sladensladen Deployment Mark Taylor

JSAMP is a pure Java library. It should run on any Java Runtime Environment of version J2SE1.4 or above. It does not require any external libraries, though see below.

In order to perform the necessary XML-RPC communications for use with the SAMP Standard Profile, a pluggable layer is used. This means that you can choose which XML-RPC library is used. Currently, there are the following possibilities:

Internal
A standalone library implementation is provided within JSAMP, so if you wish to use this, no external dependencies are required.
Apache XML-RPC 1.2
To use the Apache XML-RPC library, the Apache classes must be on the class path. They can be found in the jar file xmlrpc-1.2-b1.jar.
Roll your own
By implementing the class org.astrogrid.samp.xmlrpc.XmlRpcKit you can provide your own implementation, perhaps based on a third party XML-RPC library.

In some cases you can choose which of these is used by supplying particular implementations of the classes in the xmlrpc package. Mostly though, an implementation will be chosen without any explicit choice in the application code. In this case, you can control which implementation is used by setting the value of the jsamp.xmlrpc.impl system property.

The internal implementation was introduced as an experimental measure, but it seems to be quite reliable and of comparable speed to the Apache one. The internal implementation is therefore recommended for most purposes.

If you are embedding JSAMP into your own application code but want to keep the size of the additional class files to a minimum, you can avoid incorporating all of the classes in the built jar file. The subordinate packages are arranged in a modular way. They are organised as follows:

org.astrogrid.samp
Basic classes required throughout the library. You need these whatever you're doing.
org.astrogrid.samp.client
Classes intended for use by application authors writing SAMP clients.
org.astrogrid.samp.hub
Classes providing the hub implementation. If you are only writing a client which will not contain an embedded hub, you do not need these classes.
org.astrogrid.samp.gui
Utility classes providing GUI components for visual display of SAMP activity. These classes are not required for basic client or hub operations.
org.astrogrid.samp.test
Classes providing the hub test suite and some diagnostic tools intended for debugging SAMP systems. These are not intended for use in third party applications. This does not however contain unit tests for JSAMP itself, which are not present in the distributed jar file.
org.astrogrid.samp.bridge
Classes for the Bridge application, which can connect two or more hubs together.
org.astrogrid.samp.httpd
Simple HTTP server implementation. This is required by the internal XML-RPC implementation. It may also provide useful utility functionality for client applications who wish to serve resources over HTTP.
org.astrogrid.samp.xmlrpc
Classes which deal with XML-RPC based communications and the implementation of the Standard Profile. Everything in the packages above is independent of the Profile used, so if for some reason you are using a non-standard profile, you may not need the xmlrpc* packages. The classes here define a pluggable interface to XML-RPC implementations; one or other of the provided implementations, or a custom-written one, will be required as well to make use of these classes.
org.astrogrid.samp.xmlrpc.apache
Implementation of the pluggable XML-RPC layer based on the Apache XML-RPC library. To use these classes you will need the Apache XML-RPC library on the classpath as well. If you are using the internal XML-RPC implementation, these classes are not required.
org.astrogrid.samp.xmlrpc.internal
Implementation of the pluggable XML-RPC layer which requires no external libraries. If you are using the Apache library or a home-grown XML-RPC implementation, these classes are not required.
org.astrogrid.samp.web
Implementation of the Web Profile hub and clients. The Web Profile is XML-RPC based, and requires some of the classes in the org.astrogrid.samp.xmlrpc* packages as well.
jsamp/src/site/xdoc/profiles.xml0000664000175000017500000002336312730747754016554 0ustar sladensladen Profiles Mark Taylor

The SAMP protocol is defined in two parts, as an abstract API and as transport-specific Profiles. One or more Profiles may be offered by a SAMP hub to allow clients to communicate with it. At present, JSAMP offers two basic profiles, the Standard Profile, intended for normal desktop-based clients, and the Web Profile, intended for browser-based clients (some variants of these are also possible). These are described below.

By default, the hub is configured with both Standard and Web profiles running. You can turn them on and off while the hub is running using the Profiles menu. You can configure which profiles are run on startup using the -profiles and -extraprofiles hub flags or the jsamp.hub.profiles and jsamp.hub.profiles.extra system properties, or if you're writing code with one of the methods in the Hub class.

The Profiles menu in the hub window looks something like this:

Screenshot of Profiles menu in the hub GUI window

and from the system tray icon something like this:

Screenshot of Profiles menu in the system tray window

Checking one of the checkboxes has the effect of turning the profile in question on, and unchecking it turns it off. When a profile is turned off, any clients registered using that profile are forcibly ejected from the hub.

The Standard Profile is intended for use by normal desktop tools. Clients discover the location of the hub by looking in a file named .samp in the user's home directory. The fact that this file is normally only readable by the user running the hub means that connections cannot be made by other users.

The Web Profile is intended for use by web applications, that is, programs or web pages running inside a web browser. Web applications can find the hub at a well-known port. When a web application wants to register, the hub will ask the user, by popping up a dialogue window, whether the application should be allowed to run. The dialogue window will look something like this:

Web Profile popup dialogue screenshot

There are a number of configuration options available for the Web Profile hub, connected with security. They may be set on the hub command line, with the various -web:* options, or using the Profiles|Web Profile Configuration menu. The options are as follows:

CORS cross-domain access:
Whether to allow access using the Cross-Origin Resource Sharing standard. this is believed to be the safest mode of browser/hub communication, so it should usually be switched on.
Flash cross-domain access:
Whether to allow access using the Adobe Flash-based crossdomain.xml mechanism. This may be less secure than CORS, but is required for some browser/web application combinations. If you use a browser that supports CORS (thought to be: Chrome v2.0+, Firefox v3.5+, Safari v4.0+, IE v8+), and only use JavaScript-based web SAMP applications, you may be able to leave this switched off and thereby improve security.
Silverlight cross-domain access
Whether to allow access using the Microsoft Silverlight-based clientaccesspolicy.xml mechanism. Silverlight is believed to support the Flash mechanism, so you can and should probably leave this switched off.
URL Controls:
Whether web clients are restricted from accessing sensitive resources, like local files, if they have not previously been mentioned in earlier SAMP messages. This option is experimental, but it is probably a good idea to leave it on for security reasons.
MType Restrictions:
Whether the messages web clients are permitted to send are restricted. If this option is selected, only MTypes matching a well-known list of harmless ones are permitted - this includes all the common MTypes to exchange tables, images, spectra etc. This option is experimental, and you may need to switch it off if a web client has to send messages with unusual MTypes, but otherwise if is a good idea to leave it on for security reasons.

Note that the configuration options may only be changed when the Web Profile itself is not running.

You may be able to find some web profile clients in the sampjs project.

The JSAMP 1.3 Recommendation discusses security in relation to the Web Profile, but notes that there remain some security concerns, and that experimentation will continue in hub implementations around this issue.

The security measures taken by the JSAMP Web Profile implementation relating to the Web Profile are:

  • The HTTP server on which the Web Profile runs will normally reject any access attempts from hosts other than the local host, as recommended by the SAMP 1.3 document. However, access by additional trusted hosts may be enabled if they are explicitly named using the jsamp.web.extrahosts system property.
  • The Web Profile URL translation service (Section 5.2.6 of SAMP 1.3) is, by default, selective about what URLs it will proxy. URL translation is only performed for a URL which has previously been mentioned (for instance as the value of a Message or Response argument or a declared Metadata map) in a SAMP communication from a trusted (non-Web Profile) client. Translation requests which do not meet this criterion are met witha 403 Forbidden response. This means for instance that a Web Profile client cannot simply request, e.g., the content of file:///etc/passwd. This policy is on by default, but can be switched off and on using the Profiles|Web Profile Configuration|URL Controls menu item from the hub GUI, or with the -web:[no]urlcontrol hub command-line switch.
  • The MTypes which a web client is permitted to send is, by default, restricted. In this way, web clients can be restricted to sending messages known to have harmless semantics, and blocked, for instance, from sending messages which cause scripts to be executed on desktop clients, which have more potential for dangerous effects. By default, only MTypes matching a "whitelist" of wildcards are allowed; this includes samp.app.*, table.*, image.*, and others which are used for the normal exchange of data. This restriction can be switched off and on using the Profiles|Web Profile Configuration|MType Restrictions menu item from the hub GUI, or with the -web:[no]restrictmtypes hub command-line switch. There are ways from both the hub and the (non-Web) client side of achieving finer control. In particular non-Web clients may annotate their MType subscriptions list using the key "x-samp.mostly-harmless"; setting this to 1 or 0 adds the MType to or removes it from the whitelist for that particular subscribing client. See the documentation of the ListMessageRestriction class for more detail.
  • The Web Profile may be switched off. Users can switch it on and off during hub operation using the Profiles menu from the hub GUI (if present). Since version 1.3-1 it is switched on in the default configuration.
  • If the Web Profile is switched off during operation using the Profiles|Web Profile menu item in the hub GUI, any clients registered through the Web Profile will be disconnected immediately. A user can therefore eject Web Profile clients if there are suspicions about their behaviour. Single clients can be ejected as well using the Clients|Disconnect Selected Client menu item.
  • The details of which cross-domain workarounds are used can be controlled from the Profiles|Web Profile Configuration menu or from the hub command line. The different workarounds can be switched on and off independently, though only while the Web Profile is not running. By default CORS and Flash are switched on, and Silverlight is switched off.

The upshot of all this is that in the default configuration, even if a hostile web application connects to the hub, it is most unlikely to be able to do anything worse than, for instance, send unwanted FITS images to your image viewer. If such a hostile client is oberved, it can be forcibly disconnected, either individually or by shutting down the Web Profile (or the Hub itself). Such a hostile (or indeed friendly) client can of course only ever connect to the Hub following explicit authorisation by the user (clicking "Yes" on the registration popup dialogue).

jsamp/src/site/xdoc/gui.xml0000664000175000017500000001340212730747754015506 0ustar sladensladen Graphical Features Mark Taylor

JSAMP offers a graphical view of SAMP status in two basic contexts:

  • If you are using the client toolkit, you can see what the client knows about hub status
  • If you are running a hub, you can see the internal state of the hub

These facilities are available both using the command-line clients described in commands and directly from the API. The classes you use are different for the hub and client views, but they share similar class hierachies and graphical representations.

In both cases, there are currently three levels of GUI which can be used:

  1. No GUI: (classes client.HubConnector, hub.HubService)
  2. Client list: An automatically updated list of registered clients with their metadata and subscriptions (classes gui.GuiHubConnector, gui.GuiHubService.html) is available.
  3. Message tracker: As well as the list of registered clients with their metadata and subscriptions, lists of all messages sent and received by visible clients (classes gui.MessageTrackerHubConnector, gui.MessageTrackerHubService) are available.

Each level facility places more load on the implementation, though except for very high message volumes, probably even the message tracker will not slow things down appreciably.

Some more discussion and screenshots of these features are given below. For detailed information on exactly what components and models are available in each case, consult the relevant javadocs.

The client list keeps track of the clients currently registered and their associated metadata and subscriptions. Some ready-to-use components which display this information are available; the following HubMonitor screenshot shows these:

HubMonitor screenshot

The upper left part is a JList of clients with a panel showing the metadata and subscriptions for a selected client on the right. The icon panel in the lower left part gives a more compact display of the currently registered clients. In both cases these components automatically update themselves as clients register and unregister. There is also a register/unregister toggle button and connection status icon at the bottom right.

You can also obtain the ListModel which contains the Client objects for your own custom use if you prefer not to use these components as provided.

The message tracker classes as well as showing the registered clients and their metadata and subscriptions, also keep track of which clients have sent messages to which others, and whether successful responses or other outcomes have resulted. In the case of the hub, all messages from any client to any other can be seen; for the client classes, only those messages sent/received by the client using those classes are visible, because of the SAMP architecture.

The message tracker windows show a tabbed panel. One tab shows the client list as for the earlier example, with the change that a representation of what messages are currently (or recently) in progress is shown after each client's name: little triangles indicate messages received from/sent to to each client (according to whether they are on the left/right of the circle). The triangles change colour etc according to the status of the message in question - hovering the mouse over them gives a tooltip with some more information. You can also obtain a small standalone component which contains just this graphical panel giving message status. This is what the message tracker hub GUI client tab looks like:

Message tracker hub client list

Another tab shows a window which gives more detail on messages sent/received. For each message, a summary row is given in a table, and more detail is shown in a panel below, including the complete message and response contents. The messages are retained in the table for a little while after they have completed to allow them to be examined, and then disappear automatically. The message detail tab for the hub looks like this:

Message tracker hub message list

You can also obtain the various ListModels containing message Transmission information if you want to construct your own custom components based on these.

jsamp/src/site/xdoc/index.xml0000664000175000017500000000610612730747754016034 0ustar sladensladen JSAMP Mark Taylor

JSAMP is a Java toolkit for use with the Simple Applications Messaging Protocol. It provides the following components:

  • A SAMP hub implementation, suitable for standalone or embedded use
  • A set of classes which can be used to implement SAMP capabilities in client applications
  • A hub test and benchmarking suite, which can test correctness and performance of third-party hub implementations
  • Utilities which can be used by client and hub developers, or SAMP users, to analyse or debug SAMP sessions
  • Graphical components built on top of the basic functionality suitable for use with the above
  • Hub and Client implementations of the Standard and Web Profiles.
  • A bridge component, which can allow clients on different hubs to talk to each other

For a given purpose, some or all of these components may be used at one time.

The toolkit's design aims are, in rough order of priority:

  • Correctness and completeness according to the SAMP standard
  • Robustness
  • Ease of use for client application authors
  • Ease of deployment within third-party applications (few dependencies)
  • Flexibility, including the possibility to use with custom SAMP Profiles
  • Good documentation
  • Small size (this one's gone by the board a bit)

The current version targets the SAMP 1.3 Recommendation document (11 April 2012).

JSAMP is currently developed by Mark Taylor (m.b.taylor@bristol.ac.uk), working in the Astrophysics Group at Bristol University. It was originally developed within the UK's AstroGrid project, and has also been supported by STFC grants ST/H008470/1, ST/I00176X/1 and ST/J001414/1, and by Microsoft Research. All of this support is gratefully acknowledged. Many members of the SAMP community have provided valuable input and help with development; special mentions go to Thomas Boch, Laurent Bourgès, Sylvain Lafrasse, Jonathan Fay, Paul Harrison, Luigi Paioro, John Taylor and Ivan Zolotukhin.

Queries, bug reports, suggestions should generally be mailed to the author. Alternatively they can be addressed at the source respository or, if of general interest raised on the apps-samp@ivoa.net mailing list.

JSAMP is open source software; it is available under (at least) the Academic Free Licence and the BSD Licence.

jsamp/src/site/xdoc/history.xml0000664000175000017500000011151412730747754016426 0ustar sladensladen Change Log Mark Taylor

Date: 31 Jul 2008

Initial release (beta). Targets SAMP WD v1.0 (25 June 2008).

Date: 19 Sep 2008

Code reorganisation and pluggable XML-RPC implementation:
The XML-RPC and Standard Profile-related parts of the library have been reorganised somewhat. A new package org.astrogrid.samp.xmlrpc ant its descendants now hold all the code which relates specifically to XML-RPC communications and the SAMP Standard Profile; the code in the other packages is profile-agnostic and handles only transport-independent aspects of the protocol. The xmlrpc package itself defines a pluggable interface for providing XML-RPC client and server functionality; two implementations are also provided, in the packages xmlrpc.apache and xmlrpc.internal. The Apache one is basically what was there in previous versions; the internal one is completely freestanding, and if this is used the Apache XML-RPC library is not required for operation.
GUI functionality added
There are more classes in the org.astrogrid.samp.gui package to facilitate high-level use of SAMP within GUI applications. ConnectorGui provides Actions suitable for insertion in a general SAMP control menu, and SendActionManager provides menus and Actions for sending particular messages.
New command snooper:
Logs received messages to the terminal. Useful for debugging.
Minor changes:
  • Moved some GUI functionality from client.HubConnector to gui.ConnectorGui.
  • HubClient privateKey member is now an Object not a String, for greater generality.
  • RegInfo(String,String,String) constructor withdrawn.
  • HubRunner class moved from package hub to xmlrpc.
  • Several other more or less minor changes.

Date: 25 Sep 2008

Bugfixes:
  • Fixed a problem with XML-RPC implementation when running in presence of unexpected (not 1.2) version of Apache XML-RPC library.
  • jsamp.xmlrpc.impl property now correctly propagated to JVM running external hub.
  • Race condition in unit tests which sometimes gave false failures is fixed.
  • Fixed threading problem in hubmonitor which meant that sometimes window did not appear.
  • Some other minor items.

Date: 9 Dec 2008

Major New Features:
  • There is now an option for both hub and client GUI to keep track of and display messages which have been sent. HubRunner and HubMonitor GUIs by default now have tabs showing messages sent/received and their current status - see GUI Features section for some screenshots. To see this in action, start the HubRunner in (default) msg-gui mode and then run HubTester. Or just use the hub with your own SAMP clients. Note that this functionality incurs some overhead - if not used no such overhead is incurred.
  • A new small GUI component is available which just shows the icons for all registered clients (like what used to appear in the bottom right corner of VODesktop windows).
  • Facilities have been added for full logging of all XML or RPC communications in the hub or clients. See new xml-log and rpc-log options to -xmlrpc flags of command-line tools, and classes org.astrogrid.samp.xmlrpc.internal.*LoggingInternal*.
  • Replace DefaultSendActionManager with other, more capable, SendActionManager subclasses. This makes it easy to handle the results from messages which have been sent, for instance by passing the information to the user graphically or in other ways.
  • HubMonitor tool can now subscribe to messages other than administrative ones, like Snooper.
  • MessageSender tool now pretty-prints response(s).
Backwards Compatibility:
There have been a number of changes since release 0.2 which are backwardly incompatible, so that source code using earlier versions of JSAMP will need to be adjusted. GUI-related functionality is most affected. This is because I'm still designing it, and some changes were required to accommodate new features etc; sorry. I hope that there will be fewer backwardly incompatible changes in the future as the library matures - but I'm not offering any guarantees just yet. Some of the more obvious changes are as follows:
  • New class gui.GuiHubConnector now contains the Swing-related functionality which was previously in (its superclass) client.HubConnector, and also all the functionality from the now removed class gui.ConnectorGui.
  • HubRunner and ConnectorGui APIs modified to permit use of various different hub implementations (with different graphical characteristics - see HubMode).
If there are things which used to work in a previous version and you can't see how to do it now, please contact me and I'll advise how to update.
Bugfixes:
  • Fixed server error which sometimes resulted in failed reads, especially for long messages.
  • Fixed error in handling CalcStorm -xmlrpc flag.
Other:
  • Change return values of callAll and notifyAll hub methods as per agreed modifications to the standard at version 1.1. Affects hub and client API, implementation and test suite.
  • Default XML-RPC implementation is now Internal rather than Apache.
  • HubConnector now subscribes to samp.hub.disconnect MType.
  • Improved performance of internal client when sending long messages.
  • Add SampUtils.isSampChar() convenience method.
  • Add public createTag method to HubConnector.
  • Added test for long messages in HubTester.
  • HubTester now tests nearly all legal SAMP string characters.
  • ...And some other things not mentioned here.

Date: 27 Mar 2009

Minor Enhancements:
  • Add GuiHubConnector.createRegisterOrHubAction method, which registers if it can, else offers the user to start a hub. This may be the only button you need.
  • Internal HTTP server now tolerates LF as well as the correct CRLF as HTTP request header line terminators, as recommended by HTTP 1.1 (RFC2616).
  • Add convenience methods call and callAll to HubConnector - these allow you to make asynchronous calls so that the results are delivered as callbacks to supplied objects without having to worry about registering handlers and matching tags.
  • Add createTargetSelector/createTargetAction methods to SendActionManager class. These give another way (button plus combo box) go allow users to trigger a send.
  • Add comment to .samp lockfile noting that contact XML-RPC URL hostname is configurable.
  • Cache home directory when first determined in SampUtil.
Backward Compatibility:
  • Moved HTTP server implementation used by internal XML-RPC implementation into its own package, samp.httpd. Also added some utility classes in that package to facilitate serving dynamic resources and resources from the classpath. This is because having a simple self-contained HTTP server may be useful for client implementations doing SAMP-like things other than strictly communicating with the hub.
  • ResultHandler and LogResultHandler classes moved from package gui to client.
  • Changed SAMP protocol version to 1.11.
Bug Fixes:
  • Fixed hub lockfile location bug for Windows at Java 1.4.
  • Fix process handling bug which caused Windows XP external hub start to write incomplete lock file.

Date: 5 Aug 2009

Though this version is numbered 1.0, it's not a giant leap ahead of the previous one (0.3-1). However, this is the first release since SAMP became an IVOA Recommendation, and this toolkit is believed to be fully compliant with that standard. The intention is that backwardly incompatible changes will be kept to a minimum following this release.

New functionality:

  • New Bridge client added. This is a significant bit of infrastructure which allows clients on different hubs to interoperate.
  • Added popup menus to GUI hub views which allow you to ping or forcibly disconnect registered clients.
  • System property jsamp.server.port provided to allow selection of the default server port.
  • System property jsamp.lockfile provided to support non-standard lockfile location.
  • HubConnector now subscribes by default to new client.env.get MType.
  • Added -clientname and -clientmeta flags to Snooper command-line tool.
  • You can now set the samp.secret string for HubRunner if you don't want it chosen randomly.
  • Add some more documentation pages: System Properties, Debugging Aids and Bridge.
  • The help message now reports relevant system properties as well as other help info.

Changes to behaviour (note some of these may have backward compatibility issues):

  • The default hostname for HTTP server etc (SampUtils.getLocalHost()) is now "127.0.0.1", not the DNS name; in certain network environments this works better than the alternatives, though it's less good for inter-machine communications. This default can be altered by setting the samp.hostname system property; it has two new special values "[hostname]" and "[hostnumber]".
  • Icon URLs declared by test clients etc now use internal server references rather than links to external static resources. This means icons are not dependent on network availability.
  • Clients forcibly disconnected by samp.hub.disconnect now don't try to re-autoconnect themselves.
  • Remove warning if permission change on .samp file to owner-only read fails. This permission change is probably not possible on Windows-like OSes (unless anyone can tell me different), and the warning causes confusion.
  • System property samp.localhost renamed to jsamp.localhost (the old name is still recognised for backward compatibility).
  • Withdraw -xmlrpc flag from command-line tools; the jsamp.xmlrpc.impl system property should be used instead.
  • Standard version is now reported as v1.11 REC 2009-04-21.

API Changes (note some of these may have backward compatibility issues):

  • Added DefaultClientProfile class; this is now the recommended way of getting a general purpose ClientProfile object (rather than using StandardClientProfile.getInstance(). Using this will aid pluggability (ability to use with non-standard profiles in the future).
  • Added UtilServer class; this can help to reduce the number of HTTP servers run by a JSAMP application, and provides convenience methods for exporting local (e.g. file: or classpath) URLs.
  • Added getHubClient and disconnect methods to BasicHubService; you can now use the hub object to forcibly disconnect clients.
  • StandardClientProfile now has overridable getLockInfo method for better customisability.
  • Added parseValue utility method to SampUtils class.
  • Added new method HubConnector.connectionChanged.
  • InternalServerFactory now returns a server which can be safely reused.
  • Class LockInfo moved from org.astrogrid.samp package to org.astrogrid.samp.xmlrpc, which is where it should have been.

Bugfixes:

  • Missing jsamp.version file now included in source zip archive.
  • Fixed error reporting bug in messagesender tool.

Date: 21 Jul 2010

Protocol extension:

  • The environment variable SAMP_HUB can now be used to specify a non-default lockfile location for clients and hub to use with the Standard Profile. This usage is expected to be part of the next version of the SAMP standard (SAMP 1.2, in WD at time of writing). See the documentation for the StandardClientProfile class for details, and DefaultClientProfile for a JSAMP-specific extension to this mechanism. The non-standard jsamp.lockfile and jsamp.profile system properties which did the same job in a non-standard way have been withdrawn. This has some backwardly incompatible consequences:
    • jsamp.lockfile and jsamp.profile system properties withdrawn; use SAMP_HUB environment variable instead.
    • SampUtils.LOCKFILE_NAME constant withdrawn; use StandardClientProfile.LOCKFILE_NAME instead.
    • SampUtils.getLockFile() method withdrawn; use StandardClientProfile.getLockUrl() instead. If you want to find out if a hub is running, instead of SampUtils.getLockFile().exists(), use DefaultClientProfile.getProfile().isHubRunning().
    • LockInfo.readLockFile(File) method withdrawn; use LockInfo.readLockFile(URL) instead.
    • LockInfo.readLockFile() method withdrawn; use StandardClientProfile.getLockInfo() instead.
    • LockWriter no-arg constructor withdrawn.

Enhancements:

  • Where possible, if the hub is running in GUI mode it will now install itself as an icon in the "system tray" rather than posting the window directly; a popup menu associated with the tray icon allows window display and hub shutdown. If the platform does not provide system tray functionality, it will revert to the previous behaviour of posting the window directly. System tray functionality is available only when running under Java 1.6 or later, and only when using a suitable display manager.
  • Added -httplock flag to hubrunner, which allows publication of lockfile by HTTP for use by non-localhost clients.

Usability:

  • Invoking JSamp class (e.g. "java -jar jsamp.jar") with no arguments now starts the hub rather than just printing a usage message.
  • ClientProfile interface has new method isHubRunning. This is more general (and easier to use) than testing something like StandardClientProfile.getLockFile().exists().
  • HubRunnner.runHub method now returns the running HubRunner instance.

Bugfixes:

  • Fix error in XML-RPC fault responses (faultCode was string instead of int).
  • Fix the MessageTracker hub so it doesn't get confused if clients re-use the same tag for different messages.
  • Adjusted logic of client tracking in HubConnector to be more robust.
  • Fix bug in outgoing message tooltips for MessageTracker message box.
  • Fix concurrency issue in HttpServer.
  • Fix issue which could cause GUI freeze while waiting for remote icons to load.
  • Catch security exceptions when calling addShutdownHook in XmlRpcHubConnection. It seems that signed applets may not have the appropiate permissions.

Minor improvements:

  • Command-line tools now use DefaultClientProfile.getProfile() rather than StandardClientProfile.getInstance() as they should (hence will be correctly influenced by SAMP_HUB environment variable).
  • More tests added to HubTester for correctness of XML-RPC responses.
  • Be more frugal with DocumentBuilderFactory instances to reduce some unwanted logging.
  • Improve wait implementation in HubTester to avoid non-yield race conditions.
  • A warning is now issued if getClientMap is called before declareSubscriptions on a HubConnector, since without declaring subscriptions the client map won't work.

Date: 15 Feb 2011

The two main changes at this release are generalisation of the hub running framework to allow multiple profiles to run interfacing to the same hub simultaneously, and implementation of the experimental Web Profile. The former was motivated by the latter (though should really have been present from the start). This work was suggested by Jonathan Fay and financially supported by Microsoft Research, whose support is gratefully acknowledged. There are also a number of bug fixes and minor enhancements.

Hub framework:

The org.astrogrid.samp.hub.HubRunner class has been deprecated in favour of the new class org.astrogrid.samp.hub.Hub. This can be used from the command line or programmatically to start a hub, and it can run zero or more profiles, defined by the new HubProfile interface, simultaneously. HubProfile implementations are provided for the Standard Profile and the Web Profile, and you can plug in your own at runtime. This class is now the Main-Class defined in the jar file's Manifest, so invoking (e.g. clicking or java -jar) the jsamp-*.jar file will now invoke this class. Documentation of the command-line usage is on the Command-line Tools page. By default only the Standard Profile is run, so simply invoking the jar file will have much the same behaviour as it did in previous versions, that is starting a Standard Profile-only hub. Which profiles are run can be influenced in various ways, including by defining the jsamp.hub.profiles system property.

A new "facade" mode of hub operation has been introduced, which allows tunnelling from one hub profile to another (mostly of interest to hub profile implementors).

There have been a number of other backwardly incompatible changes to the hub implementation classes: Most of the HubService interface has been replaced using HubConnection and Receiver has been replaced with CallableClient, reducing amount and duplication of code, and some assumptions specific to the Standard Profile have been removed from the interfaces of hub classes which are properly profile-independent. These changes are not believed likely to affect anybody who is not writing hub implementation code.

Web Profile:

This release also includes client and hub implementations of the experimental Web Profile. Implementation is in the new package org.astrogrid.samp.web. Note that this profile is still under discussion and details of the definition may change in the future.

Bug fixes:

  • Fixed URI escaping bugs related to "+" characters (new utility methods SampUtils.uriEncode/Decode).
  • Fixed some concurrency bugs to do with handler lists in HubConnector, HttpServer, InternalServer and ApacheServer (thanks to Laurent Bourgès).
  • Fixed, I think, threading issues that occasionally prevented hub forced shutdown notifications getting to some clients. It is possible this fix will have knock-on performance or other effects, especially in the presence of badly-behaved clients - please report if you notice problems. Thanks (again) to Laurent Borgès for extensive help with this.
  • Fixed a bug related to output capture when an external hub is started.
  • Fixed some not-quite-correct internal HTTP server error responses (405, 400).
  • Fixed internal HTTP server so it won't reorder request/response headers.
  • Improved header handling (case-sensitive, duplicated keys) in HttpServer.
  • Fixed client XML logging bug.

Minor changes and enhancements:

  • Changed pluggable XML-RPC implementation framework API slightly - handleCall method in SampXmlRpcHandler interface gets an additional argument.
  • SAMP Version now declares itself as SAMP v1.2 REC.
  • Some additional system properties are now propagated when starting an external hub.
  • Window positioning now follows platform norms by default rather than the java policy of placement at (0,0). Specifically: the java.awt.Window.locationByPlatform system property is set, if it does not already have an explicit value, in the JSamp class.
  • AbstractMessageHandler has a new method createResponse which may be overridden for more flexibility.
  • External hub start action is disabled in JNLP context, since it won't work.

Date: 2 Aug 2011

Various changes relating to configurable use of profiles and the Web Profile in particular, to match the SAMP 1.3 WD, and to enable experimentation with multi-profile configurations while we gain experience with security options. There is more discussion in the new Profiles page, but the main changes are:

  • The Web Profile is now available, but not switched on, by default. The Web Profile (and other profiles) can be switched on or off while the hub is running by using the new Profiles menu in the hub window or system tray icon. There is a corresponding new hub command flag -web:extraprofiles and system property jsamp.hub.profiles.extra.
  • The Profiles menu also has a Web Profile Configuration submenu, which allows fine control over the way the Web Profile is run. This is to permit experimentation with security options; this menu or some of its options may be withdrawn in future versions. There are corresponding flags for the hub command: -web:[no]cors, -web:[no]flash, -web:[no]silverlight, -web:[no]urlcontrol.
  • The Web Profile hub server now blocks non-localhost requests at the HTTP level. This should benefit security.
  • The Web Profile URL translation service now by default blocks access to sensitive resources (local filesystem and localhost URLs) if they have not previously been mentioned by a non-Web Profile client. This should benefit security.
  • Web profile now uses a map argument and not a string argument to register(), as per the change to the Web Profile specification.
  • Various related changes to the hub implementation and API have been made to enable the above. The recommended entry point, Hub.runHub(), still works though.

The Hub GUI window now has menus:

  • File menu, which just allows hub shutdown.
  • Clients menu, which allows pinging and disconnection of clients (this functionality duplicates the existing, but not very obvious, client popup menu).
  • Profiles menu, as described above.

JSON is now used as the standard serialization/deserialization format for SAMP objects in a few places:

  • New utility methods toJson and fromJson in SampUtils class.
  • Non-scalar message parameter contents and client metadata can now be specified to the messagesender and snooper tools on the command line using JSON syntax.
  • Message display, e.g. in the hub message tracker GUI, are now shown in JSON format. In practice this means you'll see a few more double quotes - it also means they can be cut and pasted directly into other JSON-aware contexts.

Other items:

  • The ClientProfile.isHubRunning method now probes more agressively for a hub (for instance in the Standard Profile it pings rather than just looking for the .samp file). This is a change to both the API and the implementations.
  • The Web Profile registration dialogue is now somewhat internationalised. Thanks to Luigi Paioro and Markus Demleitner for providing translations. This can be extended by providing further instances of AuthResourceBundle class or supplying a suitable org.astrogrid.samp.web.AuthResourceBundle_xx.properties resource; run AuthResourceBundle.main() for example output.
  • The Web Profile hub now fails at start time rather than during registration attempts if Swing client authorization is in use with headless graphics.
  • More helpful warning message when standard profile lockfile is present but broken (suggests deleting file).
  • HubMonitor now correctly uses DefaultClientProfile rather than StandardClientProfile.

Date: 26 Oct 2011

  • Hub now runs with Web Profile on by default.
  • Web Profile has a new configuration option, which restricts permitted MTypes that a Web Profile client can send. This is switched on by default.
  • Tone down warning in the Web SAMP confirmation dialogue slightly to reflect improved security measures in default Web Profile configuration.
  • The messagesender command-line tool has new flags -targetname and -targetid which replace the previous -target flag.
  • Add French translation for Web SAMP confirmation dialogue (thanks to Thomas Boch).
  • URL encode argument of ResourceHandler.addResource (thanks to Omar Laurino).
  • Add -web:auth extreme mode to hub.
  • Web hub profile now throws SampException not SecurityException on registration failure; this makes failures less verbose in the hub.

Date: 12 Dec 2011

  • Remove some code which was not compatible with J2S1.4.
  • Web Profile URL control now controls all URLs not just local ones. All maps passing through the hub connection are now scanned for URLs.
  • Reduce logging for message send failures, partly by reducing failures themselves.
  • Hub implements x-samp.query.by-meta MType.
  • New methods on Hub class to determine whether the hub is running and with what profiles.
  • Added -subs flag to Snooper tool.
  • Fixed bug in JSON parser (wouldn't recognise empty objects "{}").
  • The implementation of MType restrictions for Web Profile clients has been changed. Blocked MTypes are now completely hidden from Web Profile clients. There is still a list of blocked MTypes (ones with known harmless semantics), but subscribing clients may override this, i.e. note MTypes that they subscribe to as known to be either harmless or harmful by annotating the subscription (populating the map value corresponding to the MType key) with a key "x-samp.mostly-harmless" and a value of "1" (harmless) or "0" (harmful).
  • The Web Profile registration dialogue now reports the apparent URL of the registering client (as determined from the Referer header) if available.

Date: 30 Oct 2012

  • Failure to start some profiles no longer prevents the hub from starting up at all. This means that default configuration (Standard + Web profile) hub startup no longer fails on a machine that already has a web profile hub running on it.
  • Reported standard version is now 1.3 REC.
  • Fixed some broken command-line hub options (-[no]restrictmtypes and -[no]urlcontrol).
  • Command line -web:* and -std:* flags are now used even when -profiles flag is not supplied explicitly (where possible).
  • Added Hub.getRunningHubs static method to locate hubs running in current JVM, as requested by JMMC.
  • Added Hub.getWindow() method, as requested by JMMC.
  • Improved shutdown sequence, shutting down clients first and then hubs, following contribution from JMMC.
  • Plugged several resource leaks (cancel auxiliary threads in HubConnector on setActive(false), free XML-RPC servers associated with callable clients on unregister) following contribution from JMMC.
  • The distributed jar file is now signed with a Thawte code-signing certificate (owned by the University of Bristol). This means that you can provide a JNLP file to start a hub using WebStart which will not complain to the user about not being able to authenticate the publisher's identity.

Date: 9 Oct 2013

  • JSAMP has been rehosted, and no longer lives on AstroGrid sites. The distribution web site is now at http://www.star.bristol.ac.uk/~mbt/jsamp/, and the source can be found on github at https://github.com/mbtaylor/jsamp.
  • Fixed the Web Profile hub to work with HTTPS-based web pages as well as HTTP-based ones; previously HTTPS pages were blocked for no good reason(?). Developers of applications which may run the JSAMP hub are encouraged to upgrade to this version to avoid penalising use of HTTPS in third-party SAMP web applications.
  • Add new system property jsamp.web.extrahosts to allow trusted non-local hosts to register with the Web Profile (for instance near-local mobile devices).
  • Improve shutdown behaviour somewhat, now less prone to fail to delete .samp file in JNLP shutdown.
  • The jar file is now distributed in both signed and unsigned forms.

Date: 13 Nov 2014

  • The build is now done using Maven 3. Thanks to Paul Harrison for doing most of the work on this. As a consequence, the version numbering system has changed - it's now a.b.c instead of a.b-c. Note although the POM file is updated etc, the release is not currently deploying to a central maven repository; I haven't worked out how to do that yet. If somebody out there is keen to see that happen and wants to help me out, get in touch.
  • The jar file now contains "Permissions" and "Application-Name" attributes, which are required when running the hub as a signed jar under WebStart at java 7u51+.
  • The Web Profile security dialogue has been adjusted so that long strings transmitted as application Name, Origin or URL do not extend the width of the popup window indefinitely.
  • Updated Standard Profile samp.profile.version value to 1.3. By an oversight it had got stuck at 1.11.
  • The hub now completes client calls without waiting for HTTP responses. (XmlRpcCallableClient implementation uses callAndForget instead of callAndWait). This prevents some cases of calls hanging in the presence of badly-behaved clients. Some other bugfixes related to hub shutdown were required to prevent this causing knock-on problems.
  • Work round a JNLP shutdown classloading bug.
  • One or two minor bugfixes.
jsamp/src/site/xdoc/api.xml0000664000175000017500000001251312730747754015475 0ustar sladensladen API Overview Mark Taylor

The client toolkit API is fully described by the javadocs. This section provides some pointers for deciding where to start looking in order to use the toolkit for a particular purpose.

If you have an application in which you would like to provide SAMP functionality, you should in most cases use the HubConnector class or one of its subclasses. It is suitable for long-running applications which wish to send and receive SAMP messages, and seamlessly handles registering and unregistering with hubs, including taking care of the case in which a running hub shuts down and another one starts up later during the application's lifetime.

The HubConnector subclasses GuiHubConnector and MessageTrackerHubConnector layer some additional graphical facilities on top of HubConnector itself, for instance automatically updated ListModels which keep track of registered clients and sent/received messages. Some ready-to-use GUI components suitable for use with these are provided in the org.astrogrid.samp.gui package, or you can roll your own. The org.astrogrid.samp.gui.HubMonitor class is a simple application which uses a HubConnector. You may find its source code useful as a template for your own applications.

Short-lived applications, for instance ones which simply wish to register, send a message and unregister again, may prefer to use the lower-level HubConnection interface directly.

In either case, an instance of the ClientProfile class is used to initiate communication with the hub. The usual way for a client to obtain a profile for communication is to call DefaultClientProfile.getProfile. By default this returns an object conforming to SAMP's Standard Profile, but other profiles could be implemented and used with the rest of the API.

The classes which provide the basic SAMP hub functionality are in the package org.astrogrid.samp.hub, and the classes required for running a Standard and Web Profile hub are in org.astrogrid.samp.xmlrpc and org.astrogrid.samp.web respectively. If you want to run a hub, either with or without a graphical display, in most cases you can do it simply by using static methods (runHub/runExternalHub) of the Hub class. This hub can be embedded within a third party application if required. For more configurability, for instance customising the graphical display or hub discovery process, you may want to look at the other classes in this package, perhaps with reference to the source code. It is possible to configure which profiles a hub runs either programmatically using Hub class methods or externally by setting the jsamp.hub.profiles system property.

The JSAMP classes log activity using the J2SE java.util.logging classes. Most messages are either at the INFO level (normal activity, e.g. details of each message sent/received) or the WARNING level (errors, things which might be cause for concern). If run under default settings, all of these messages will be written to standard error for the application. It's easy to configure this behaviour otherwise however. For details see the J2SE java.util.logging javadocs, but the short story is: to restrict logging to WARNINGs only, do

Logger.getLogger("org.astrogrid.samp").setLevel(Level.WARN);

or to turn logging off altogether, do

Logger.getLogger("org.astrogrid.samp").setLevel(Level.OFF);
jsamp/src/site/xdoc/sysprops.xml0000664000175000017500000001755612730747754016642 0ustar sladensladen System Properties Mark Taylor

System properties are a way of communicating values from the runtime environment to Java; they are effectively Java's answer to environment variables. JSAMP defines some system properties which you can set to alter the way that it behaves.

If you are starting java from the command line, you can specify these using java's -D flag with the syntax -Dname=value. Note that any such flags must occur before a -jar flag on the command line. For example, to request that JSAMP opens its default HTTP server to listen on port 2112, you could invoke a JSAMP-using application like this:

   java -Djsamp.server.port=2112 -jar foo.jar

They will work the same way for JSAMP's command line tools, and for third-party applications which use the JSAMP library for SAMP communications.

System properties can also be set programmatically from within Java code. Note that in this case you may need to set these property values near the start of the application; in most cases they are only read once.

The following system properties may be used to affect JSAMP's behaviour. Listed alongside the property name is a link to the static public class member which may be used to refer to this property name in code: the javadocs so linked may provide more detail on use.

jsamp.hub.profiles (Hub.HUBPROFILES_PROP):
jsamp.hub.profiles.extra (Hub.EXTRAHUBPROFILES_PROP):
These define the default profiles which a hub will run. If a hub is started from within JSAMP without specifying which profiles it will use, the profiles are defined by the value of these properties. profiles determines the profiles which will start when the hub starts, and profiles.extra determines additional profiles which may be started later under user control. The values are comma-separated lists, and each item may be one of:
  • std: Standard Profile
  • web: Web Profile
  • hubprofile-classname: the name of a class which implements HubProfile and has a suitable no-arg constructor.
If this property is not specified, a default list will be used. This is currently the Standard and Web Profiles on start, with no extras (equivalent to jsamp.hub.profiles=std,web, jsamp.hub.profiles.extra=""). This property only affects how a hub is run; it has no effect on SAMP clients.
jsamp.localhost (SampUtils.LOCALHOST_PROP):
Sets the hostname by which the local host is to be identified in URLs, for instance server endpoints. If unset, the default is currently the loopback address 127.0.0.1. However, if this property is set (presumably to the local host's fully- or partly-qualified domain name) its value will be used instead. There are two special values:
  • [hostname]: uses the host's fully qualified domain name
  • [hostnumber]: uses the host's IP number
jsamp.server.port (UtilServer.PORT_PROP):
Gives a preferred port number on which to open the default server. In most cases the default server is the only HTTP server used by an application using JSAMP, though they can have more than one. If this property is undefined or set to zero, or if the specified port number is already occupied, an unused port is chosen by the system.
jsamp.web.extrahosts (CorsHttpServer.EXTRAHOSTS_PROP):
Gives a comma-separated list of names (host names or IP numbers) of hosts that are permitted to use the Web Profile alongside the localhost. Normally web profile access is only allowed to the local host for security reasons, but trusted "near-local" hosts may be added here if required. One possibility is to add the address of a mobile device to be used for external application control.
jsamp.xmlrpc.impl (XmlRpcKit.IMPL_PROP):
Indicates which pluggable XML-RPC implementation should be used. If defined, this may be one of the following strings:
  • internal: normal internal implementation
  • xml-log: internal implementation which logs all incoming and outgoing XML-RPC messages by writing their full XML form to standard output
  • rpc-log: internal implementation which logs all incoming and outgoing XML-RPC messages by writing an abbreviated form of their content to standard output
  • apache: implementation using Apache's XML-RPC library version 1.2; this requires the Apache xmlrpc-1.2b classes to be on the classpath
The members of this list are given as the contents of the XmlRpcKit.KNOWN_IMPLS array. Alternatively the full classname of a class which implements org.astrogrid.samp.xmlrpc.XmlRpcKit and which has a no-arg constructor may be given. The default is currently internal if this property is not specified. The implementations ought to behave the same as far as communications go, though there may be performance differences (the logging ones will be slower for sure). The logging implementations can be useful for debugging.

Note that the system properties jsamp.lockfile and jsamp.profile, which existed in JSAMP 1.0, have been withdrawn in subsequent versions. Use the SAMP_HUB environment variable, in accordance with the standard profile extension, instead.

jsamp/src/test/0000775000175000017500000000000012730747754013256 5ustar sladensladenjsamp/src/test/java/0000775000175000017500000000000012730747754014177 5ustar sladensladenjsamp/src/test/java/org/0000775000175000017500000000000012730747754014766 5ustar sladensladenjsamp/src/test/java/org/astrogrid/0000775000175000017500000000000012730747754016764 5ustar sladensladenjsamp/src/test/java/org/astrogrid/samp/0000775000175000017500000000000012730747754017724 5ustar sladensladenjsamp/src/test/java/org/astrogrid/samp/client/0000775000175000017500000000000012730747754021202 5ustar sladensladenjsamp/src/test/java/org/astrogrid/samp/client/HubConnectorTest.java0000664000175000017500000003026712730747754025306 0ustar sladensladenpackage org.astrogrid.samp.client; import java.io.IOException; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Random; import java.util.logging.Level; import java.util.logging.Logger; import junit.framework.TestCase; import org.astrogrid.samp.Client; import org.astrogrid.samp.Message; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.RegInfo; import org.astrogrid.samp.Response; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.TestProfile; import org.astrogrid.samp.xmlrpc.internal.InternalServer; public class HubConnectorTest extends TestCase { private final Random random_ = new Random( 2323L ); private static final String ECHO_MTYPE = "test.echo"; protected void setUp() { Logger.getLogger( "org.astrogrid.samp" ).setLevel( Level.SEVERE ); } public void testConnector() throws IOException, InterruptedException { TestProfile[] profiles = TestProfile.createTestProfiles( random_ ); for ( int i = 0; i < profiles.length; i++ ) { testConnector( profiles[ i ] ); } } private void testConnector( TestProfile profile ) throws IOException, InterruptedException { assertNull( profile.register() ); HubConnector connector = new HubConnector( profile ); connector.setAutoconnect( 1 ); connector.declareSubscriptions( connector.computeSubscriptions() ); Map clientMap = connector.getClientMap(); assertTrue( clientMap.isEmpty() ); Metadata meta = new Metadata(); meta.setName( "HubConnectorTest" ); meta.put( "colour", "yellow" ); connector.declareMetadata( meta ); assertEquals( meta, connector.getMetadata() ); assertTrue( ! connector.isConnected() ); profile.startHub(); HubConnection c0 = profile.register(); assertNotNull( c0 ); assertEquals( new HashMap(), getSubscriptions( c0 ) ); // Sometimes these tests just don't work. It's because of the // indeterminate nature of SAMP - message delivery order is not // guaranteed etc. I could remove these tests but .. most of the // time they should, and do, pass. synchronized ( clientMap ) { while ( clientMap.size() != 3 ) clientMap.wait(); } c0.unregister(); synchronized ( clientMap ) { while ( clientMap.size() != 2 ) clientMap.wait(); } assertTrue( connector.isConnected() ); assertNotNull( connector.getConnection() ); assertEquals( 2, clientMap.size() ); assertTrue( clientMap.containsKey( connector.getConnection() .getRegInfo().getHubId() ) ); assertTrue( clientMap.containsKey( connector.getConnection() .getRegInfo().getSelfId() ) ); assertEquals( meta, connector.getMetadata() ); assertEquals( meta, getMetadata( connector.getConnection() ) ); String selfId = connector.getConnection().getRegInfo().getSelfId(); synchronized ( clientMap ) { while ( ! meta.equals( ((Client) clientMap.get( selfId )) .getMetadata() ) ) { clientMap.wait(); } } Subscriptions subs = connector.getSubscriptions(); assertTrue( subs.containsKey( "samp.hub.event.register" ) ); assertTrue( ! subs.containsKey( ECHO_MTYPE ) ); assertEquals( subs, getSubscriptions( connector.getConnection() ) ); synchronized ( clientMap ) { while ( ! subs.equals( ((Client) clientMap.get( selfId )) .getSubscriptions() ) ) { clientMap.wait(); } } connector.addMessageHandler( new TestMessageHandler() ); subs = connector.computeSubscriptions(); assertTrue( subs.containsKey( ECHO_MTYPE ) ); assertTrue( ! subs.equals( connector.getSubscriptions() ) ); assertTrue( ! subs .equals( getSubscriptions( connector.getConnection() ) ) ); connector.declareSubscriptions( subs ); assertEquals( subs, connector.getSubscriptions() ); assertEquals( subs, getSubscriptions( connector.getConnection() ) ); assertEquals( meta, ((Client) clientMap.get( connector.getConnection() .getRegInfo().getSelfId() )) .getMetadata() ); meta.put( "colour", "blue" ); connector.declareMetadata( meta ); assertEquals( meta, connector.getMetadata() ); assertEquals( meta, getMetadata( connector.getConnection() ) ); delay( 500 ); assertEquals( subs, ((Client) clientMap.get( connector.getConnection() .getRegInfo().getSelfId() )) .getSubscriptions() ); assertEquals( meta, ((Client) clientMap.get( connector.getConnection() .getRegInfo().getSelfId() )) .getMetadata() ); RegInfo regInfo0 = connector.getConnection().getRegInfo(); connector.setActive( false ); assertNull( connector.getConnection() ); assertTrue( clientMap.isEmpty() ); connector.setActive( true ); RegInfo regInfo1 = connector.getConnection().getRegInfo(); assertTrue( ! regInfo0.getSelfId().equals( regInfo1.getSelfId() ) ); assertEquals( regInfo0.getHubId(), regInfo1.getHubId() ); connector.setAutoconnect( 0 ); profile.stopHub(); delay( 500 ); assertNull( connector.getConnection() ); assertTrue( clientMap.isEmpty() ); profile.startHub(); RegInfo regInfo2 = connector.getConnection().getRegInfo(); assertTrue( ! regInfo0.getPrivateKey() .equals( regInfo2.getPrivateKey() ) ); assertTrue( ! regInfo1.getPrivateKey() .equals( regInfo2.getPrivateKey() ) ); assertEquals( 2, clientMap.size() ); assertEquals( meta, getMetadata( connector.getConnection() ) ); assertEquals( subs, getSubscriptions( connector.getConnection() ) ); assertTrue( clientMap.containsKey( connector.getConnection() .getRegInfo().getHubId() ) ); assertTrue( clientMap.containsKey( connector.getConnection() .getRegInfo().getSelfId() ) ); connector.getConnection().unregister(); profile.stopHub(); } public void testSynch() throws IOException { TestProfile[] profiles = TestProfile.createTestProfiles( random_ ); for ( int i = 0; i < profiles.length; i++ ) { testSynch( profiles[ i ] ); } } private void testSynch( TestProfile profile ) throws IOException { profile.startHub(); TestMessageHandler echo = new TestMessageHandler(); HubConnector c1 = new HubConnector( profile ); String id1 = c1.getConnection().getRegInfo().getSelfId(); c1.addMessageHandler( echo ); Subscriptions subs1 = new Subscriptions(); subs1.addMType( ECHO_MTYPE ); c1.declareSubscriptions( subs1 ); HubConnector c2 = new HubConnector( profile ); String id2 = c2.getConnection().getRegInfo().getSelfId(); c2.addMessageHandler( echo ); c2.declareSubscriptions( c2.computeSubscriptions() ); Map params = new HashMap(); params.put( "names", Arrays.asList( new String[] { "Arthur", "Gordon", "Pym" } ) ); Message msg = new Message( ECHO_MTYPE, params ); msg.check(); Response r1 = c1.callAndWait( id2, msg, 0 ); Response r2 = c2.callAndWait( id1, msg, 0 ); assertEquals( Response.OK_STATUS, r1.getStatus() ); assertEquals( Response.OK_STATUS, r2.getStatus() ); assertEquals( params, r1.getResult() ); assertEquals( params, r2.getResult() ); TestResultHandler tHandler = new TestResultHandler(); c1.call( id2, msg, tHandler, 0 ); int millis = tHandler.waitTillDone(); assertTrue( tHandler.getResponse( id2 ).isOK() ); Message msg5 = new Message( msg ); msg5.addParam( "waitMillis", "5000" ); TestResultHandler th2 = new TestResultHandler(); TestResultHandler th3 = new TestResultHandler(); c1.call( id2, msg5, th2, 1 ); c1.callAll( msg5, th3, 1 ); assertTrue( ! th2.isDone_ ); assertTrue( ! th3.isDone_ ); assertNull( th2.getResponse( id2 ) ); assertNull( th3.getResponse( id2 ) ); int delay = th2.waitTillDone() + th3.waitTillDone(); assertTrue( delay > 400 ); // should be about 1 sec assertTrue( th2.isDone_ ); assertTrue( th3.isDone_ ); assertNull( th2.getResponse( id2 ) ); assertNull( th3.getResponse( id2 ) ); Message msg02 = new Message( msg ); msg02.addParam( "waitMillis", "200" ); TestResultHandler th4 = new TestResultHandler(); TestResultHandler th5 = new TestResultHandler(); long start02 = System.currentTimeMillis(); c1.call( id2, msg02, th4, 5 ); c1.callAll( msg02, th5, 5 ); assertTrue( ! th4.isDone_ ); assertTrue( ! th5.isDone_ ); th4.waitTillDone(); th5.waitTillDone(); assertTrue( System.currentTimeMillis() - start02 > 150 ); assertTrue( th4.isDone_ ); assertTrue( th5.isDone_ ); assertTrue( th4.getResponse( id2 ).isOK() ); assertTrue( th5.getResponse( id2 ).isOK() ); profile.stopHub(); } private Subscriptions getSubscriptions( HubConnection connection ) throws SampException { return connection .getSubscriptions( connection.getRegInfo().getSelfId() ); } private Metadata getMetadata( HubConnection connection ) throws SampException { return connection.getMetadata( connection.getRegInfo().getSelfId() ); } static void delay( int millis ) { Object o = new Object(); synchronized ( o ) { try { o.wait( millis ); } catch ( InterruptedException e ) { throw new RuntimeException( e ); } } } private static class TestMessageHandler extends AbstractMessageHandler { TestMessageHandler() { super( ECHO_MTYPE ); } public Map processCall( HubConnection conn, String senderId, Message msg ) { String waitParam = (String) msg.getParam( "waitMillis" ); if ( waitParam != null ) { int delay = SampUtils.decodeInt( waitParam ); delay( delay ); } return msg.getParams(); } } private static class TestResultHandler implements ResultHandler { volatile boolean isDone_; final Map resultMap_ = Collections.synchronizedMap( new HashMap() ); public synchronized void result( Client client, Response response ) { resultMap_.put( client.getId(), response ); notifyAll(); } public synchronized void done() { isDone_ = true; notifyAll(); } public Response getResponse( String clientId ) { return (Response) resultMap_.get( clientId ); } public synchronized int waitTillDone() { long start = System.currentTimeMillis(); while ( ! isDone_ ) { try { wait(); } catch ( InterruptedException e ) { fail(); } } return (int) ( System.currentTimeMillis() - start ); } public void reset() { isDone_ = false; resultMap_.clear(); } } public static void main( String[] args ) throws Exception { HubConnectorTest t = new HubConnectorTest(); t.setUp(); t.testConnector(); } } jsamp/src/test/java/org/astrogrid/samp/CommandTest.java0000664000175000017500000000120112730747754022777 0ustar sladensladenpackage org.astrogrid.samp; import junit.framework.TestCase; public class CommandTest extends TestCase { public void testCommands() throws Exception { String[] cmdClasses = JSamp.COMMAND_CLASSES; for ( int i = 0; i < cmdClasses.length; i++ ) { String className = cmdClasses[ i ]; Class clazz = Class.forName( className ); assertNotNull( JSamp.getMainMethod( clazz ) ); } } public void testVersion() { assertTrue( SampUtils.getSampVersion().trim().charAt( 0 ) != '?' ); assertTrue( SampUtils.getSoftwareVersion().trim().charAt( 0 ) != '?' ); } } jsamp/src/test/java/org/astrogrid/samp/web/0000775000175000017500000000000012730747754020501 5ustar sladensladenjsamp/src/test/java/org/astrogrid/samp/web/I18nTest.java0000664000175000017500000000637312730747754022734 0ustar sladensladenpackage org.astrogrid.samp.web; import java.lang.reflect.Method; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.Locale; import java.util.Map; import java.util.ResourceBundle; import java.util.Set; import junit.framework.TestCase; public class I18nTest extends TestCase { private static final Locale[] locales_ = new Locale[] { Locale.ENGLISH, Locale.ITALIAN, Locale.GERMAN, Locale.FRENCH, }; private static final String[] suffixes_ = new String[] { "_en", "_it", "_de", "_fr", }; private static final Method[] contentMethods_ = AuthResourceBundle.getContentMethods(); public void testTools() throws Exception { assertEquals( toMap( getContent( Locale.ENGLISH ) ), toMap( getContent( Locale.UK ) ) ); } public void testAuthResourceBundle() throws Exception { Set mapSet = new HashSet(); for ( int il = 0; il < locales_.length; il++ ) { Locale locale = locales_[ il ]; ResourceBundle bundle = ResourceBundle.getBundle( AuthResourceBundle.class.getName(), locale ); AuthResourceBundle.checkHasAllKeys( bundle ); AuthResourceBundle.Content content = AuthResourceBundle.getAuthContent( bundle ); Map map = toMap( content ); for ( Iterator it = map.entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); String value = (String) entry.getValue(); assertNotNull( value ); assertTrue( value.length() > 0 ); } assertTrue( "Reuse content for " + locale, ! mapSet.contains( map ) ); mapSet.add( map ); } } /** * This one does something different - by picking the resources by * locale-specific name, it avoids getting a parent chain. * In this way it can check if any resources are missing * (if you do it the more obvious way, missing ones are filled in by * the parent, which we don't want for testing purposes). */ public void testAuthSuffixes() { for ( int is = 0; is < suffixes_.length; is++ ) { ResourceBundle bundle = ResourceBundle.getBundle( AuthResourceBundle.class.getName() + suffixes_[ is ], Locale.US ); AuthResourceBundle.checkHasAllKeys( bundle ); } } private static AuthResourceBundle.Content getContent( Locale locale ) { ResourceBundle bundle = ResourceBundle.getBundle( AuthResourceBundle.class.getName(), locale ); return AuthResourceBundle.getAuthContent( bundle ); } private static Map toMap( AuthResourceBundle.Content content ) throws Exception { Map map = new HashMap(); for ( int im = 0; im < contentMethods_.length; im++ ) { Method method = contentMethods_[ im ]; map.put( method.getName(), (String) method.invoke( content, new Object[ 0 ] ) ); } return map; } } jsamp/src/test/java/org/astrogrid/samp/web/WebTestProfile.java0000664000175000017500000001104712730747754024245 0ustar sladensladenpackage org.astrogrid.samp.web; import java.io.IOException; import java.net.ConnectException; import java.net.ServerSocket; import java.net.Socket; import java.net.URL; import java.net.MalformedURLException; import java.util.HashMap; import java.util.Map; import java.util.Random; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.TestProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.httpd.HttpServer; import org.astrogrid.samp.hub.HubProfile; import org.astrogrid.samp.hub.KeyGenerator; import org.astrogrid.samp.hub.MessageRestriction; import org.astrogrid.samp.xmlrpc.SampXmlRpcClientFactory; import org.astrogrid.samp.xmlrpc.XmlRpcKit; import org.astrogrid.samp.xmlrpc.internal.InternalServer; public class WebTestProfile extends TestProfile { private final int port_; private final String path_; private final URL hubEndpoint_; private final SampXmlRpcClientFactory xClientFactory_; private final boolean urlControl_; private final MessageRestriction mrestrict_; private final String baseAppName_; private ClientAuthorizer clientAuth_; private int regSeq_; public WebTestProfile( Random random, boolean urlControl, MessageRestriction mrestrict ) throws IOException { this( random, getFreePort(), "/", XmlRpcKit.getInstance().getClientFactory(), urlControl, mrestrict, "test" ); } public WebTestProfile( Random random, int port, String path, SampXmlRpcClientFactory xClientFactory, boolean urlControl, MessageRestriction mrestrict, String baseAppName ) { super( random ); port_ = port; path_ = path; xClientFactory_ = xClientFactory; urlControl_ = urlControl; mrestrict_ = mrestrict; baseAppName_ = baseAppName; clientAuth_ = ClientAuthorizers.createFixedClientAuthorizer( true ); try { hubEndpoint_ = new URL( "http://" + SampUtils.getLocalhost() + ":" + port + path ); } catch ( MalformedURLException e ) { throw new AssertionError(); } } public void setClientAuthorizer( ClientAuthorizer clientAuth ) { clientAuth_ = clientAuth; } public HubProfile createHubProfile() throws IOException { WebHubProfile.ServerFactory sxfact = new WebHubProfile.ServerFactory(); sxfact.setPort( port_ ); sxfact.setXmlrpcPath( path_ ); sxfact.setOriginAuthorizer( OriginAuthorizers.TRUE ); sxfact.setAllowFlash( false ); sxfact.setAllowSilverlight( false ); ClientAuthorizer copyAuth = new ClientAuthorizer() { public boolean authorize( HttpServer.Request request, String appName ) { return clientAuth_.authorize( request, appName ); } }; return new WebHubProfile( sxfact, copyAuth, mrestrict_, new KeyGenerator( "wk:", 24, createRandom() ), urlControl_ ); } public boolean isHubRunning() { try { Socket sock = new Socket( hubEndpoint_.getHost(), port_ ); sock.close(); return true; } catch ( IOException e ) { return false; } } public HubConnection register() throws SampException { int regSeq; synchronized ( this ) { regSeq = ++regSeq_; } Map secMap = new HashMap(); secMap.put( "samp.name", baseAppName_ + "-" + regSeq ); try { return new WebHubConnection( xClientFactory_ .createClient( hubEndpoint_ ), secMap ); } catch ( SampException e ) { for ( Throwable ex = e; ex != null; ex = ex.getCause() ) { if ( ex instanceof ConnectException ) { return null; } } throw e; } catch ( ConnectException e ) { return null; } catch ( IOException e ) { throw new SampException( e ); } } private static int getFreePort() throws IOException { ServerSocket sock = new ServerSocket( 0 ); int port = sock.getLocalPort(); sock.close(); return port; } } jsamp/src/test/java/org/astrogrid/samp/web/WebClientTest.java0000664000175000017500000003562112730747754024067 0ustar sladensladenpackage org.astrogrid.samp.web; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.ServerSocket; import java.net.URL; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Random; import java.util.logging.Level; import java.util.logging.Logger; import junit.framework.TestCase; import org.astrogrid.samp.Message; import org.astrogrid.samp.RegInfo; import org.astrogrid.samp.Response; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.TestProfile; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.httpd.HttpServer; import org.astrogrid.samp.hub.MessageRestriction; import org.astrogrid.samp.xmlrpc.XmlRpcHubConnection; import org.astrogrid.samp.xmlrpc.internal.InternalServer; public class WebClientTest extends TestCase { public void setUp() { Logger.getLogger( "org.astrogrid.samp" ).setLevel( Level.WARNING ); Logger.getLogger( InternalServer.class.getName() ) .setLevel( Level.SEVERE ); Logger.getLogger( WebHubConnection.class.getName() ) .setLevel( Level.SEVERE ); Logger.getLogger( UrlTracker.class.getName() ) .setLevel( Level.SEVERE ); Logger.getLogger( HttpServer.class.getName() ) .setLevel( Level.SEVERE ); } // Test withdrawn, multiple cycles of allowReverseCallbacks are not // recommended. Code may come in useful for something else another // day though. public void doNottestCallbacks() throws IOException, InterruptedException { WebTestProfile profile = new WebTestProfile( new Random( 23L ), true, null ); profile.startHub(); XmlRpcHubConnection tconn = (XmlRpcHubConnection) profile.register(); XmlRpcHubConnection rconn = (XmlRpcHubConnection) profile.register(); String tid = tconn.getRegInfo().getSelfId(); String rid = rconn.getRegInfo().getSelfId(); Subscriptions subs = new Subscriptions(); String pMtype = "test.ping"; subs.addMType( pMtype ); Message pmsg = new Message( pMtype ); Receiver rcvr = new Receiver(); rconn.setCallable( rcvr ); rconn.declareSubscriptions( subs ); tconn.notify( rid, pmsg ); assertTrue( rcvr.waitForMessage( 1000 ) != null ); rconn.exec( "allowReverseCallbacks", new String[] { "0" } ); tconn.notify( rid, pmsg ); assertTrue( rcvr.waitForMessage( 1000 ) == null ); rconn.exec( "allowReverseCallbacks", new String[] { "1" } ); assertTrue( rcvr.waitForMessage( 1000 ) != null ); assertTrue( rcvr.waitForMessage( 1000 ) == null ); tconn.unregister(); rconn.unregister(); } public void testRegistration() throws IOException { WebTestProfile profile = new WebTestProfile( new Random( 23L ), true, null ); profile.startHub(); assertTrue( profile.isHubRunning() ); HubConnection conn = profile.register(); assertEquals( "samp.url-translator", WebClientProfile.URLTRANS_KEY ); RegInfo regInfo = conn.getRegInfo(); assertTrue( ((String) regInfo.get( "samp.private-key" )).length() > 0 ); profile.setClientAuthorizer( ClientAuthorizers .createFixedClientAuthorizer( false ) ); try { profile.register(); fail(); } catch ( SampException e ) { assertTrue( e.getMessage().indexOf( "denied" ) > 0 ); } profile.stopHub(); } public void testControlledUrlTranslator() throws IOException, InterruptedException { WebTestProfile profile = new WebTestProfile( new Random( 23L ), true, null ); profile.startHub(); Subscriptions subs = new Subscriptions(); String mtype = "test.url"; subs.addMType( mtype ); String ftxt = "some\ntext\nin\na\nfile\n"; HubConnection webConn = profile.register(); Receiver webReceiver = new Receiver(); webConn.setCallable( webReceiver ); webConn.declareSubscriptions( subs ); RegInfo webReg = webConn.getRegInfo(); String turl = (String) webReg.get( WebClientProfile.URLTRANS_KEY ); HubConnection dirConn = profile.registerDirect(); Receiver dirReceiver = new Receiver(); dirConn.setCallable( dirReceiver ); dirConn.declareSubscriptions( subs ); RegInfo dirReg = dirConn.getRegInfo(); File file1 = File.createTempFile( "txtfile", ".tmp" ); file1.deleteOnExit(); OutputStream fout1 = new FileOutputStream( file1 ); fout1.write( ftxt.getBytes( "utf-8" ) ); fout1.close(); URL furl1 = SampUtils.fileToUrl( file1 ); URL tfurl1 = new URL( turl + furl1.toString() ); Message url1Msg = new Message( mtype ) .addParam( "url", furl1.toString() ); // First try - blocked because it's a local URL, and no incoming // reference has been seen (UrlTracker). try { readUrl( tfurl1 ); fail( "Should have blocked access to " + tfurl1 ); } catch ( IOException e ) { } // Second try: send message out mentioning it. This will still // fail, because we're not trusted, and it will permanently block // access (if we're the first one to mention it, it's probalbly // not legitimante and it might provoke an echo somewhere). webConn.notify( dirReg.getSelfId(), url1Msg ); dirReceiver.waitForMessage( 1000 ); try { readUrl( tfurl1 ); fail( "Should have blocked access to " + tfurl1 ); } catch ( IOException e ) { } // Third try: get some trusted client to mention the URL, // but since it's been permanently blocked, we still can't access it. dirConn.notify( webReg.getSelfId(), url1Msg ); webReceiver.waitForMessage( 1000 ); try { readUrl( tfurl1 ); fail( "Should have blocked access to " + tfurl1 ); } catch ( IOException e ) { } // Now try with a different file. File file2 = File.createTempFile( "txtfile", ".tmp" ); file2.deleteOnExit(); OutputStream fout2 = new FileOutputStream( file2 ); fout2.write( ftxt.getBytes( "utf-8" ) ); fout2.close(); URL furl2 = SampUtils.fileToUrl( file2 ); URL tfurl2 = new URL( turl + furl2.toString() ); Message url2msg = new Message( mtype ) .addParam( "url", furl2.toString() ); // Get a trusted client to mention it. dirConn.notify( webReg.getSelfId(), url2msg ); webReceiver.waitForMessage( 1000 ); // Read it. Should be OK. assertEquals( ftxt, readUrl( tfurl2 ) ); // Tidy up. file1.delete(); file2.delete(); profile.stopHub(); } public void testUncontrolledUrlTranslator() throws IOException { WebTestProfile profile = new WebTestProfile( new Random( 29L ), false, null ); profile.startHub(); HubConnection webConn = profile.register(); RegInfo webReg = webConn.getRegInfo(); String turl = (String) webReg.get( WebClientProfile.URLTRANS_KEY ); File file = File.createTempFile( "txtfile", ".tmp" ); file.deleteOnExit(); OutputStream fout = new FileOutputStream( file ); String ftxt = "some\ntext\nin\na\nfile\n"; fout.write( ftxt.getBytes( "utf-8" ) ); fout.close(); URL furl = SampUtils.fileToUrl( file ); URL tfurl = new URL( turl + furl.toString() ); assertEquals( ftxt, readUrl( tfurl ) ); file.delete(); profile.stopHub(); } public void testRestrictMTypes() throws IOException, InterruptedException { assertTrue( passMessage( ListMessageRestriction.ALLOW_ALL, "test.hello" ) ); assertTrue( ! passMessage( ListMessageRestriction.DENY_ALL, "test.hello" ) ); assertTrue( passMessage( singleMessageRestriction( true, "test.hello" ), "test.hello" ) ); assertTrue( ! passMessage( singleMessageRestriction( true, "test.goodbye" ), "test.hello" ) ); assertTrue( passMessage( singleMessageRestriction( false, "test.goodbye" ), "test.hello" ) ); assertTrue( ! passMessage( singleMessageRestriction( false, "test.goodbye" ), "test.goodbye" ) ); assertTrue( passMessage( singleMessageRestriction( true, "test.*" ), "test.hello" ) ); assertTrue( ! passMessage( singleMessageRestriction( true, "test.*" ), "hello" ) ); } private MessageRestriction singleMessageRestriction( final boolean allow, String mtype ) { return new ListMessageRestriction( allow, new String[] { mtype }, true ); } private boolean passMessage( MessageRestriction mrestrict, String mtype ) throws IOException, InterruptedException { Message msg0 = new Message( mtype ); WebTestProfile profile = new WebTestProfile( new Random( 1019L ), false, mrestrict ); profile.startHub(); HubConnection tconn = profile.register(); HubConnection rconn = profile.register(); String tid = tconn.getRegInfo().getSelfId(); String rid = rconn.getRegInfo().getSelfId(); Subscriptions subs = new Subscriptions(); subs.addMType( mtype ); Receiver rcvr = new Receiver(); rconn.setCallable( rcvr ); rconn.declareSubscriptions( subs ); boolean sent; try { tconn.notify( rid, msg0 ); sent = true; } catch ( SampException e ) { sent = false; } if ( sent ) { Message msg1 = rcvr.waitForMessage( 1000 ); assertEquals( msg0, msg1 ); } tconn.unregister(); rconn.unregister(); return sent; } public void testServer() throws IOException { ServerSocket sock = new ServerSocket( 0 ); String path = "/"; WebHubProfile.ServerFactory sxfact = new WebHubProfile.ServerFactory(); sxfact.setLogType( null ); sxfact.setPort( 0 ); sxfact.setXmlrpcPath( "/" ); sxfact.setOriginAuthorizer( OriginAuthorizers.TRUE ); sxfact.setAllowFlash( true ); sxfact.setAllowSilverlight( true ); InternalServer xServer = sxfact.createSampXmlRpcServer(); HttpServer hServer = xServer.getHttpServer(); URL surl = new URL( "http://localhost:" + hServer.getSocket().getLocalPort() ); hServer.start(); String xdomain = readUrl( new URL( surl, "/crossdomain.xml" ) ); assertTrue( xdomain.indexOf( "" ) > 0 ); String accpol = readUrl( new URL( surl, "/clientaccesspolicy.xml" ) ); assertTrue( accpol.indexOf( "" ) > 0 ); hServer.stop(); } public void testMessageRestriction() { MessageRestriction allMr = ListMessageRestriction.ALLOW_ALL; MessageRestriction noneMr = ListMessageRestriction.DENY_ALL; MessageRestriction dfltMr = ListMessageRestriction.DEFAULT; MessageRestriction testMr = new ListMessageRestriction( true, new String[] { "test.*" }, true ); MessageRestriction notestMr = new ListMessageRestriction( false, new String[] { "test.*" }, true ); assertTrue( allMr.permitSend( "system.exec", new HashMap() ) ); assertTrue( ! noneMr.permitSend( "system.exec", new HashMap() ) ); assertTrue( ! dfltMr.permitSend( "system.exec", new HashMap() ) ); assertTrue( ! testMr.permitSend( "system.exec", new HashMap() ) ); assertTrue( notestMr.permitSend( "system.exec", new HashMap() ) ); assertTrue( dfltMr.permitSend( "table.load.votable", new HashMap() ) ); assertTrue( new ListMessageRestriction( true, new String[] { "do.what" }, true ) .permitSend( "do.what", new HashMap() ) ); } private static String readUrl( URL url ) throws IOException { StringBuffer ubuf = new StringBuffer(); InputStream in = url.openStream(); for ( int b; ( b = in.read() ) >= 0; ) { ubuf.append( (char) b ); } in.close(); return ubuf.toString(); } private static class Receiver implements CallableClient { private final List msgList_ = new ArrayList(); private final List responseList_ = new ArrayList(); public synchronized void receiveCall( String senderId, String msgId, Message msg ) { msgList_.add( msg ); notifyAll(); } public synchronized void receiveNotification( String senderId, Message msg ) { msgList_.add( msg ); notifyAll(); } public synchronized void receiveResponse( String responsderId, String msgTag, Response response ) { msgList_.add( response ); notifyAll(); } public synchronized Message waitForMessage( long millis ) throws InterruptedException { long end = System.currentTimeMillis() + millis; while ( System.currentTimeMillis() < end && msgList_.isEmpty() ) { wait( end - System.currentTimeMillis() ); } return msgList_.isEmpty() ? null : (Message) msgList_.get( 0 ); } public synchronized Response waitForResponse( long millis ) throws InterruptedException { long end = System.currentTimeMillis() + millis; while ( System.currentTimeMillis() < end && responseList_.isEmpty() ) { wait( end - System.currentTimeMillis() ); } return responseList_.isEmpty() ? null : (Response) responseList_.get( 0 ); } } } jsamp/src/test/java/org/astrogrid/samp/TestProfile.java0000664000175000017500000000671612730747754023041 0ustar sladensladenpackage org.astrogrid.samp; import java.io.IOException; import java.util.Random; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.hub.BasicHubService; import org.astrogrid.samp.hub.Hub; import org.astrogrid.samp.hub.HubProfile; import org.astrogrid.samp.hub.HubService; import org.astrogrid.samp.hub.MessageRestriction; import org.astrogrid.samp.hub.ProfileToken; import org.astrogrid.samp.web.WebTestProfile; import org.astrogrid.samp.xmlrpc.StandardTestProfile; import org.astrogrid.samp.xmlrpc.SampXmlRpcClientFactory; import org.astrogrid.samp.xmlrpc.SampXmlRpcServerFactory; import org.astrogrid.samp.xmlrpc.XmlRpcKit; /** * Abstract class providing both a hub and a client profile; * the client profile talks to the hub. * This can be used for isolated testing of hub/client profile pairs. * For use within a test framework implementations should normally take * care not to use the resources (well-known lockfile locations, * well-known ports etc) which are commonly used for normal SAMP operations. * * @author Mark Taylor * @since 4 Feb 2011 */ public abstract class TestProfile implements ClientProfile { private final Random random_; private final ProfileToken directToken_; private Hub hub_; private HubService service_; protected TestProfile( Random random ) { random_ = random; directToken_ = new ProfileToken() { public String getProfileName() { return ""; } public MessageRestriction getMessageRestriction() { return null; } }; } public synchronized void startHub() throws IOException { if ( hub_ != null ) { throw new IllegalStateException( "Hub not stopped " + "due to earlier test failure?" ); } service_ = new BasicHubService( createRandom() ); hub_ = new Hub( service_ ); service_.start(); hub_.startProfile( createHubProfile() ); } public synchronized void stopHub() { if ( hub_ == null ) { throw new IllegalStateException(); } hub_.shutdown(); hub_ = null; service_ = null; } public HubConnection registerDirect() throws SampException { return service_.register( directToken_ ); } public abstract HubProfile createHubProfile() throws IOException; public Random createRandom() { return new Random( random_.nextLong() ); } /** * Returns an array of TestProfiles suitable for performing tests on. */ public static TestProfile[] createTestProfiles( Random random ) throws IOException { SampXmlRpcClientFactory aClient = XmlRpcKit.APACHE.getClientFactory(); SampXmlRpcServerFactory aServ = XmlRpcKit.APACHE.getServerFactory(); SampXmlRpcClientFactory iClient = XmlRpcKit.INTERNAL.getClientFactory(); SampXmlRpcServerFactory iServ = XmlRpcKit.INTERNAL.getServerFactory(); return new TestProfile[] { new StandardTestProfile( random, aClient, aServ, iClient, iServ ), new StandardTestProfile( random, iClient, iServ, aClient, aServ ), new StandardTestProfile( random, iClient, iServ, iClient, iServ ), new WebTestProfile( random, true, null ), }; } } jsamp/src/test/java/org/astrogrid/samp/HubTest.java0000664000175000017500000000250512730747754022147 0ustar sladensladenpackage org.astrogrid.samp; import java.io.IOException; import java.util.Random; import java.util.logging.Level; import java.util.logging.Logger; import junit.framework.TestCase; import org.astrogrid.samp.test.CalcStorm; import org.astrogrid.samp.test.Calculator; import org.astrogrid.samp.test.HubTester; import org.astrogrid.samp.xmlrpc.StandardTestProfile; /** * Hub test case. * Most of the hard work is done by the hub test classes which are part of * the Java SAMP distribution itself, which know how to put a third-party * hub through its paces. * This class mainly just starts up a hub and invokes those tests. * * @author Mark Taylor * @since 29 Jul 2008 */ public class HubTest extends TestCase { protected void setUp() throws IOException { Logger.getLogger( "org.astrogrid.samp" ).setLevel( Level.SEVERE ); } public void testHubTester() throws Exception { Random random = new Random( 23 ); TestProfile[] profiles = TestProfile.createTestProfiles( random ); for ( int i = 0; i < profiles.length; i++ ) { TestProfile profile = profiles[ i ]; profile.startHub(); new HubTester( profile ).run(); new CalcStorm( profile, random, 8, 8, Calculator.RANDOM_MODE ) .run(); profile.stopHub(); } } } jsamp/src/test/java/org/astrogrid/samp/bridge/0000775000175000017500000000000012730747754021160 5ustar sladensladenjsamp/src/test/java/org/astrogrid/samp/bridge/BridgeTest.java0000664000175000017500000002554512730747754024072 0ustar sladensladenpackage org.astrogrid.samp.bridge; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.InetAddress; import java.net.URL; import java.util.HashSet; import java.util.HashMap; import java.util.Iterator; import java.util.Map; import java.util.Random; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import junit.framework.TestCase; import org.astrogrid.samp.Client; import org.astrogrid.samp.Message; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.Response; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.TestProfile; import org.astrogrid.samp.bridge.Bridge; import org.astrogrid.samp.client.AbstractMessageHandler; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.HubConnector; import org.astrogrid.samp.client.ResultHandler; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.httpd.UtilServer; import org.astrogrid.samp.xmlrpc.StandardTestProfile; import org.astrogrid.samp.xmlrpc.XmlRpcKit; public class BridgeTest extends TestCase { private final static String ECHO_MTYPE = "test.echo"; public void setUp() { Logger.getLogger( "org.astrogrid.samp" ).setLevel( Level.WARNING ); Logger.getLogger( "org.astrogrid.samp.xmlrpc.internal" ) .setLevel( Level.SEVERE ); Logger.getLogger( "org.astrogrid.samp.xmlrpc.StandardHubProfile" ) .setLevel( Level.SEVERE ); } public void testBridge() throws IOException, SampException, InterruptedException { bridgeTest( 2 ); bridgeTest( 4 ); } public void testExport() throws IOException { File file = File.createTempFile( "test", ".txt" ); file.deleteOnExit(); OutputStream out = new FileOutputStream( file ); byte[] text = new byte[] { (byte) 'g', (byte) 'u', (byte) 'r', }; out.write( text ); out.close(); String hostname = InetAddress.getLocalHost().getCanonicalHostName(); UrlExporter exporter1 = new UrlExporter( hostname, true ); UrlExporter exporter2 = new UrlExporter( hostname, false ); assertEquals( "http://" + hostname + "/gur", exporter1.exportString( "http://127.0.0.1/gur" ) ); String rawUrl = file.toURL().toString() // fix broken file.toURL() .replaceFirst( "^file:/\\b", "file://localhost/" ); URL expUrl1 = new URL( exporter1.exportString( rawUrl ) ); URL expUrl2 = new URL( exporter2.exportString( rawUrl ) ); assertEquals( "http", expUrl1.getProtocol() ); assertEquals( "file", expUrl2.getProtocol() ); assertEquals( rawUrl, expUrl2.toString() ); InputStream expIn1 = expUrl1.openStream(); InputStream expIn2 = expUrl2.openStream(); for ( int i = 0; i < text.length; i++ ) { assertEquals( expIn1.read(), text[ i ] ); assertEquals( expIn2.read(), text[ i ] ); } expIn1.close(); expIn2.close(); file.delete(); } private void bridgeTest( int nhub ) throws IOException, SampException, InterruptedException { Random random = new Random( 232323 ); XmlRpcKit kit = XmlRpcKit.getInstance(); // Set up N hubs with one client each. // Clients all have similar (not identical) metadata, and all // subscribe to MTYPE_ECHO. TestProfile[] profiles = new TestProfile[ nhub ]; HubConnector[] connectors = new HubConnector[ nhub ]; HubConnection[] connections = new HubConnection[ nhub ]; for ( int ih = 0; ih < nhub; ih++ ) { final int ih1 = ih + 1; final char iha = (char) ( ih + 'A' ); TestProfile profile = new StandardTestProfile( random, kit ) { public String toString() { return "P." + iha; } }; profiles[ ih ] = profile; profile.startHub(); HubConnector connector = new HubConnector( profile ); connectors[ ih ] = connector; Metadata meta = new Metadata(); meta.setName( "Client " + ih1 ); meta.put( "client.id", "C" + ih1 ); connector.declareMetadata( meta ); connector.addMessageHandler( new EchoMessageHandler() ); connector.declareSubscriptions( connector.computeSubscriptions() ); connections[ ih ] = connector.getConnection(); assertNotNull( connections[ ih ] ); } // Wait for all metadata and subscriptions. for ( int ih = 0; ih < nhub; ih++ ) { Map clientMap = connectors[ ih ].getClientMap(); synchronized ( clientMap ) { while ( ! hasAtts( clientMap, true, true ) ) { clientMap.wait(); } } } // Check no (other) subscribed clients, one other client (the hub). for ( int ih = 0; ih < nhub; ih++ ) { HubConnection connection = connections[ ih ]; assertEquals( 0, connection.getSubscribedClients( ECHO_MTYPE ) .size() ); assertEquals( 1, connection.getRegisteredClients().length ); } // Start bridge. Bridge bridge = new Bridge( profiles ); bridge.start(); // Wait for all metadata and subscriptions from bridge start. for ( int ih = 0; ih < nhub; ih++ ) { Map clientMap = connectors[ ih ].getClientMap(); synchronized ( clientMap ) { while ( ! hasAtts( clientMap, true, true ) ) { clientMap.wait(); } } } // Check that all proxy and bridge clients we expect to have appeared // are present and correct. for ( int ih = 0; ih < nhub; ih++ ) { HubConnection connection = connections[ ih ]; Map clientMap = connectors[ ih ].getClientMap(); assertEquals( nhub - 1, connection.getSubscribedClients( ECHO_MTYPE ) .size() ); assertEquals( ( nhub - 1 ) + 2, connection.getRegisteredClients().length ); assertEquals( ( nhub - 1 ), getProxyCount( clientMap ) ); assertTrue( hasBridge( clientMap ) ); Set srcSet = new HashSet(); for ( Iterator it = clientMap.values().iterator(); it.hasNext(); ) { Client client = (Client) it.next(); srcSet.add( client.getMetadata().get( "bridge.proxy.source" ) ); } assertTrue( srcSet.contains( null ) ); assertEquals( nhub, srcSet.size() ); } // Send one echo broadcast from each client. This should get // anwered over the bridge from all the remote clients. EchoResultHandler[] resultHandlers = new EchoResultHandler[ nhub ]; for ( int ih = 0; ih < nhub; ih++ ) { Map echoContent = new HashMap(); echoContent.put( "text", "gur" ); echoContent.put( "index", "ix" + ( ih + 1 ) ); Message msg = new Message( ECHO_MTYPE, echoContent ); resultHandlers[ ih ] = new EchoResultHandler( echoContent ); connectors[ ih ].callAll( msg, resultHandlers[ ih ], 5 ); } // Check the right number of results are in. for ( int ih = 0; ih < nhub; ih++ ) { EchoResultHandler handler = resultHandlers[ ih ]; synchronized ( handler ) { while ( ! handler.isDone_ ) { handler.wait(); } } assertEquals( nhub - 1, handler.okResults_ ); } // Stop the bridge. bridge.stop(); // Check all clients have gone away. for ( int ih = 0; ih < nhub; ih++ ) { Map clientMap = connectors[ ih ].getClientMap(); synchronized ( clientMap ) { while ( getProxyCount( clientMap ) > 0 || hasBridge( clientMap ) ) { clientMap.wait(); } } assertEquals( 2, clientMap.size() ); } } private boolean hasAtts( Map clientMap, boolean wantMeta, boolean wantSubs ) { for ( Iterator it = clientMap.values().iterator(); it.hasNext(); ) { Client client = (Client) it.next(); if ( wantMeta ) { Metadata meta = client.getMetadata(); if ( meta == null || meta.isEmpty() ) { return false; } } if ( wantSubs ) { Subscriptions subs = client.getSubscriptions(); if ( subs == null || subs.isEmpty() ) { return false; } } } return true; } private int getProxyCount( Map clientMap ) { int nProxy = 0; for ( Iterator it = clientMap.values().iterator(); it.hasNext(); ) { Client client = (Client) it.next(); if ( client.getMetadata().containsKey( "bridge.proxy.source" ) ) { nProxy++; } } return nProxy; } private boolean hasBridge( Map clientMap ) { for ( Iterator it = clientMap.values().iterator(); it.hasNext(); ) { Client client = (Client) it.next(); if ( client.getMetadata().getName().equalsIgnoreCase( "bridge" ) ) { return true; } } return false; } private static class EchoMessageHandler extends AbstractMessageHandler { EchoMessageHandler() { super( ECHO_MTYPE ); } public Map processCall( HubConnection conn, String senderId, Message msg ) { return msg.getParams(); } } private static class EchoResultHandler implements ResultHandler { private final Map expectedResult_; int okResults_; boolean isDone_; EchoResultHandler( Map expectedResult ) { expectedResult_ = new HashMap( expectedResult ); } public synchronized void result( Client responder, Response response ) { if ( response.isOK() ) { Map result = response.getResult(); if ( expectedResult_.equals( response.getResult() ) ) { okResults_++; } else { System.err.println( "echo mismatch" ); } } else { System.err.println( "echo error response" ); } } public synchronized void done() { isDone_ = true; notifyAll(); } } } jsamp/src/test/java/org/astrogrid/samp/SampUtilsTest.java0000664000175000017500000001347712730747754023364 0ustar sladensladenpackage org.astrogrid.samp; import java.net.InetAddress; import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Properties; import java.util.Random; import junit.framework.TestCase; public class SampUtilsTest extends TestCase { private final Random random_ = new Random( 232323L ); public void testCodecs() { for ( int k = 0; k < 100; k++ ) { boolean b = random_.nextBoolean(); int i = random_.nextInt(); long l = random_.nextLong(); double f = random_.nextDouble(); assertEquals( b, SampUtils .decodeBoolean( SampUtils.encodeBoolean( b ) ) ); assertEquals( i, SampUtils .decodeInt( SampUtils.encodeInt( i ) ) ); assertEquals( l, SampUtils .decodeLong( SampUtils.encodeLong( l ) ) ); assertEquals( f, SampUtils .decodeFloat( SampUtils.encodeFloat( f ) ), 0.0 ); } } public void testUriCodecs() { assertEquals( "abc", SampUtils.uriEncode( "abc" ) ); assertEquals( "abc", SampUtils.uriDecode( "abc" ) ); assertEquals( "a b+c+d", SampUtils.uriDecode( "a%20b+c%2Bd" ) ); String[] txts = { "abc", "http://localhost/file", "a b c * / % ++--%1", }; for ( int i = 0; i < txts.length; i++ ) { String txt = txts[ i ]; assertEquals( txt, SampUtils.uriDecode( SampUtils.uriEncode( txt ) ) ); assertEquals( txt, SampUtils.uriDecode( SampUtils.uriDecode( SampUtils.uriEncode( SampUtils.uriEncode( txt ) ) ) ) ); } } public void testChars() { assertTrue( SampUtils.isStringChar( 'x' ) ); assertTrue( SampUtils.isStringChar( 'X' ) ); assertTrue( SampUtils.isStringChar( (char) 0x09 ) ); assertTrue( SampUtils.isStringChar( (char) 0x0a ) ); assertTrue( SampUtils.isStringChar( (char) 0x0d ) ); assertTrue( ! SampUtils.isStringChar( (char) 0 ) ); assertTrue( ! SampUtils.isStringChar( (char) 0x0e ) ); assertTrue( ! SampUtils.isStringChar( '\b' ) ); assertTrue( ! SampUtils.isStringChar( (char) 0xffef ) ); } public void testChecks() { goodObject( "a" ); goodObject( "abc\t\r\n" ); badObject( "abc\b" ); badObject( new Integer( 23 ) ); badObject( new Double( Math.E ) ); badObject( null ); badObject( Arrays.asList( new Object[] { "AA", null, } ) ); List abclist = Arrays.asList( new Object[] { "a", "bb", "ccc" } ); goodObject( abclist ); goodObject( new ArrayList() ); goodObject( new HashMap() ); Map xmap = new HashMap(); xmap.put( "xx", abclist ); goodObject( xmap ); xmap.put( abclist, "xx" ); badObject( xmap ); } public void testHostname() throws UnknownHostException { String hprop = SampUtils.LOCALHOST_PROP; Properties sysprops = System.getProperties(); String prop = sysprops.getProperty( hprop ); sysprops.remove( hprop ); assertEquals( "127.0.0.1", SampUtils.getLocalhost() ); sysprops.setProperty( hprop, "host-with-the-most" ); assertEquals( "host-with-the-most", SampUtils.getLocalhost() ); sysprops.setProperty( hprop, "[hostname]" ); assertEquals( InetAddress.getLocalHost().getCanonicalHostName(), SampUtils.getLocalhost() ); if ( prop == null ) { sysprops.remove( hprop ); } else { sysprops.setProperty( hprop, prop ); } } public void testJson() { Map m = new LinkedHashMap(); m.put( "one", "1" ); m.put( "two", "2" ); m.put( "list", Arrays.asList( new String[] { "A", "B", "C" } ) ); String js = "{\"one\":\"1\",\"two\":\"2\",\"list\":[\"A\",\"B\",\"C\"]}"; assertEquals( nows( js ), nows( SampUtils.toJson( m, false ) ) ); assertEquals( nows( js ), nows( SampUtils.toJson( m, true ) ) ); assertEquals( nows( js ), nows( SampUtils.toJson( SampUtils.fromJson( js ), true ) ) ); assertTrue( SampUtils.toJson( m, false ).indexOf( '\n' ) < 0 ); assertTrue( SampUtils.toJson( m, true ).indexOf( '\n' ) >= 0 ); assertEquals( new HashMap(), SampUtils.fromJson("{}") ); assertEquals( new HashMap(), SampUtils.fromJson("{ }") ); assertEquals( new ArrayList(), SampUtils.fromJson("[]") ); assertEquals( new ArrayList(), SampUtils.fromJson("[ ]") ); } private static String nows( String txt ) { return txt.replaceAll( "\\s+", "" ); } private void goodObject( Object obj ) { SampUtils.checkObject( obj ); SampUtils.checkList( Arrays.asList( new Object[] { "a", obj, "c" } ) ); Map map = new HashMap(); map.put( "k1", "v1" ); map.put( "key", obj ); map.put( "k2", "v2" ); SampUtils.checkMap( map ); } private void badObject( Object obj ) { try { SampUtils.checkObject( obj ); fail( "Object should be bad: " + obj ); } catch ( DataException e ) { return; } try { SampUtils.checkList( Arrays.asList( new Object[] { "a", obj, "c" } ) ); fail( "Object should be bad: " + obj ); } catch ( DataException e ) { return; } } } jsamp/src/test/java/org/astrogrid/samp/xmlrpc/0000775000175000017500000000000012730747754021231 5ustar sladensladenjsamp/src/test/java/org/astrogrid/samp/xmlrpc/StandardTestProfile.java0000664000175000017500000000720712730747754026023 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.io.File; import java.io.IOException; import java.util.Random; import org.astrogrid.samp.TestProfile; import org.astrogrid.samp.DataException; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.hub.HubProfile; /** * Test Profile implementation based on the Standard Profile. * It puts the lockfile in a non-standard place though, so it can * run without interfering with any Standard Profile hub that * happens to be running at the same time. * * @author Mark Taylor * @since 4 Feb 2011 */ public class StandardTestProfile extends TestProfile { private final Random random_; private final File lockFile_; private final SampXmlRpcClientFactory hubClientFactory_; private final SampXmlRpcServerFactory hubServerFactory_; private final SampXmlRpcClientFactory clientClientFactory_; private final SampXmlRpcServerFactory clientServerFactory_; private static StandardTestProfile[] testProfiles_; public StandardTestProfile( Random random, XmlRpcKit xmlrpc ) { this( random, xmlrpc.getClientFactory(), xmlrpc.getServerFactory(), xmlrpc.getClientFactory(), xmlrpc.getServerFactory() ); } public StandardTestProfile( Random random, SampXmlRpcClientFactory hubClientFactory, SampXmlRpcServerFactory hubServerFactory, SampXmlRpcClientFactory clientClientFactory, SampXmlRpcServerFactory clientServerFactory ) { super( random ); random_ = random; hubClientFactory_ = hubClientFactory; hubServerFactory_ = hubServerFactory; clientClientFactory_ = clientClientFactory; clientServerFactory_ = clientServerFactory; File dir = new File( System.getProperty( "user.dir", "." ) ); try { lockFile_ = File.createTempFile( "samp", ".lock", dir ); lockFile_.delete(); lockFile_.deleteOnExit(); } catch ( IOException e ) { throw new RuntimeException( e ); } } public HubProfile createHubProfile() { return new StandardHubProfile( hubClientFactory_, hubServerFactory_, lockFile_, Long.toHexString( random_.nextLong() ) ); } public boolean isHubRunning() { try { return LockInfo.readLockFile( SampUtils.fileToUrl( lockFile_ ) ) != null; } catch ( IOException e ) { return false; } } public HubConnection register() throws SampException { LockInfo lockInfo; try { lockInfo = LockInfo.readLockFile( SampUtils.fileToUrl( lockFile_ ) ); if ( lockInfo == null ) { return null; } else { try { lockInfo.check(); } catch ( DataException e ) { return null; } SampXmlRpcClient xClient = clientClientFactory_.createClient( lockInfo .getXmlrpcUrl() ); return new StandardHubConnection( xClient, clientServerFactory_, lockInfo.getSecret() ); } } catch ( IOException e ) { throw new SampException( e ); } } } jsamp/src/test/java/org/astrogrid/samp/xmlrpc/StandardHubProfileTest.java0000664000175000017500000000553012730747754026457 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.io.File; import java.io.IOException; import java.net.URL; import java.util.Random; import java.util.logging.Level; import java.util.logging.Logger; import junit.framework.TestCase; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.hub.BasicHubService; import org.astrogrid.samp.hub.HubService; import org.astrogrid.samp.hub.MessageRestriction; import org.astrogrid.samp.hub.ProfileToken; public class StandardHubProfileTest extends TestCase { private static final ProfileToken TEST_PROFILE = new ProfileToken() { public String getProfileName() { return "StandardTest"; } public MessageRestriction getMessageRestriction() { return null; } }; public StandardHubProfileTest() { Logger.getLogger( "org.astrogrid.samp" ).setLevel( Level.WARNING ); Logger.getLogger( org.astrogrid.samp.httpd.HttpServer.class.getName() ) .setLevel( Level.SEVERE ); Logger.getLogger( StandardHubProfile.class.getName() ) .setLevel( Level.SEVERE ); } public void testRunHub() throws IOException { File tmpfile = File.createTempFile( "tmp", ".samp" ); assertTrue( tmpfile.delete() ); runHub( tmpfile ); runHub( null ); } private void runHub( File lockfile ) throws IOException { final String secret = "it's-a-secret"; XmlRpcKit xmlrpc = XmlRpcKit.INTERNAL; StandardHubProfile hubProf = new StandardHubProfile( xmlrpc.getClientFactory(), xmlrpc.getServerFactory(), lockfile, secret ); if ( lockfile != null ) { assertTrue( ! lockfile.exists() ); } final HubService hubService = new BasicHubService( new Random( 199099L ) ); hubProf.start( new ClientProfile() { public HubConnection register() throws SampException { return hubService.register( TEST_PROFILE ); } public boolean isHubRunning() { return hubService.isHubRunning(); } } ); if ( lockfile != null ) { assertEquals( secret, LockInfo.readLockFile( SampUtils .fileToUrl( lockfile ) ) .getSecret() ); } URL lockurl = hubProf.publishLockfile(); assertEquals( secret, LockInfo.readLockFile( lockurl ).getSecret() ); hubProf.stop(); if ( lockfile != null ) { assertTrue( ! lockfile.exists() ); } assertNull( LockInfo.readLockFile( lockurl ) ); } } jsamp/src/test/java/org/astrogrid/samp/xmlrpc/XmlRpcTest.java0000664000175000017500000000121212730747754024135 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import junit.framework.TestCase; public class XmlRpcTest extends TestCase { public void testImplementations() { assertTrue( XmlRpcKit.APACHE.isAvailable() ); assertTrue( XmlRpcKit.INTERNAL.isAvailable() ); XmlRpcKit[] impls = XmlRpcKit.KNOWN_IMPLS; for ( int i = 0; i < impls.length; i++ ) { assertTrue( impls[ i ].isAvailable() ); } assertEquals( XmlRpcKit.APACHE, XmlRpcKit.getInstanceByName( "apache" ) ); assertEquals( XmlRpcKit.INTERNAL, XmlRpcKit.getInstanceByName( "internal" ) ); } } jsamp/src/test/java/org/astrogrid/samp/httpd/0000775000175000017500000000000012730747754021047 5ustar sladensladenjsamp/src/test/java/org/astrogrid/samp/httpd/ServerTest.java0000664000175000017500000000646712730747754024035 0ustar sladensladenpackage org.astrogrid.samp.httpd; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.net.URL; import java.net.SocketAddress; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import junit.framework.TestCase; public class ServerTest extends TestCase { public void testMultiMapperHandler() throws IOException { HttpServer server = new HttpServer(); MultiURLMapperHandler mHandler = new MultiURLMapperHandler( server, "gur" ); server.addHandler( mHandler ); File f1 = new File( "a/b.fits" ); URL fileUrl1 = f1.toURL(); URL url1 = mHandler.addLocalUrl( f1.toURL() ); assertEquals( "http", url1.getProtocol() ); assertTrue( url1.toString().endsWith( "b.fits" ) ); URL url2 = mHandler .addLocalUrl( ServerTest.class.getResource( "ServerTest.class" ) ); assertEquals( "http", url2.getProtocol() ); assertTrue( url2.toString().endsWith( ".class" ) ); HttpServer.Request req2 = new HttpServer.Request( "GET", url2.getPath(), new HashMap(), new SocketAddress() {}, null ); HttpServer.Response resp2 = server.serve( req2 ); assertEquals( 200, resp2.getStatusCode() ); ByteArrayOutputStream bos = new ByteArrayOutputStream(); resp2.writeBody( bos ); byte[] bosbuf = bos.toByteArray(); assertEquals( (byte) 0xca, bosbuf[ 0 ] ); assertEquals( (byte) 0xfe, bosbuf[ 1 ] ); assertEquals( (byte) 0xba, bosbuf[ 2 ] ); assertEquals( (byte) 0xbe, bosbuf[ 3 ] ); mHandler.removeServerUrl( url2 ); resp2 = server.serve( req2 ); assertEquals( 404, resp2.getStatusCode() ); assertNull( fileUrl1.getRef() ); String frag = "frag"; URL fragUrl1 = new URL( fileUrl1.toString() + "#" + frag ); assertEquals( frag, fragUrl1.getRef() ); assertEquals( frag, mHandler.addLocalUrl( fragUrl1 ).getRef() ); } public void testHeaderMap() { HttpServer.HttpHeaderMap hdrMap = new HttpServer.HttpHeaderMap(); hdrMap.addHeader( "a", "AA" ); hdrMap.addHeader( "b", "BB" ); hdrMap.addHeader( "c", "CC" ); assertEquals( 3, hdrMap.size() ); assertEquals( Arrays.asList( new String[] { "a", "b", "c" } ), new ArrayList( hdrMap.keySet() ) ); assertEquals( "CC", hdrMap.get( "c" ) ); hdrMap.addHeader( "c", "DD" ); assertEquals( Arrays.asList( new String[] { "a", "b", "c" } ), new ArrayList( hdrMap.keySet() ) ); assertEquals( "CC, DD", hdrMap.get( "c" ) ); hdrMap.addHeader( "C", "EE" ); assertEquals( Arrays.asList( new String[] { "a", "b", "c" } ), new ArrayList( hdrMap.keySet() ) ); assertEquals( "CC, DD, EE", hdrMap.get( "c" ) ); assertEquals( "AA", HttpServer.getHeader( hdrMap, "a" ) ); assertEquals( "AA", HttpServer.getHeader( hdrMap, "A" ) ); assertNull( HttpServer.getHeader( hdrMap, "x" ) ); assertNull( HttpServer.getHeader( hdrMap, null ) ); assertEquals( "CC, DD, EE", HttpServer.getHeader( hdrMap, "c" ) ); assertEquals( "CC, DD, EE", HttpServer.getHeader( hdrMap, "C" ) ); } } jsamp/src/docs/0000775000175000017500000000000012730747754013227 5ustar sladensladenjsamp/src/docs/packagelists/0000775000175000017500000000000012730747754015701 5ustar sladensladenjsamp/src/docs/packagelists/j2se/0000775000175000017500000000000012730747754016544 5ustar sladensladenjsamp/src/docs/packagelists/j2se/package-list0000664000175000017500000000520712730747754021037 0ustar sladensladenjava.applet java.awt java.awt.color java.awt.datatransfer java.awt.dnd java.awt.event java.awt.font java.awt.geom java.awt.im java.awt.im.spi java.awt.image java.awt.image.renderable java.awt.print java.beans java.beans.beancontext java.io java.lang java.lang.ref java.lang.reflect java.math java.net java.nio java.nio.channels java.nio.channels.spi java.nio.charset java.nio.charset.spi java.rmi java.rmi.activation java.rmi.dgc java.rmi.registry java.rmi.server java.security java.security.acl java.security.cert java.security.interfaces java.security.spec java.sql java.text java.util java.util.jar java.util.logging java.util.prefs java.util.regex java.util.zip javax.accessibility javax.crypto javax.crypto.interfaces javax.crypto.spec javax.imageio javax.imageio.event javax.imageio.metadata javax.imageio.plugins.jpeg javax.imageio.spi javax.imageio.stream javax.naming javax.naming.directory javax.naming.event javax.naming.ldap javax.naming.spi javax.net javax.net.ssl javax.print javax.print.attribute javax.print.attribute.standard javax.print.event javax.rmi javax.rmi.CORBA javax.security.auth javax.security.auth.callback javax.security.auth.kerberos javax.security.auth.login javax.security.auth.spi javax.security.auth.x500 javax.security.cert javax.sound.midi javax.sound.midi.spi javax.sound.sampled javax.sound.sampled.spi javax.sql javax.swing javax.swing.border javax.swing.colorchooser javax.swing.event javax.swing.filechooser javax.swing.plaf javax.swing.plaf.basic javax.swing.plaf.metal javax.swing.plaf.multi javax.swing.table javax.swing.text javax.swing.text.html javax.swing.text.html.parser javax.swing.text.rtf javax.swing.tree javax.swing.undo javax.transaction javax.transaction.xa javax.xml.parsers javax.xml.transform javax.xml.transform.dom javax.xml.transform.sax javax.xml.transform.stream org.ietf.jgss org.omg.CORBA org.omg.CORBA.DynAnyPackage org.omg.CORBA.ORBPackage org.omg.CORBA.TypeCodePackage org.omg.CORBA.portable org.omg.CORBA_2_3 org.omg.CORBA_2_3.portable org.omg.CosNaming org.omg.CosNaming.NamingContextExtPackage org.omg.CosNaming.NamingContextPackage org.omg.Dynamic org.omg.DynamicAny org.omg.DynamicAny.DynAnyFactoryPackage org.omg.DynamicAny.DynAnyPackage org.omg.IOP org.omg.IOP.CodecFactoryPackage org.omg.IOP.CodecPackage org.omg.Messaging org.omg.PortableInterceptor org.omg.PortableInterceptor.ORBInitInfoPackage org.omg.PortableServer org.omg.PortableServer.CurrentPackage org.omg.PortableServer.POAManagerPackage org.omg.PortableServer.POAPackage org.omg.PortableServer.ServantLocatorPackage org.omg.PortableServer.portable org.omg.SendingContext org.omg.stub.java.rmi org.w3c.dom org.xml.sax org.xml.sax.ext org.xml.sax.helpers jsamp/src/docs/packagelists/xmlrpc/0000775000175000017500000000000012730747754017206 5ustar sladensladenjsamp/src/docs/packagelists/xmlrpc/package-list0000664000175000017500000000017312730747754021476 0ustar sladensladenorg.apache.xmlrpc org.apache.xmlrpc.applet org.apache.xmlrpc.secure org.apache.xmlrpc.secure.sunssl org.apache.xmlrpc.util jsamp/src/java/0000775000175000017500000000000012730747754013220 5ustar sladensladenjsamp/src/java/org/0000775000175000017500000000000012730747754014007 5ustar sladensladenjsamp/src/java/org/astrogrid/0000775000175000017500000000000012730747754016005 5ustar sladensladenjsamp/src/java/org/astrogrid/samp/0000775000175000017500000000000012730747754016745 5ustar sladensladenjsamp/src/java/org/astrogrid/samp/client/0000775000175000017500000000000012730747754020223 5ustar sladensladenjsamp/src/java/org/astrogrid/samp/client/ResponseHandler.java0000664000175000017500000000232112730747754024160 0ustar sladensladenpackage org.astrogrid.samp.client; import org.astrogrid.samp.Response; /** * Interface for a client which wishes to receive responses to message it * has sent asynchrnonously using call or callAll. * * @author Mark Taylor * @since 16 Jul 2008 */ public interface ResponseHandler { /** * Indicates whether this handler will process the response with a * given message tag. * * @param msgTag tag with which earlier call was labelled * @return true iff this handler wants to process the response labelled * with msgTag */ boolean ownsTag( String msgTag ); /** * Processes a response to an earlier message. * Will only be called for msgTag values which return * true from {@link #ownsTag}. * * @param connection hub connection * @param responderId client id of client sending response * @param msgTag message tag from previous call * @param response response object */ void receiveResponse( HubConnection connection, String responderId, String msgTag, Response response ) throws Exception; } jsamp/src/java/org/astrogrid/samp/client/MessageHandler.java0000664000175000017500000000321612730747754023752 0ustar sladensladenpackage org.astrogrid.samp.client; import java.util.Map; import org.astrogrid.samp.Message; /** * Interface for a client which wishes to receive messages. * In most cases it is more convenient to subclass the abstract class * {@link AbstractMessageHandler} than to implement this interface directly. * * @author Mark Taylor * @since 16 Jul 2008 */ public interface MessageHandler { /** * Returns a Subscriptions map corresponding to the messages * handled by this object. * Only messages with MTypes which match the keys of this map will * be passed to this object. * * @return {@link org.astrogrid.samp.Subscriptions}-like map */ Map getSubscriptions(); /** * Processes a message which does not require a response. * * @param connection hub connection * @param senderId public ID of client which sent the message * @param message message */ void receiveNotification( HubConnection connection, String senderId, Message message ) throws Exception; /** * Processes a message which does require a response. * Implementations should make sure that a subsequent call to * connection.reply() is made using the * supplied msgId. * * @param connection hub connection * @param senderId public ID of client which sent the message * @param msgId message ID * @param message message */ void receiveCall( HubConnection connection, String senderId, String msgId, Message message ) throws Exception; } jsamp/src/java/org/astrogrid/samp/client/ClientTracker.java0000664000175000017500000004035312730747754023625 0ustar sladensladenpackage org.astrogrid.samp.client; import java.util.ArrayList; import java.util.Collection; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Timer; import java.util.TimerTask; import java.util.logging.Logger; import org.astrogrid.samp.Client; import org.astrogrid.samp.Message; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.Subscriptions; /** * Message handler which watches hub event messages to keep track of * what clients are currently registered and what their attributes are * on behalf of the hub. * The results are stored in an externally supplied {@link TrackedClientSet} * object. This class tries its best to handle complications arising * from the fact that calls concerning a client may arrive out of order * (for instance declareMetadata before registration or after unregistration). * * @author Mark Taylor * @author Laurent Bourges * @since 16 Jul 2008 */ class ClientTracker extends AbstractMessageHandler { private final TrackedClientSet clientSet_; private final Map clientMap_; private final OperationQueue opQueue_; private final static Logger logger_ = Logger.getLogger( ClientTracker.class.getName() ); private static final int QUEUE_TIME = 10000; private static final String REGISTER_MTYPE; private static final String UNREGISTER_MTYPE; private static final String METADATA_MTYPE; private static final String SUBSCRIPTIONS_MTYPE; private static final String[] TRACKED_MTYPES = new String[] { REGISTER_MTYPE = "samp.hub.event.register", UNREGISTER_MTYPE = "samp.hub.event.unregister", METADATA_MTYPE = "samp.hub.event.metadata", SUBSCRIPTIONS_MTYPE = "samp.hub.event.subscriptions", }; /** * Constructor. * * @param clientSet object used to record registered clients and their * attributes */ public ClientTracker( TrackedClientSet clientSet ) { super( TRACKED_MTYPES ); clientSet_ = clientSet; clientMap_ = clientSet.getClientMap(); opQueue_ = new OperationQueue(); } /** * Removes all clients from the list. */ public void clear() { try { initialise( null ); } catch ( SampException e ) { assert false; } } /** * Initialises this tracker from a hub connection. * It is interrogated to find the current list of registered clients * and their attributes. * * @param connection hub connection; may be null for no connection */ public void initialise( HubConnection connection ) throws SampException { String[] clientIds; // If connection is null, there are no registered clients. if ( connection == null ) { clientIds = new String[ 0 ]; } // If connection is live, get the list of other registered clients, // and don't forget to add an entry for self, which // getRegisteredClients() excludes. else { String[] otherIds = connection.getRegisteredClients(); clientIds = new String[ otherIds.length + 1 ]; System.arraycopy( otherIds, 0, clientIds, 0, otherIds.length ); clientIds[ otherIds.length ] = connection.getRegInfo().getSelfId(); } // Prepare an array of client objects, populating their characteristics // by interrogating the connection. int nc = clientIds.length; TrackedClient[] clients = new TrackedClient[ nc ]; for ( int ic = 0; ic < nc; ic++ ) { String id = clientIds[ ic ]; TrackedClient client = new TrackedClient( id ); client.setMetadata( connection.getMetadata( id ) ); client.setSubscriptions( connection.getSubscriptions( id ) ); clients[ ic ] = client; } // Populate the client set. Discard any queued operations first. // This doesn't guarantee that we've got the most up to date // information ... but in absence of guaranteed delivery order for // messages that's more or less impossible. synchronized ( opQueue_ ) { ClientOperation[] pendingOps = opQueue_.getOperations(); opQueue_.clear(); clientSet_.setClients( clients ); } } public Map processCall( HubConnection connection, String senderId, Message message ) { String mtype = message.getMType(); if ( ! senderId.equals( connection.getRegInfo().getHubId() ) ) { logger_.warning( "Hub admin message " + mtype + " received from " + "non-hub client. Acting on it anyhow" ); } String id = (String) message.getParams().get( "id" ); if ( id == null ) { throw new IllegalArgumentException( "id parameter missing in " + mtype ); } String selfId = connection.getRegInfo().getSelfId(); if ( REGISTER_MTYPE.equals( mtype ) ) { TrackedClient client = new TrackedClient( id ); opQueue_.apply( client ); clientSet_.addClient( client ); } else if ( UNREGISTER_MTYPE.equals( mtype ) ) { performClientOperation( new ClientOperation( id, mtype ) { public void perform( TrackedClient client ) { opQueue_.discard( client ); clientSet_.removeClient( client ); } }, connection ); } else if ( METADATA_MTYPE.equals( mtype ) ) { final Map meta = (Map) message.getParams().get( "metadata" ); performClientOperation( new ClientOperation( id, mtype ) { public void perform( TrackedClient client ) { client.setMetadata( meta ); clientSet_.updateClient( client, true, false ); } }, connection ); } else if ( SUBSCRIPTIONS_MTYPE.equals( mtype ) ) { final Map subs = (Map) message.getParams().get( "subscriptions" ); performClientOperation( new ClientOperation( id, mtype ) { public void perform( TrackedClient client ) { client.setSubscriptions( subs ); clientSet_.updateClient( client, false, true ); } }, connection ); } else { throw new IllegalArgumentException( "Shouldn't have received MType" + mtype ); } return null; } /** * Performs an operation on a ClientOperation object. * * @param op client operation * @param connection hub connection */ private void performClientOperation( ClientOperation op, HubConnection connection ) { String id = op.getId(); // If the client is currently part of this tracker's data model, // we can peform the operation directly. TrackedClient client = (TrackedClient) clientMap_.get( id ); if ( client != null ) { op.perform( client ); } // If it's not, but it applies to this client itself, it's just // because we haven't added ourself to the client list yet. // Queue it. else if ( id.equals( connection.getRegInfo().getSelfId() ) ) { opQueue_.add( op ); } // Otherwise, the client is not yet known. This is most likely // because, in absence of any guarantee about message delivery order // within SAMP, a message which was sent between its registration // and its unregistration might still arrive either before its // registration event has arrived or after its unregistration event // has arrived. In the hope that it is the former, we hang on to // this operation so that it can be peformed at some future date // when we actually have a client object we can apply it to. else { // If it's for this client, this is just because it hasn't added // itself to the client list yet. Should get resolved very soon. if ( id.equals( connection.getRegInfo().getSelfId() ) ) { logger_.info( "Message " + op.getMType() + " arrived for self" + " - holding till later" ); } // Otherwise less certain, but we still hope. else { logger_.info( "No known client " + id + " for message " + op.getMType() + " - holding till later" ); } // Either way, queue it. opQueue_.add( op ); } } /** * Client implementation used to populate internal data structures. * It just implements the Client interface as well as adding mutators * for metadata and subscriptions, and providing an equals method based * on public id. */ private static class TrackedClient implements Client { private final String id_; private Metadata metadata_; private Subscriptions subscriptions_; /** * Constructor. * * @param id client public id */ public TrackedClient( String id ) { id_ = id; } public String getId() { return id_; } public Metadata getMetadata() { return metadata_; } public Subscriptions getSubscriptions() { return subscriptions_; } /** * Sets this client's metadata. * * @param metadata new metadata */ void setMetadata( Map metadata ) { metadata_ = Metadata.asMetadata( metadata ); } /** * Sets this client's subscriptions. * * @param subscriptions new subscriptions */ void setSubscriptions( Map subscriptions ) { subscriptions_ = Subscriptions.asSubscriptions( subscriptions ); } public boolean equals( Object o ) { if ( o instanceof TrackedClient ) { TrackedClient other = (TrackedClient) o; return other.id_.equals( this.id_ ); } else { return false; } } public int hashCode() { return id_.hashCode(); } public String toString() { return SampUtils.toString( this ); } } /** * Describes an operation to be performed on a TrackedClient object * which is already part of this tracker's model. */ private static abstract class ClientOperation { private final String id_; private final String mtype_; private final long birthday_; /** * Constructor. * * @param id client public ID * @param mtype MType of the message which triggered this operation */ ClientOperation( String id, String mtype ) { id_ = id; mtype_ = mtype; birthday_ = System.currentTimeMillis(); } /** * Performs the instance-specific operation on a given client. * * @param client client */ public abstract void perform( TrackedClient client ); /** * Returns the client ID for the client this operation applies to. * * @return client public ID */ public String getId() { return id_; } /** * Returns the MType of the message which triggered this operation. * * @return message MType */ public String getMType() { return mtype_; } /** * Returns the creation time of this object. * * @return System.currentTimeMillis() at construction */ public long getBirthday() { return birthday_; } public String toString() { return "message " + mtype_ + " for client " + id_; } } /** * Data structure for holding ClientOperation objects which (may) need * to be applied in the future. * Operations are dumped here if they cannot be performed immediately * because the client in question is not (yet) known by this tracker. * The hope is that the client will register at some point in the future * and the pending operations can be applied then. However, this may * never happen, so the queue maintains its own expiry system to throw * out old events. */ private static class OperationQueue { private final Collection opList_; private Timer tidyTimer_; /** * Constructor. */ OperationQueue() { opList_ = new ArrayList(); } /** * Add a new client operation which may get the opportunity to be * performed some time in the future. * * @param op oeration to add */ public synchronized void add( ClientOperation op ) { if ( tidyTimer_ == null ) { TimerTask tidy = new TimerTask() { public void run() { discardOld( QUEUE_TIME ); } }; tidyTimer_ = new Timer( true ); tidyTimer_.schedule( tidy, QUEUE_TIME, QUEUE_TIME ); } opList_.add( op ); } /** * Apply any pending operations to given client. * This client was presumably unavailable at the time such operations * were queued. * * @param client client to apply pending operations to */ public synchronized void apply( TrackedClient client ) { String id = client.getId(); for ( Iterator it = opList_.iterator(); it.hasNext(); ) { ClientOperation op = (ClientOperation) it.next(); if ( op.getId().equals( id ) ) { logger_.info( "Performing queued " + op ); op.perform( client ); it.remove(); } } } /** * Discards any operations corresponding to a given client, * presumably because the client is about to disappear. * * @param client client to forget about */ public synchronized void discard( TrackedClient client ) { String id = client.getId(); for ( Iterator it = opList_.iterator(); it.hasNext(); ) { ClientOperation op = (ClientOperation) it.next(); if ( op.getId().equals( id ) ) { logger_.warning( "Discarding queued " + op ); it.remove(); } } } /** * Throws away any pending operations which are older than a certain * age, presumably in the expectation that their client will never * register. * * @param maxAge oldest operations (in milliseconds) permitted to * remain in the queue */ public synchronized void discardOld( long maxAge ) { long now = System.currentTimeMillis(); for ( Iterator it = opList_.iterator(); it.hasNext(); ) { ClientOperation op = (ClientOperation) it.next(); if ( now - op.getBirthday() > maxAge ) { logger_.warning( "Discarding queued " + op + " - client never showed up" ); it.remove(); } } } /** * Removes all entries from this queue. */ public synchronized void clear() { opList_.clear(); if ( tidyTimer_ != null ) { tidyTimer_.cancel(); tidyTimer_ = null; } } /** * Returns an array containing all the operations currently pending. * * @return operation list */ public synchronized ClientOperation[] getOperations() { return (ClientOperation[]) opList_.toArray( new ClientOperation[ 0 ] ); } } } jsamp/src/java/org/astrogrid/samp/client/ClientProfile.java0000664000175000017500000000300512730747754023623 0ustar sladensladenpackage org.astrogrid.samp.client; /** * Defines an object that can be used to register with a running SAMP hub. * Registration takes the form of providing a connection object which * a client can use to perform further hub interactions. * Client-side implementations will take care of communications, * mapping between profile-specific transport mechanisms and the * methods of the generated {@link HubConnection} objects. * *

The usual way for a prospective SAMP client to obtain an instance of * this class is by using {@link DefaultClientProfile#getProfile}. * *

This interface is so-named partly for historical reasons; * "HubConnectionFactory" might have been more appropriate. * * @author Mark Taylor * @since 15 Jul 2008 */ public interface ClientProfile { /** * Attempts to register with a SAMP hub and return a corresponding * connection object. Some profile-specific hub discovery mechanism * is used to locate the hub. * If no hub is running, null will normally be returned. * * @return hub connection representing a new registration, or null * @throws SampException in case of some unexpected error */ HubConnection register() throws SampException; /** * Indicates whether a hub contactable by this profile appears to be * running. This is intended to execute reasonably quickly. * It should not go as far as registering. * * @return true iff it looks like a hub is running */ boolean isHubRunning(); } jsamp/src/java/org/astrogrid/samp/client/ResultHandler.java0000664000175000017500000000135612730747754023647 0ustar sladensladenpackage org.astrogrid.samp.client; import org.astrogrid.samp.Client; import org.astrogrid.samp.Response; /** * Interface which consumes call responses. * * @author Mark Taylor * @since 12 Nov 2008 */ public interface ResultHandler { /** * Called when a response is received from a client to which the message * was sent. * * @param responder responder client * @param response content of response */ public void result( Client responder, Response response ); /** * Called when no more {@link #result} invocations will be made, * either because all have been received or for some other reason, * such as a timeout or the hub shutting down. */ public void done(); } jsamp/src/java/org/astrogrid/samp/client/AbstractMessageHandler.java0000664000175000017500000001234212730747754025436 0ustar sladensladenpackage org.astrogrid.samp.client; import java.util.HashMap; import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; import org.astrogrid.samp.ErrInfo; import org.astrogrid.samp.Message; import org.astrogrid.samp.Response; import org.astrogrid.samp.SampMap; import org.astrogrid.samp.Subscriptions; /** * Partial implementation of MessageHandler interface which helps to ensure * correct client behaviour. * Concrete subclasses just need to specify the MTypes they subscribe to * and implement the {@link #processCall} method. * * @author Mark Taylor * @since 16 Jul 2008 */ public abstract class AbstractMessageHandler implements MessageHandler { private Subscriptions subscriptions_; private final Logger logger_ = Logger.getLogger( AbstractMessageHandler.class.getName() ); /** * Constructor using a given subscriptions map. * * @param subscriptions {@link org.astrogrid.samp.Subscriptions}-like map * defining which MTypes this handler can process */ protected AbstractMessageHandler( Map subscriptions ) { setSubscriptions( subscriptions ); } /** * Constructor using a given list of subscribed MTypes. * * @param mtypes list of MTypes which this handler can process */ protected AbstractMessageHandler( String[] mtypes ) { Map subs = new HashMap(); for ( int i = 0; i < mtypes.length; i++ ) { subs.put( mtypes[ i ], new HashMap() ); } setSubscriptions( subs ); } /** * Constructor using a single subscribed MType. * * @param mtype single MType which this handler can process */ protected AbstractMessageHandler( String mtype ) { this( new String[] { mtype } ); } /** * Implements message processing. Implementations should normally * return a map which contains the samp.result part * of the call response, that is the MType-specific return value * name->value map. * As a special case, returning null is equivalent to returning an empty * map. * However, if {@link #createResponse} is overridden, the return value * semantics may be different. * * @param connection hub connection * @param senderId public ID of sender client * @param message message with MType this handler is subscribed to * @return result of handling this message; exact semantics determined * by {@link #createResponse createResponse} implementation */ public abstract Map processCall( HubConnection connection, String senderId, Message message ) throws Exception; /** * Invoked by {@link #receiveCall receiveCall} to create a success * response from the result of calling {@link #processCall processCall}. * *

The default implementation calls * {@link Response#createSuccessResponse}(processOutput), * first transforming a null value to an empty map for convenience. * However, it may be overridden for more flexibility (for instance * in order to return non-OK responses). * * @param processOutput a Map returned by {@link #processCall processCall} */ protected Response createResponse( Map processOutput ) { Map result = processOutput == null ? SampMap.EMPTY : processOutput; return Response.createSuccessResponse( result ); } /** * Sets the subscriptions map. Usually this is called by the constructor, * but it may be reset manually. * * @param subscriptions {@link org.astrogrid.samp.Subscriptions}-like map * defining which MTypes this handler can process */ public void setSubscriptions( Map subscriptions ) { Subscriptions subs = Subscriptions.asSubscriptions( subscriptions ); subs.check(); subscriptions_ = subs; } public Map getSubscriptions() { return subscriptions_; } /** * Calls {@link #processCall} and discards the result. */ public void receiveNotification( HubConnection connection, String senderId, Message message ) { try { processCall( connection, senderId, message ); } catch ( Throwable e ) { logger_.log( Level.INFO, "Error processing notification " + message.getMType() + " - ignored", e ); } } /** * Calls {@link #processCall}, generates a response from the result * using {@link #createResponse}, and sends the resulting response * as a reply to the hub. In case of an exception, a suitable error * response is sent instead. */ public void receiveCall( HubConnection connection, String senderId, String msgId, Message message ) throws SampException { Response response; try { response = createResponse( processCall( connection, senderId, message ) ); } catch ( Throwable e ) { response = Response.createErrorResponse( new ErrInfo( e ) ); } connection.reply( msgId, response ); } } jsamp/src/java/org/astrogrid/samp/client/DefaultClientProfile.java0000664000175000017500000001150512730747754025134 0ustar sladensladenpackage org.astrogrid.samp.client; import java.util.logging.Logger; import org.astrogrid.samp.Platform; import org.astrogrid.samp.web.WebClientProfile; import org.astrogrid.samp.xmlrpc.StandardClientProfile; /** * Factory which supplies the default ClientProfile for use by SAMP clients. * By using this class to obtain ClientProfile instances, applications * can be used with non-standard profiles supplied at runtime without * requiring any code changes. * *

The profile returned by this class depends on the SAMP_HUB environment * variable ({@link #HUBLOC_ENV}). * If it consists of the prefix "jsamp-class:" * ({@link #HUBLOC_CLASS_PREFIX}) followed by the classname of a class * which implements {@link ClientProfile} and has a no-arg constructor, * then an instance of the named class is used. * Otherwise, an instance of {@link StandardClientProfile} or * {@link WebClientProfile} is returned. * * @author Mark Taylor * @since 4 Aug 2009 */ public class DefaultClientProfile { private static ClientProfile profile_; private static final Logger logger_ = Logger.getLogger( DefaultClientProfile.class.getName() ); /** Environment variable used for hub location ({@value}). */ public static final String HUBLOC_ENV = "SAMP_HUB"; /** * Prefix for SAMP_HUB env var indicating a supplied ClientProfile * implementation ({@value}). */ public static final String HUBLOC_CLASS_PREFIX = "jsamp-class:"; /** * No-arg constructor prevents instantiation. */ private DefaultClientProfile() { } /** * Returns a ClientProfile instance suitable for general purpose use. * By default this is currently the Standard Profile * ({@link org.astrogrid.samp.xmlrpc.StandardClientProfile#getInstance * StandardClientProfile.getInstance()}), * but the instance may be modified programatically or by use of * the SAMP_HUB environment variable. * *

If no instance has been set, the SAMP_HUB environment variable * is examined. If it consists of the prefix "jsamp-class:" * ({@link #HUBLOC_CLASS_PREFIX}) followed by the classname of a class * which implements {@link ClientProfile} and has a no-arg constructor, * then an instance of the named class is used. * Otherwise, an instance of {@link StandardClientProfile} is returned. * *

The instance is obtained lazily. * * @return client profile instance */ public static ClientProfile getProfile() { if ( profile_ == null ) { final ClientProfile profile; String hubloc = Platform.getPlatform().getEnv( HUBLOC_ENV ); if ( hubloc == null || hubloc.trim().length() == 0 ) { profile = StandardClientProfile.getInstance(); } else if ( hubloc.startsWith( HUBLOC_CLASS_PREFIX ) ) { String cname = hubloc.substring( HUBLOC_CLASS_PREFIX.length() ); final Class clazz; try { clazz = Class.forName( cname ); } catch ( ClassNotFoundException e ) { throw (IllegalArgumentException) new IllegalArgumentException( "No profile class " + cname ) .initCause( e ); } try { profile = (ClientProfile) clazz.newInstance(); logger_.info( "Using non-standard hub location: " + HUBLOC_ENV + "=" + hubloc ); } catch ( Throwable e ) { throw (RuntimeException) new RuntimeException( "Error instantiating custom " + "profile " + clazz.getName() ) .initCause( e ); } } else if ( hubloc.startsWith( StandardClientProfile .STDPROFILE_HUB_PREFIX ) ) { profile = StandardClientProfile.getInstance(); } else if ( hubloc.startsWith( WebClientProfile .WEBPROFILE_HUB_PREFIX ) ) { profile = WebClientProfile.getInstance(); } else { throw new RuntimeException( "Can't make sense of " + HUBLOC_ENV + "=" + hubloc ); } profile_ = profile; } return profile_; } /** * Sets the profile object which will be returned by {@link #getProfile}. * * @param profile default profile instance */ public static void setProfile( ClientProfile profile ) { profile_ = profile; } } jsamp/src/java/org/astrogrid/samp/client/HubConnector.java0000664000175000017500000013617712730747754023476 0ustar sladensladenpackage org.astrogrid.samp.client; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.SortedMap; import java.util.Timer; import java.util.TimerTask; import java.util.TreeMap; import java.util.logging.Level; import java.util.logging.Logger; import org.astrogrid.samp.Client; import org.astrogrid.samp.ErrInfo; import org.astrogrid.samp.Message; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.Platform; import org.astrogrid.samp.Response; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.Subscriptions; /** * Manages a client's connection to SAMP hubs. * Normally SAMP client applications will use one instance of this class * for as long as they are running. * It provides the following services: *

    *
  • Keeps track of other registered clients *
  • Keeps track of hubs shutting down and starting up *
  • Manages client metadata and subscription information across * hub reconnections *
  • Facilitates provision of callback services by the client *
  • Implements simple MTypes such as samp.app.ping. *
  • Optionally looks out for hubs starting up and connects automatically * when they do *
* *

This object provides a {@link #getConnection} method which provides * the currently active {@link HubConnection} object if one exists or can be * acquired. The HubConnection can be used for direct calls * on the running hub, but in some cases similar methods with additional * functionality exist in this class: *

*
{@link #declareMetadata declareMetadata} *
{@link #declareSubscriptions declareSubscriptions} *
These methods not only make the relevant declarations to the * existing hub connection, if one exists, but will retain the * metadata and subscriptions information and declare them to * other connections if the hub connection is terminated and * restarted (with either the same or a different hub) * over the lifetime of this object. *
*
{@link #callAndWait callAndWait} *
Provides identical semantics to the similarly named * HubConnection method, but communicates with the hub * asynchronously and fakes the synchrony at the client end. * This is more robust and almost certainly a better idea. *
*
{@link #call call} *
{@link #callAll callAll} *
Convenience methods to make asynchronous calls without having to * worry about registering handlers which match up message tags. *
* *
* *

It is good practice to call {@link #setActive setActive(false)} * when this object is finished with; however if it is not called * explicitly, any open connection will unregister itself on * object finalisation or JVM termination, as long as the JVM shuts * down cleanly. * *

Examples

* Here is an example of what use of this class might look like: *
 *   // Construct a connector
 *   ClientProfile profile = DefaultClientProfile.getProfile();
 *   HubConnector conn = new HubConnector(profile)
 *
 *   // Configure it with metadata about this application
 *   Metadata meta = new Metadata();
 *   meta.setName("Foo");
 *   meta.setDescriptionText("Application that does stuff");
 *   conn.declareMetadata(meta);
 *
 *   // Prepare to receive messages with specific MType(s)
 *   conn.addMessageHandler(new AbstractMessageHandler("stuff.do") {
 *       public Map processCall(HubConnection c, String senderId, Message msg) {
 *           // do stuff
 *       }
 *   });
 *
 *   // This step required even if no custom message handlers added.
 *   conn.declareSubscriptions(conn.computeSubscriptions());
 *
 *   // Keep a look out for hubs if initial one shuts down
 *   conn.setAutoconnect(10);
 *
 *   // Broadcast a message
 *   conn.getConnection().notifyAll(new Message("stuff.event.doing"));
 * 
* *

A real example, including use of the GUI hooks, can be found in the * {@link org.astrogrid.samp.gui.HubMonitor} client source code. * *

Backwards Compatibility Note

* This class does less than it did in earlier versions; * the functionality which is no longer here can now be found in the * {@link org.astrogrid.samp.gui.GuiHubConnector} class instead. * * @author Mark Taylor * @since 15 Jul 2008 */ public class HubConnector { private final ClientProfile profile_; private final TrackedClientSet clientSet_; private final List messageHandlerList_; private final List responseHandlerList_; private final ConnectorCallableClient callable_; private final Map responseMap_; private final ClientTracker clientTracker_; private final CallHandler callHandler_; private volatile boolean isActive_; private volatile HubConnection connection_; private volatile Metadata metadata_; private volatile Subscriptions subscriptions_; private volatile int autoSec_; private volatile Timer regTimer_; private volatile int iCall_; private final Logger logger_ = Logger.getLogger( HubConnector.class.getName() ); private static final String SHUTDOWN_MTYPE = "samp.hub.event.shutdown"; private static final String DISCONNECT_MTYPE = "samp.hub.disconnect"; private static final String PING_MTYPE = "samp.app.ping"; private static final String GETENV_MTYPE = "client.env.get"; /** * Constructs a HubConnector based on a given profile instance. * A default client set implementation is used. * * @param profile profile implementation */ public HubConnector( ClientProfile profile ) { this( profile, new TrackedClientSet() ); } /** * Constructs a HubConnector based on a given profile instance * using a custom client set implementation. * * @param profile profile implementation * @param clientSet object to keep track of registered clients */ public HubConnector( ClientProfile profile, TrackedClientSet clientSet ) { profile_ = profile; clientSet_ = clientSet; isActive_ = true; // Set up data structures. messageHandlerList_ = Collections.synchronizedList( new ArrayList() ); responseHandlerList_ = Collections.synchronizedList( new ArrayList() ); callable_ = new ConnectorCallableClient(); responseMap_ = Collections.synchronizedMap( new HashMap() ); // Listen out for events describing changes to registered clients. clientTracker_ = new ClientTracker( clientSet_ ); addMessageHandler( clientTracker_ ); // Listen out for hub shutdown events. addMessageHandler( new AbstractMessageHandler( SHUTDOWN_MTYPE ) { public Map processCall( HubConnection connection, String senderId, Message message ) { String mtype = message.getMType(); assert SHUTDOWN_MTYPE.equals( mtype ); checkHubMessage( connection, senderId, mtype ); disconnect(); return null; } } ); // Listen out for forcible disconnection events. addMessageHandler( new AbstractMessageHandler( DISCONNECT_MTYPE ) { public Map processCall( HubConnection connection, String senderId, Message message ) { String mtype = message.getMType(); assert DISCONNECT_MTYPE.equals( mtype ); if ( senderId.equals( connection.getRegInfo().getHubId() ) ) { Object reason = message.getParam( "reason" ); logger_.warning( "Forcible disconnect from hub" + ( reason == null ? " [no reason given]" : " (" + reason + ")" ) ); disconnect(); isActive_ = false; return null; } else { throw new IllegalArgumentException( "Ignoring " + mtype + " message from non-hub" + " client " + senderId ); } } } ); // Implement samp.app.ping MType. addMessageHandler( new AbstractMessageHandler( PING_MTYPE ) { public Map processCall( HubConnection connection, String senderId, Message message ) throws InterruptedException { String waitMillis = (String) message.getParam( "waitMillis" ); if ( waitMillis != null ) { Object lock = new Object(); synchronized ( lock ) { lock.wait( SampUtils.decodeInt( waitMillis ) ); } } return null; } } ); // Implement client.env.get MType. addMessageHandler( new AbstractMessageHandler( GETENV_MTYPE ) { public Map processCall( HubConnection connection, String senderId, Message message ) { String name = (String) message.getParam( "name" ); String value = Platform.getPlatform().getEnv( name ); Map result = new HashMap(); result.put( "value", value == null ? "" : value ); return result; }; } ); // Listen out for responses to calls for which we are providing // faked synchronous call behaviour. addResponseHandler( new ResponseHandler() { public boolean ownsTag( String msgTag ) { return responseMap_.containsKey( msgTag ); } public void receiveResponse( HubConnection connection, String responderId, String msgTag, Response response ) { synchronized ( responseMap_ ) { if ( responseMap_.containsKey( msgTag ) && responseMap_.get( msgTag ) == null ) { responseMap_.put( msgTag, response ); responseMap_.notifyAll(); } } } } ); // Listen out for responses to calls for which we have agreed to // pass results to user-supplied ResultHandler objects. callHandler_ = new CallHandler(); addResponseHandler( callHandler_ ); } /** * Sets the interval at which this connector attempts to connect to a * hub if no connection currently exists. * Otherwise, a connection will be attempted whenever * {@link #getConnection} is called. * * @param autoSec number of seconds between attempts; * <=0 means no automatic connections are attempted */ public synchronized void setAutoconnect( int autoSec ) { autoSec_ = autoSec; configureRegisterTimer( autoSec_ ); } /** * Configures a timer thread to attempt registration periodically. * * @param autoSec number of seconds between attempts; * <=0 means no automatic connections are attempted */ private synchronized void configureRegisterTimer( int autoSec ) { // Cancel and remove any existing auto-connection timer. if ( regTimer_ != null ) { regTimer_.cancel(); regTimer_ = null; } // If required, install a new one. if ( autoSec > 0 ) { TimerTask regTask = new TimerTask() { public void run() { if ( ! isConnected() ) { try { HubConnection conn = getConnection(); if ( conn == null ) { logger_.config( "SAMP autoconnection attempt " + "failed" ); } else { logger_.info( "SAMP autoconnection attempt " + "succeeded" ); } } catch ( SampException e ) { logger_.config( "SAMP Autoconnection attempt " + " failed: " + e ); } } } }; regTimer_ = new Timer( true ); regTimer_.schedule( regTask, 0, autoSec_ * 1000 ); } } /** * Declares the metadata for this client. * This declaration affects the current connection and any future ones. * * @param meta {@link org.astrogrid.samp.Metadata}-like map */ public void declareMetadata( Map meta ) { Metadata md = Metadata.asMetadata( meta ); md.check(); metadata_ = md; if ( isConnected() ) { try { connection_.declareMetadata( md ); } catch ( SampException e ) { logger_.log( Level.WARNING, "SAMP metadata declaration failed", e ); } } } /** * Returns this client's own metadata. * * @return metadata */ public Metadata getMetadata() { return metadata_; } /** * Declares the MType subscriptions for this client. * This declaration affects the current connection and any future ones. * *

Note that this call must be made, with a subscription list * which includes the various hub administrative messages, in order * for this connector to act on those messages (for instance to * update its client map and so on). For this reason, it is usual * to call it with the subs argument given by * the result of calling {@link #computeSubscriptions}. * * @param subscriptions {@link org.astrogrid.samp.Subscriptions}-like map */ public void declareSubscriptions( Map subscriptions ) { Subscriptions subs = Subscriptions.asSubscriptions( subscriptions ); subs.check(); subscriptions_ = subs; if ( isConnected() ) { try { connection_.declareSubscriptions( subs ); } catch ( SampException e ) { logger_.log( Level.WARNING, "Subscriptions declaration failed", e ); } } } /** * Returns this client's own subscriptions. * * @return subscriptions */ public Subscriptions getSubscriptions() { return subscriptions_; } /** * Works out the subscriptions map for this connector. * This is based on the subscriptions declared by for any * {@link MessageHandler}s installed in this connector as well as * any MTypes which this connector implements internally. * The result of this method is usually a suitable value to pass * to {@link #declareSubscriptions}. However you might wish to * remove some entries from the result if there are temporarily * unsubscribed services. * * @return subscription list for MTypes apparently implemented by this * connector */ public Subscriptions computeSubscriptions() { MessageHandler[] mhandlers = (MessageHandler[]) messageHandlerList_.toArray( new MessageHandler[ 0 ] ); Map subs = new HashMap(); for ( int ih = mhandlers.length - 1; ih >= 0; ih-- ) { subs.putAll( mhandlers[ ih ].getSubscriptions() ); } return Subscriptions.asSubscriptions( subs ); } /** * Adds a MessageHandler to this connector, which allows it to respond * to incoming messages. * Note that this does not in itself update the list of subscriptions * for this connector; you may want to follow it with a call to *

     *    declareSubscriptions(computeSubscriptions());
     * 
* * @param handler handler to add */ public void addMessageHandler( MessageHandler handler ) { messageHandlerList_.add( handler ); } /** * Removes a previously-added MessageHandler to this connector. * Note that this does not in itself update the list of subscriptions * for this connector; you may want to follow it with a call to *
     *    declareSubscriptions(computeSubscriptions());
     * 
* * @param handler handler to remove */ public void removeMessageHandler( MessageHandler handler ) { messageHandlerList_.remove( handler ); } /** * Adds a ResponseHandler to this connector, which allows it to receive * replies from messages sent asynchronously. * *

Note however that this class's {@link #callAndWait callAndWait} method * can provide a synchronous facade for fully asynchronous messaging, * which in many cases will be more convenient than installing your * own response handlers to deal with asynchronous replies. * * @param handler handler to add */ public void addResponseHandler( ResponseHandler handler ) { responseHandlerList_.add( handler ); } /** * Removes a ResponseHandler from this connector. * * @param handler handler to remove */ public void removeResponseHandler( ResponseHandler handler ) { responseHandlerList_.remove( handler ); } /** * Sets whether this connector is active or not. * If set false, any existing connection will be terminated (the client * will unregister) and autoconnection attempts will be suspended. * If set true, if there is no existing connection an attempt will be * made to register, and autoconnection attempts will begin if applicable. * * @param active whether this connector should be active * @see #setAutoconnect */ public void setActive( boolean active ) { isActive_ = active; if ( active ) { if ( connection_ == null ) { try { getConnection(); } catch ( SampException e ) { logger_.log( Level.WARNING, "Hub connection attempt failed", e ); } } configureRegisterTimer( autoSec_ ); } else { HubConnection connection = connection_; if ( connection != null ) { disconnect(); try { connection.unregister(); } catch ( SampException e ) { logger_.log( Level.INFO, "Unregister attempt failed", e ); } } configureRegisterTimer( 0 ); } } /** * Sends a message synchronously to a client, waiting for the response. * If more seconds elapse than the value of the timeout * parameter, an exception will result. * *

The semantics of this call are, as far as the caller is concerned, * identical to that of the similarly named {@link HubConnection} method. * However, in this case the client communicates with the hub * asynchronously and internally simulates the synchrony for the caller, * rather than letting the hub do that. * This is more robust and almost certainly a better idea. * * @param recipientId public-id of client to receive message * @param msg {@link org.astrogrid.samp.Message}-like map * @param timeout timeout in seconds, or <=0 for no timeout * @return response */ public Response callAndWait( String recipientId, Map msg, int timeout ) throws SampException { long finish = timeout > 0 ? System.currentTimeMillis() + timeout * 1000 : Long.MAX_VALUE; // 3e8 years HubConnection connection = getConnection(); String msgTag = createTag( this ); responseMap_.put( msgTag, null ); connection.call( recipientId, msgTag, msg ); synchronized ( responseMap_ ) { while ( responseMap_.containsKey( msgTag ) && responseMap_.get( msgTag ) == null && System.currentTimeMillis() < finish ) { long millis = finish - System.currentTimeMillis(); if ( millis > 0 ) { try { responseMap_.wait( millis ); } catch ( InterruptedException e ) { throw new SampException( "Wait interrupted", e ); } } } if ( responseMap_.containsKey( msgTag ) ) { Response response = (Response) responseMap_.remove( msgTag ); if ( response != null ) { return response; } else { assert System.currentTimeMillis() >= finish; throw new SampException( "Synchronous call timeout" ); } } else { if ( connection != connection_ ) { throw new SampException( "Hub connection lost" ); } else { throw new AssertionError(); } } } } /** * Sends a message asynchronously to a single client, making a callback * on a supplied ResultHandler object when the result arrives. * The {@link org.astrogrid.samp.client.ResultHandler#done} method will * be called after the result has arrived or the timeout elapses, * whichever happens first. * *

This convenience method allows the user to make an asynchronous * call without having to worry registering message handlers and * matching message tags. * * @param recipientId public-id of client to receive message * @param msg {@link org.astrogrid.samp.Message}-like map * @param resultHandler object called back when response arrives or * timeout is exceeded * @param timeout timeout in seconds, or <=0 for no timeout */ public void call( String recipientId, Map msg, ResultHandler resultHandler, int timeout ) throws SampException { HubConnection connection = getConnection(); if ( connection == null ) { throw new SampException( "Not connected" ); } String tag = createTag( this ); callHandler_.registerHandler( tag, resultHandler, timeout ); try { connection.call( recipientId, tag, msg ); callHandler_.setRecipients( tag, new String[] { recipientId, } ); } catch ( SampException e ) { callHandler_.unregisterHandler( tag ); throw e; } } /** * Sends a message asynchronously to all subscribed clients, * making callbacks on a supplied ResultHandler object when the * results arrive. * The {@link org.astrogrid.samp.client.ResultHandler#done} method will * be called after all the results have arrived or the timeout elapses, * whichever happens first. * *

This convenience method allows the user to make an asynchronous * call without having to worry registering message handlers and * matching message tags. * * @param msg {@link org.astrogrid.samp.Message}-like map * @param resultHandler object called back when response arrives or * timeout is exceeded * @param timeout timeout in seconds, or <=0 for no timeout */ public void callAll( Map msg, ResultHandler resultHandler, int timeout ) throws SampException { HubConnection connection = getConnection(); if ( connection == null ) { throw new SampException( "Not connected" ); } String tag = createTag( this ); callHandler_.registerHandler( tag, resultHandler, timeout ); try { Map callMap = connection.callAll( tag, msg ); callHandler_.setRecipients( tag, (String[]) callMap.keySet() .toArray( new String[ 0 ] ) ); } catch ( SampException e ) { callHandler_.unregisterHandler( tag ); throw e; } } /** * Indicates whether this connector is currently registered with a * running hub. * If true, the result of {@link #getConnection} will be non-null. * * @return true if currently connected to a hub */ public boolean isConnected() { return connection_ != null; } /** * If necessary attempts to acquire, and returns, a connection to a * running hub. * If there is an existing connection representing a registration * with a hub, it is returned. If not, and this connector is active, * an attempt is made to connect and register, followed by a call to * {@link #configureConnection configureConnection}, is made. * *

Note that if {@link #setActive setActive(false)} has been called, * null will be returned. * * @return hub connection representing configured registration with a hub * if a hub is running; if not, null * @throws SampException in the case of some unexpected error */ public HubConnection getConnection() throws SampException { HubConnection connection = connection_; if ( connection == null && isActive_ ) { connection = createConnection(); if ( connection != null ) { connection_ = connection; configureConnection( connection ); clientTracker_.initialise( connection ); connectionChanged( true ); } } return connection; } /** * Configures a connection with a hub in accordance with the state of * this object. * The hub is made aware of how to perform callbacks on the registered * client, and any current metadata and subscriptions are declared. * * @param connection connection representing registration with a hub */ public void configureConnection( HubConnection connection ) throws SampException { if ( metadata_ != null ) { connection.declareMetadata( metadata_ ); } if ( callable_ != null ) { connection.setCallable( callable_ ); callable_.setConnection( connection ); if ( subscriptions_ != null ) { connection.declareSubscriptions( subscriptions_ ); } } } /** * Returns a map which keeps track of other clients currently registered * with the hub to which this object is connected, including their * currently declared metadata and subscriptions. * Map keys are public IDs and values are * {@link org.astrogrid.samp.Client}s. * *

This map is {@link java.util.Collections#synchronizedMap synchronized} * which means that to iterate over any of its views * you must synchronize on it. * When the map or any of its contents changes, it will receive a * {@link java.lang.Object#notifyAll}. * *

To keep itself up to date, the client map reads hub status messages. * These will only be received if * declareSubscriptions(computeSubscriptions()) has been * called. * Hence, this method should only be called after * {@link #declareSubscriptions} has been called. * If this order is not observed, a warning will be emitted through * the logging system. * * @return id->Client map */ public Map getClientMap() { if ( subscriptions_ == null ) { logger_.warning( "Danger: you should call declareSubscriptions " + "before using client map" ); } return getClientSet().getClientMap(); } /** * Returns the tracked client set implementation which is used to keep * track of the currently registered clients. * * @return client set implementation */ protected TrackedClientSet getClientSet() { return clientSet_; } /** * Invoked by this class to create a hub connection. * The default implementation just calls profile.register(). * * @return new hub connection */ protected HubConnection createConnection() throws SampException { return profile_.register(); } /** * Unregisters from the currently connected hub, if any. * Performs any associated required cleanup. */ protected void disconnect() { boolean wasConnected = connection_ != null; connection_ = null; clientTracker_.clear(); callHandler_.stopTimeouter(); synchronized ( responseMap_ ) { responseMap_.clear(); responseMap_.notifyAll(); } if ( wasConnected ) { connectionChanged( false ); } } /** * Method which is called every time this connector changes its connection * status (from disconnected to connected, or vice versa). * The default implementation does nothing, but it may be overridden * by subclasses wishing to be informed of these events. * * @param isConnected true if we've just registered; * false if we've just unregistered */ protected void connectionChanged( boolean isConnected ) { } /** * Performs sanity checking on a message which is normally expected to * be sent only by the hub client itself. * * @param connection connection to the hub * @param senderId public client id of sender * @param mtype MType of sent message */ private void checkHubMessage( HubConnection connection, String senderId, String mtype ) { if ( ! senderId.equals( connection.getRegInfo().getHubId() ) ) { logger_.warning( "Hub admin message " + mtype + " received from " + "non-hub client. Acting on it anyhow" ); } } /** * Generates a new msgTag for use with this connector. * It is guaranteed to return a different value on each invocation. * It is advisable to use this method whenever a message tag is required * to prevent clashes. * * @param owner object to identify caller * (not really necessary - may be null) * @return unique tag for this connector */ public synchronized String createTag( Object owner ) { return ( owner == null ? "tag" : ( String.valueOf( owner ) + ":" ) ) + ++iCall_; } /** * CallableClient implementation used by this class. */ private class ConnectorCallableClient implements CallableClient { private HubConnection conn_; /** * Sets the currently active hub connection. * * @param connection connection */ private void setConnection( HubConnection connection ) { conn_ = connection; } public void receiveNotification( String senderId, Message message ) { // Offer the notification to each registered MessageHandler in turn. // It may in principle get processed by more than one. // This is almost certainly harmless. MessageHandler[] mhandlers = (MessageHandler[]) messageHandlerList_.toArray( new MessageHandler[ 0 ] ); for ( int ih = 0; ih < mhandlers.length; ih++ ) { MessageHandler handler = mhandlers[ ih ]; Subscriptions subs = Subscriptions.asSubscriptions( handler.getSubscriptions() ); String mtype = message.getMType(); if ( subs.isSubscribed( mtype ) ) { try { handler.receiveNotification( conn_, senderId, message ); } catch ( Throwable e ) { logger_.log( Level.WARNING, "Notify handler failed " + mtype, e ); } } } } public void receiveCall( String senderId, String msgId, Message message ) { // Offer the call to each registered MessageHandler in turn. // Since only one should be allowed to respond to it, only // the first one which bites is allowed to process it. String mtype = message.getMType(); ErrInfo errInfo = null; MessageHandler[] mhandlers = (MessageHandler[]) messageHandlerList_.toArray( new MessageHandler[ 0 ] ); for ( int ih = 0; ih < mhandlers.length; ih++ ) { MessageHandler handler = mhandlers[ ih ]; Subscriptions subs = Subscriptions.asSubscriptions( handler.getSubscriptions() ); if ( subs.isSubscribed( mtype ) ) { try { handler.receiveCall( conn_, senderId, msgId, message ); return; } catch ( Throwable e ) { errInfo = new ErrInfo( e ); logger_.log( Level.WARNING, "Call handler failed " + mtype, e ); } } } if ( errInfo == null ) { logger_.warning( "No handler for subscribed MType " + mtype ); errInfo = new ErrInfo( "No handler found" ); errInfo.setUsertxt( "No handler was found for the supplied" + " MType. " + "Looks like a programming error " + "at the recipient end. Sorry." ); } Response response = Response.createErrorResponse( errInfo ); response.check(); try { conn_.reply( msgId, response ); } catch ( SampException e ) { logger_.warning( "Failed to reply to " + msgId ); } } public void receiveResponse( String responderId, String msgTag, Response response ) { // Offer the response to each registered ResponseHandler in turn. // It shouldn't be processed by more than one, but if it is, // warn about it. int handleCount = 0; ResponseHandler[] rhandlers = (ResponseHandler[]) responseHandlerList_.toArray( new ResponseHandler[ 0 ] ); for ( int ih = 0; ih < rhandlers.length; ih++ ) { ResponseHandler handler = rhandlers[ ih ]; if ( handler.ownsTag( msgTag ) ) { handleCount++; try { handler.receiveResponse( conn_, responderId, msgTag, response ); } catch ( Exception e ) { logger_.log( Level.WARNING, "Response handler failed", e ); } } } if ( handleCount == 0 ) { logger_.warning( "No handler for message " + msgTag + " response" ); } else if ( handleCount > 1 ) { logger_.warning( "Multiple (" + handleCount + ")" + " handlers handled message " + msgTag + " respose" ); } } } /** * ResponseHandler which looks after responses made by calls using the * call() and callAll() convenience methods. */ private class CallHandler implements ResponseHandler { private final SortedMap tagMap_; private Thread timeouter_; /** * Constructor. */ CallHandler() { // Set up a structure to contain tag->CallItem entries for // responses we are expecting. They are arranged in order of // which is going to time out soonest. tagMap_ = new TreeMap(); } /** * Ensures that a thread is running to wake up when the next timeout * has (or at least might have) happened. */ private void readyTimeouter() { synchronized ( tagMap_ ) { if ( timeouter_ == null ) { timeouter_ = new Thread( "ResultHandler timeout watcher" ) { public void run() { watchTimeouts(); } }; timeouter_.setDaemon( true ); timeouter_.start(); } } } /** * Stops any current timeout watcher operating on behalf of this * handler and tidies up associated resources. */ private void stopTimeouter() { synchronized ( tagMap_ ) { if ( timeouter_ != null ) { timeouter_.interrupt(); } timeouter_ = null; tagMap_.clear(); } } /** * Runs in a daemon thread to watch out for timeouts that might * have occurred. */ private void watchTimeouts() { while ( ! Thread.currentThread().isInterrupted() ) { synchronized ( tagMap_ ) { // Wait until the next scheduled timeout is expected. long nextFinish = tagMap_.isEmpty() ? Long.MAX_VALUE : ((CallItem) tagMap_.get( tagMap_.firstKey() )) .finish_; final long delay = nextFinish - System.currentTimeMillis(); if ( delay > 0 ) { try { tagMap_.wait( delay ); } catch ( InterruptedException e ) { Thread.currentThread().interrupt(); } } // Then process any timeouts that are pending. long now = System.currentTimeMillis(); for ( Iterator it = tagMap_.entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); CallItem item = (CallItem) entry.getValue(); if ( now >= item.finish_ ) { item.handler_.done(); it.remove(); } } } } } /** * Stores a ResultHandler object which will take delivery of the * responses tagged with a given tag. * * @param tag message tag identifying send/response * @param handler callback object * @param timeout milliseconds before forcing completion */ public void registerHandler( String tag, ResultHandler handler, int timeout ) { long finish = timeout > 0 ? System.currentTimeMillis() + timeout * 1000 : Long.MAX_VALUE; // 3e8 years CallItem item = new CallItem( handler, finish ); if ( ! item.isDone() ) { synchronized ( tagMap_ ) { readyTimeouter(); tagMap_.put( tag, item ); tagMap_.notifyAll(); } } else { handler.done(); } } /** * Set the recipients from which we are expecting responses. * Once all are in, the handler can be disposed of. * * @param tag message tag identifying send/response * @param recipients clients expected to reply */ public void setRecipients( String tag, String[] recipients ) { CallItem item; synchronized ( tagMap_ ) { item = (CallItem) tagMap_.get( tag ); } item.setRecipients( recipients ); retireIfDone( tag, item ); } /** * Unregister a handler for which no responses are expected. * * @param tag message tag identifying send/response */ public void unregisterHandler( String tag ) { synchronized ( tagMap_ ) { tagMap_.remove( tag ); } } public boolean ownsTag( String tag ) { synchronized ( tagMap_ ) { return tagMap_.containsKey( tag ); } } public void receiveResponse( HubConnection connection, String responderId, String msgTag, Response response ) { final CallItem item; synchronized ( tagMap_ ) { item = (CallItem) tagMap_.get( msgTag ); } if ( item != null ) { item.addResponse( responderId, response ); retireIfDone( msgTag, item ); } } /** * Called when a tag/handler entry might be ready to finish with. */ private void retireIfDone( String tag, CallItem item ) { if ( item.isDone() ) { synchronized ( tagMap_ ) { item.handler_.done(); tagMap_.remove( tag ); } } } } /** * Stores state about a particular set of responses expected by the * CallHandler class. */ private class CallItem implements Comparable { final ResultHandler handler_; final long finish_; volatile Map responseMap_; // responderId -> Response volatile Map recipientMap_; // responderId -> Client /** * Constructor. * * @param handler callback object * @param finish epoch at which timeout should be called */ CallItem( ResultHandler handler, long finish ) { handler_ = handler; finish_ = finish; } /** * Sets the recipient Ids for which responses are expected. * * @param recipientIds recipient client ids */ public synchronized void setRecipients( String[] recipientIds ) { recipientMap_ = new HashMap(); // Store client objects for each recipient ID. Note however // because of various synchrony issues we can't guarantee that // all these client objects can be determined - some may be null. // Store the ids as keys in any case. Map clientMap = getClientMap(); for ( int ir = 0; ir < recipientIds.length; ir++ ) { String id = recipientIds[ ir ]; Client client = (Client) clientMap.get( id ); recipientMap_.put( id, client ); } // If we have pending responses (couldn't be processed earlier // because no recipients), take care of them now. if ( responseMap_ != null ) { for ( Iterator it = responseMap_.entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); String responderId = (String) entry.getKey(); Response response = (Response) entry.getValue(); processResponse( responderId, response ); } responseMap_ = null; } } /** * Take delivery of a response object. * * @param responderId client ID of responder * @param response response object */ public synchronized void addResponse( String responderId, Response response ) { // If we know the recipients, deal with it now. if ( recipientMap_ != null ) { processResponse( responderId, response ); } // Otherwise, defer until we do know the recipients. else { if ( responseMap_ == null ) { responseMap_ = new HashMap(); } responseMap_.put( responderId, response ); } } /** * Process a response when we have both the list of recipients * and the response itself. * * @param responderId client ID of responder * @param response response object */ private synchronized void processResponse( final String responderId, Response response ) { if ( recipientMap_.containsKey( responderId ) ) { // Get a client object. We have to try belt and braces. Client client = (Client) recipientMap_.get( responderId ); if ( client == null ) { client = (Client) getClientMap().get( responderId ); } if ( client == null ) { client = new Client() { public String getId() { return responderId; } public Metadata getMetadata() { return null; } public Subscriptions getSubscriptions() { return null; } }; } // Make the callback to the supplied handler. handler_.result( client, response ); // Note that we've done this one. recipientMap_.remove( responderId ); } } /** * Indicate whether this call item has received all the responses it's * going to. * * @return iff no further activity is expected */ public synchronized boolean isDone() { return ( recipientMap_ != null && recipientMap_.isEmpty() ) || System.currentTimeMillis() >= finish_; } /** * Compares on timeout epochs. * Implementation is consistent with equals, * which means it's OK to use them in a SortedMap. */ public int compareTo( Object o ) { CallItem other = (CallItem) o; if ( this.finish_ < other.finish_ ) { return -1; } else if ( this.finish_ > other.finish_ ) { return +1; } else { return System.identityHashCode( this ) - System.identityHashCode( other ); } } } } jsamp/src/java/org/astrogrid/samp/client/CallableClient.java0000664000175000017500000000261712730747754023732 0ustar sladensladenpackage org.astrogrid.samp.client; import java.util.Map; import org.astrogrid.samp.Message; import org.astrogrid.samp.Response; /** * Defines callbacks which the hub can make on a callable client. * * @author Mark Taylor * @since 16 Jul 2008 */ public interface CallableClient { /** * Receives a message for which no response is required. * * @param senderId public ID of sending client * @param message message */ void receiveNotification( String senderId, Message message ) throws Exception; /** * Receives a message for which a response is required. * The implementation must take care to call the hub's reply * method at some future point. * * @param senderId public ID of sending client * @param msgId message identifier for later use with reply * @param message message */ void receiveCall( String senderId, String msgId, Message message ) throws Exception; /** * Receives a response to a message previously sent by this client. * * @param responderId public ID of responding client * @param msgTag client-defined tag labelling previously-sent message * @param response returned response object */ void receiveResponse( String responderId, String msgTag, Response response ) throws Exception; } jsamp/src/java/org/astrogrid/samp/client/HubConnection.java0000664000175000017500000001341312730747754023626 0ustar sladensladenpackage org.astrogrid.samp.client; import java.util.List; import java.util.Map; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.RegInfo; import org.astrogrid.samp.Response; import org.astrogrid.samp.Subscriptions; /** * Represents a registered client's connection to a running hub. * An application typically obtains an instance of this class * from a {@link ClientProfile} object. * *

It is good practice to call {@link #unregister} when the connection * is finished with; however if it is not called explicitly, the * connection will unregister itself on object finalisation or JVM termination, * as long as the JVM shuts down cleanly. * * @author Mark Taylor * @since 15 Jul 2008 */ public interface HubConnection { /** * Returns the registration information associated with this connection. * * @return registration info */ RegInfo getRegInfo(); /** * Tells the hub how it can perform callbacks on the client by providing * a CallableClient object. This is required before the client * can declare subscriptions or make asynchronous calls. * * @param callable callable client */ void setCallable( CallableClient callable ) throws SampException; /** * Tests whether the connection is currently open. * * @throws SampException if the hub has disappeared or communications * are disrupted in some other way */ void ping() throws SampException; /** * Unregisters the client and terminates this connection. */ void unregister() throws SampException; /** * Declares this registered client's metadata. * * @param meta {@link org.astrogrid.samp.Metadata}-like map */ void declareMetadata( Map meta ) throws SampException; /** * Returns the metadata for another registered client. * * @param clientId public id for another registered client * @return metadata map */ Metadata getMetadata( String clientId ) throws SampException; /** * Declares this registered client's MType subscriptions. * *

Only permitted if this client is already callable. * * @param subs {@link org.astrogrid.samp.Subscriptions}-like map */ void declareSubscriptions( Map subs ) throws SampException; /** * Returns the subscriptions for another registered client. * * @param clientId public id for another registered client * @return subscriptions map */ Subscriptions getSubscriptions( String clientId ) throws SampException; /** * Returns the list of client public IDs for those clients currently * registered. * * @return array of client ids, excluding the one for this client */ String[] getRegisteredClients() throws SampException; /** * Returns a map of subscriptions for a given MType. * * @param mtype MType * @return map in which the keys are the public IDs of clients subscribed * to mtype */ Map getSubscribedClients( String mtype ) throws SampException; /** * Sends a message to a given client without wanting a response. * * @param recipientId public-id of client to receive message * @param msg {@link org.astrogrid.samp.Message}-like map */ void notify( String recipientId, Map msg ) throws SampException; /** * Sends a message to all subscribed clients without wanting a response. * * @param msg {@link org.astrogrid.samp.Message}-like map * @return list of public-ids for clients to which the notify will be sent */ List notifyAll( Map msg ) throws SampException; /** * Sends a message to a given client expecting a response. * The receiveResponse method of this connection's * {@link CallableClient} will be called with a * response at some time in the future. * *

Only permitted if this client is already callable. * * @param recipientId public-id of client to receive message * @param msgTag arbitrary string tagging this message for caller's * benefit * @param msg {@link org.astrogrid.samp.Message}-like map * @return message ID */ String call( String recipientId, String msgTag, Map msg ) throws SampException; /** * Sends a message to all subscribed clients expecting responses. * The receiveResponse method of this connection's * {@link CallableClient} will be called with responses at some * time in the future. * *

Only permitted if this client is already callable. * * @param msgTag arbitrary string tagging this message for caller's * benefit * @param msg {@link org.astrogrid.samp.Message}-like map * @return public-id->msg-id map for clients to which an attempt to * send the call will be made */ Map callAll( String msgTag, Map msg ) throws SampException; /** * Sends a message synchronously to a client, waiting for the response. * If more seconds elapse than the value of the timeout * parameter, an exception will result. * * @param recipientId public-id of client to receive message * @param msg {@link org.astrogrid.samp.Message}-like map * @param timeout timeout in seconds, or <0 for no timeout * @return response */ Response callAndWait( String recipientId, Map msg, int timeout ) throws SampException; /** * Supplies a response to a previously received message. * * @param msgId ID associated with earlier send * @param response {@link org.astrogrid.samp.Response}-like map */ void reply( String msgId, Map response ) throws SampException; } jsamp/src/java/org/astrogrid/samp/client/TrackedClientSet.java0000664000175000017500000000662012730747754024262 0ustar sladensladenpackage org.astrogrid.samp.client; import java.util.Collections; import java.util.HashMap; import java.util.Map; import org.astrogrid.samp.Client; /** * Collection of Client objects which can be notified and interrogated * about the clients which are currently registered. * Instances of this class are thread-safe. * * @author Mark Taylor * @since 25 Nov 2008 */ public class TrackedClientSet { private final Map clientMap_; private final Map clientMapView_; /** * Constructor. */ public TrackedClientSet() { clientMap_ = new HashMap(); clientMapView_ = Collections.synchronizedMap( Collections .unmodifiableMap( clientMap_ ) ); } /** * Adds a client to this model. * Listeners are informed. May be called from any thread. * * @param client client to add */ public void addClient( Client client ) { synchronized ( clientMapView_ ) { clientMap_.put( client.getId(), client ); clientMapView_.notifyAll(); } } /** * Removes a client from this model. * Listeners are informed. May be called from any thread. * * @param client client to remove */ public synchronized void removeClient( Client client ) { Client c; synchronized ( clientMapView_ ) { c = (Client) clientMap_.remove( client.getId() ); clientMapView_.notifyAll(); } boolean removed = c != null; if ( ! removed ) { throw new IllegalArgumentException( "No such client " + client ); } assert client.equals( c ); } /** * Sets the contents of this model to a given list. * Listeners are informed. May be called from any thread. * * @param clients current client list */ public synchronized void setClients( Client[] clients ) { synchronized ( clientMapView_ ) { clientMap_.clear(); for ( int ic = 0; ic < clients.length; ic++ ) { Client client = clients[ ic ]; clientMap_.put( client.getId(), client ); } clientMapView_.notifyAll(); } } /** * Notifies listeners that a given client's attributes (may) have * changed. May be called from any thread. * * @param client modified client * @param metaChanged true if metadata may have changed * (false if known unchanged) * @param subsChanged true if subscriptions may have changed * (false if known unchanged) */ public void updateClient( Client client, boolean metaChanged, boolean subsChanged ) { synchronized ( clientMapView_ ) { clientMapView_.notifyAll(); } } /** * Returns an unmodifiable Map representing the client list. * Keys are client IDs and values are {@link org.astrogrid.samp.Client} * objects. *

This map is {@link java.util.Collections#synchronizedMap synchronized} * which means that to iterate over any of its views * you must synchronize on it. * When the map or any of its contents changes, it will receive a * {@link java.lang.Object#notifyAll}. * * @return id -> Client map */ public Map getClientMap() { return clientMapView_; } } jsamp/src/java/org/astrogrid/samp/client/SampException.java0000664000175000017500000000207712730747754023653 0ustar sladensladenpackage org.astrogrid.samp.client; import java.io.IOException; /** * Exception thrown when some error occurs in SAMP processing. * Note that this is a subclass of {@link java.io.IOException}. * * @author Mark Taylor * @since 15 Jul 2008 */ public class SampException extends IOException { /** * Constructs an exception with no message. */ public SampException() { super(); } /** * Consructs an exception with a given message. * * @param msg message */ public SampException( String msg ) { super( msg ); } /** * Constructs an exception with a given cause. * * @param cause cause of this exception */ public SampException( Throwable cause ) { this(); initCause( cause ); } /** * Constructs an exception with a given message and cause. * * @param msg message * @param cause cause of this exception */ public SampException( String msg, Throwable cause ) { this( msg ); initCause( cause ); } } jsamp/src/java/org/astrogrid/samp/client/LogResultHandler.java0000664000175000017500000000272712730747754024314 0ustar sladensladenpackage org.astrogrid.samp.client; import java.util.logging.Logger; import org.astrogrid.samp.Client; import org.astrogrid.samp.ErrInfo; import org.astrogrid.samp.Message; import org.astrogrid.samp.Response; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.client.HubConnection; /** * ResultHandler implementation which outputs some information about * responses received through the logging system. * * @author Mark Taylor * @since 12 Nov 2008 */ public class LogResultHandler implements ResultHandler { private final String mtype_; private static final Logger logger_ = Logger.getLogger( LogResultHandler.class.getName() ); /** * Constructor. * * @param msg message which was sent */ public LogResultHandler( Message msg ) { mtype_ = msg.getMType(); } public void result( Client client, Response response ) { if ( response.isOK() ) { logger_.info( mtype_ + ": successful send to " + client ); } else { logger_.warning( mtype_ + ": error sending to " + client ); ErrInfo errInfo = response.getErrInfo(); if ( errInfo != null ) { String errortxt = errInfo.getErrortxt(); if ( errortxt != null ) { logger_.warning( errortxt ); } logger_.info( SampUtils.formatObject( errInfo, 3 ) ); } } } public void done() { } } jsamp/src/java/org/astrogrid/samp/client/package.html0000664000175000017500000000046012730747754022504 0ustar sladensladen Classes required only for SAMP clients.

Clients will normally use a {@link org.astrogrid.samp.client.HubConnector} to keep track of connections with a SAMP hub. However clients requiring a lower-level interface may simply use a {@link org.astrogrid.samp.client.HubConnection} object. jsamp/src/java/org/astrogrid/samp/gui/0000775000175000017500000000000012730747754017531 5ustar sladensladenjsamp/src/java/org/astrogrid/samp/gui/SendActionManager.java0000664000175000017500000003221312730747754023717 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.Toolkit; import java.awt.event.ActionEvent; import java.util.ArrayList; import java.util.Arrays; import java.util.Iterator; import java.util.List; import java.util.logging.Logger; import javax.swing.AbstractAction; import javax.swing.AbstractListModel; import javax.swing.Action; import javax.swing.ComboBoxModel; import javax.swing.Icon; import javax.swing.ImageIcon; import javax.swing.JMenu; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; import javax.swing.event.ListDataEvent; import javax.swing.event.ListDataListener; import javax.swing.ListModel; import javax.swing.SwingUtilities; import org.astrogrid.samp.Client; /** * Manages actions to send SAMP messages to one or all recipients. * The main useful trick that this class can do is to maintain one or * more menus for sending messages to suitable recipients. * The contents of these menus are updated automatically depending on * the subscriptions of all the currently registered SAMP clients. * *

Note: concrete subclasses must call {@link #updateState} before use * (in the constructor). * * @author Mark Taylor * @since 2 Sep 2008 */ public abstract class SendActionManager { private final GuiHubConnector connector_; final ListModel subscribedClientModel_; private final List menuList_; private final ListDataListener subscriptionListener_; private final ChangeListener connectionListener_; private boolean enabled_; private Action broadcastAct_; private boolean broadcastActCreated_; private Action[] sendActs_; private static Icon SEND_ICON; private static Icon BROADCAST_ICON; private static final Logger logger_ = Logger.getLogger( SendActionManager.class.getName() ); /** ComboBox element indicating broadcast to all clients. */ public static final String BROADCAST_TARGET = "All Clients"; /** * Constructor. * * @param connector hub connector * @param clientListModel list model containing only those * clients which are suitable recipients; * all elements must be {@link org.astrogrid.samp.Client}s */ protected SendActionManager( GuiHubConnector connector, ListModel clientListModel ) { connector_ = connector; subscribedClientModel_ = clientListModel; subscriptionListener_ = new ListDataListener() { public void intervalAdded( ListDataEvent evt ) { updateState(); } public void intervalRemoved( ListDataEvent evt ) { updateState(); } public void contentsChanged( ListDataEvent evt ) { updateState(); } }; subscribedClientModel_.addListDataListener( subscriptionListener_ ); // Ensure that changes to the connection status are reflected. connectionListener_ = new ChangeListener() { public void stateChanged( ChangeEvent evt ) { updateEnabledness(); } }; connector.addConnectionListener( connectionListener_ ); // Initialise other state. enabled_ = true; menuList_ = new ArrayList(); } /** * Returns a new action for broadcast associated with this object. * The enabled status of the action will be managed by this object. * * @return broadcast action; may be null if broadcast is not required */ protected abstract Action createBroadcastAction(); /** * Returns an action which can perform a single-client send associated * with this object. If it implements equals * (and hashCode) intelligently there will be efficiency * advantages. * The enabled status of such actions will be managed by this object. * * @param client recipient client * @return action which sends to the given client */ protected abstract Action getSendAction( Client client ); /** * Sets the enabled status of this object. This acts as a restriction * (AND) on the enabled status of the menus and actions controlled by * this object. If there are no suitable recipient applications * registered they will be disabled anyway. * * @param enabled false to ensure that the actions are disabled, * true means they may be enabled */ public void setEnabled( boolean enabled ) { enabled_ = enabled; updateEnabledness(); } /** * Returns an action which will broadcast a message * to all suitable registered applications. * *

This action is currently not disabled when there are no suitable * listeners, mainly for debugging purposes (so you can see if a * message is getting sent and what it looks like even in absence of * suitable listeners). * * @return broadcast action */ public Action getBroadcastAction() { if ( ! broadcastActCreated_ ) { broadcastAct_ = createBroadcastAction(); broadcastActCreated_ = true; updateEnabledness(); } return broadcastAct_; } /** * Returns a new menu which provides options to send a message to * one of the registered listeners at a time. This menu will be * disabled when no suitable listeners are registered. * * @param name menu title * @return new message send menu */ public JMenu createSendMenu( String name ) { JMenu menu = new JMenu( name ); for ( int is = 0; is < sendActs_.length; is++ ) { menu.add( sendActs_[ is ] ); } menuList_.add( menu ); updateEnabledness(); return menu; } /** * Releases any resources associated with a menu previously created * using {@link #createSendMenu}. Don't use the menu again. * * @param menu previously created send menu */ public void disposeSendMenu( JMenu menu ) { menuList_.remove( menu ); } /** * Releases any resources associated with this object. */ public void dispose() { subscribedClientModel_.removeListDataListener( subscriptionListener_ ); if ( subscribedClientModel_ instanceof SubscribedClientListModel ) { ((SubscribedClientListModel) subscribedClientModel_).dispose(); } connector_.removeConnectionListener( connectionListener_ ); } /** * Updates the state of actions managed by this object when the * list of registered listeners has changed. */ public void updateState() { // Get a list of actions for the currently subscribed clients. int nsub = subscribedClientModel_.getSize(); Action[] sendActs = new Action[ nsub ]; for ( int ia = 0; ia < nsub; ia++ ) { sendActs[ ia ] = getSendAction( (Client) subscribedClientModel_.getElementAt( ia ) ); } // Update menus if required. if ( ! Arrays.equals( sendActs, sendActs_ ) ) { sendActs_ = sendActs; for ( Iterator menuIt = menuList_.iterator(); menuIt.hasNext(); ) { JMenu menu = (JMenu) menuIt.next(); menu.removeAll(); for ( int is = 0; is < sendActs.length; is++ ) { menu.add( sendActs[ is ] ); } } updateEnabledness(); } } /** * Returns the client list to which this manager will offer sends. * * @return listmodel whose elements are suitably subscribed {@link Client}s */ public ListModel getClientListModel() { return subscribedClientModel_; } /** * Returns a new ComboBoxModel containing selections for each suitable * client and an additional selection for broadcast to all clients. * Elements are {@link org.astrogrid.samp.Client} objects, or * {@link #BROADCAST_TARGET} to indicate broadcast. * The result of this is suitable for use with {@link #createTargetAction}. * * @return new client combo box model */ public ComboBoxModel createTargetSelector() { return new TargetComboBoxModel( subscribedClientModel_ ); } /** * Returns an action suitable for sending the message represented by * this manager to a target selected by a supplied ComboBoxModel. * This model is typically the result of calling * {@link #createTargetSelector}. * * @param targetSelector combo box model in which the elements are * {@link org.astrogrid.samp.Client} objects, * or {@link #BROADCAST_TARGET} null to indicate broadcast */ public Action createTargetAction( final ComboBoxModel targetSelector ) { return new AbstractAction( "Send to selected target" ) { public void actionPerformed( ActionEvent evt ) { Object target = targetSelector.getSelectedItem(); if ( target instanceof Client ) { getSendAction( (Client) target ); } else if ( BROADCAST_TARGET.equals( target ) ) { getBroadcastAction().actionPerformed( evt ); } else { Toolkit.getDefaultToolkit().beep(); logger_.warning( "Unknown send target: " + target + " - no action" ); } } }; } /** * Returns this manager's hub connector. * * @return connector */ public GuiHubConnector getConnector() { return connector_; } /** * Updates the enabled status of controlled actions in accordance with * this object's current state. */ private void updateEnabledness() { boolean active = enabled_ && connector_.isConnected() && sendActs_.length > 0; if ( broadcastAct_ != null ) { broadcastAct_.setEnabled( active ); } for ( Iterator it = menuList_.iterator(); it.hasNext(); ) { ((JMenu) it.next()).setEnabled( active ); } } /** * Returns an icon suitable for depicting a general targetted send. * * @return send icon */ public static Icon getSendIcon() { if ( SEND_ICON == null ) { SEND_ICON = IconStore.createResourceIcon( "phone2.gif" ); } return SEND_ICON; } /** * Returns an icon suitable for depicting a general broadcast send. * * @return broadcast icon */ public static Icon getBroadcastIcon() { if ( BROADCAST_ICON == null ) { BROADCAST_ICON = IconStore.createResourceIcon( "tx3.gif" ); } return BROADCAST_ICON; } /** * ComboBoxModel implementation used for selecting a target client. * It essentiall mirrors an existing client model but prepends a * broadcast option. */ private static class TargetComboBoxModel extends AbstractListModel implements ComboBoxModel { private final ListModel clientListModel_; private Object selectedItem_ = BROADCAST_TARGET; /** * Constructor. * * @param clientListModel list model containing suitable * {@link org.astrogrid.samp.Client}s */ TargetComboBoxModel( ListModel clientListModel ) { clientListModel_ = clientListModel; /* Watch the underlying client model for changes and * update this one accordingly. */ clientListModel_.addListDataListener( new ListDataListener() { public void contentsChanged( ListDataEvent evt ) { fireContentsChanged( evt.getSource(), adjustIndex( evt.getIndex0() ), adjustIndex( evt.getIndex1() ) ); } public void intervalAdded( ListDataEvent evt ) { fireIntervalAdded( evt.getSource(), adjustIndex( evt.getIndex0() ), adjustIndex( evt.getIndex1() ) ); } public void intervalRemoved( ListDataEvent evt ) { fireIntervalRemoved( evt.getSource(), adjustIndex( evt.getIndex0() ), adjustIndex( evt.getIndex1() ) ); } private int adjustIndex( int index ) { return index >= 0 ? index + 1 : index; } } ); } public int getSize() { return clientListModel_.getSize() + 1; } public Object getElementAt( int index ) { return index == 0 ? BROADCAST_TARGET : clientListModel_.getElementAt( index - 1 ); } public Object getSelectedItem() { return selectedItem_; } public void setSelectedItem( Object item ) { selectedItem_ = item; } } } jsamp/src/java/org/astrogrid/samp/gui/TransmissionView.java0000664000175000017500000000512712730747754023725 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.BorderLayout; import java.awt.Dimension; import javax.swing.BorderFactory; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JSplitPane; import javax.swing.JTable; import javax.swing.ListModel; import javax.swing.ListSelectionModel; import javax.swing.border.BevelBorder; import javax.swing.event.ListSelectionEvent; import javax.swing.event.ListSelectionListener; import javax.swing.table.DefaultTableColumnModel; /** * Displays a set of transmissions in a table model, * along with a detail panel for the selected one. * * @author Mark Taylor * @since 5 Dec 2008 */ class TransmissionView extends JPanel { /** * Constructor. * * @param transModel table model containing transmissions */ public TransmissionView( final TransmissionTableModel transModel ) { super( new BorderLayout() ); final TransmissionPanel transPanel = new TransmissionPanel(); transPanel.setBorder( BorderFactory .createBevelBorder( BevelBorder.LOWERED ) ); final JTable table = new JTable( transModel ); Dimension tableSize = table.getPreferredScrollableViewportSize(); tableSize.height = 80; table.setPreferredScrollableViewportSize( tableSize ); DefaultTableColumnModel tcolModel = new DefaultTableColumnModel(); for ( int icol = 0; icol < transModel.getColumnCount(); icol++ ) { tcolModel.addColumn( transModel.getTableColumn( icol ) ); } table.setColumnModel( tcolModel ); final ListSelectionModel selModel = table.getSelectionModel(); selModel.setSelectionMode( ListSelectionModel.SINGLE_SELECTION ); selModel.addListSelectionListener( new ListSelectionListener() { public void valueChanged( ListSelectionEvent evt ) { Object src = evt.getSource(); if ( ! selModel.getValueIsAdjusting() && ! selModel.isSelectionEmpty() ) { Transmission trans = transModel.getTransmission( table.getSelectedRow() ); transPanel.setTransmission( trans ); } } } ); JSplitPane splitter = new JSplitPane( JSplitPane.VERTICAL_SPLIT ); add( splitter, BorderLayout.CENTER ); splitter.setTopComponent( new JScrollPane( table ) ); splitter.setBottomComponent( transPanel ); Dimension splitSize = splitter.getPreferredSize(); splitSize.height = 180; splitter.setPreferredSize( splitSize ); } } jsamp/src/java/org/astrogrid/samp/gui/ClientListCellRenderer.java0000664000175000017500000001124412730747754024737 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.Component; import java.awt.Graphics; import java.awt.Font; import java.awt.Graphics2D; import java.awt.RenderingHints; import java.util.HashMap; import java.util.Map; import javax.swing.DefaultListCellRenderer; import javax.swing.Icon; import javax.swing.JLabel; import javax.swing.JList; import org.astrogrid.samp.Client; import org.astrogrid.samp.Metadata; /** * List Cell Renderer for use with {@link org.astrogrid.samp.Client} objects. * * @author Mark Taylor * @since 16 Jul 2008 */ class ClientListCellRenderer extends DefaultListCellRenderer { private Font[] labelFonts_; private IconStore iconStore_; private final Map addHints_; /** * Constructor. */ public ClientListCellRenderer() { iconStore_ = new IconStore( IconStore.createEmptyIcon( 16 ) ); addHints_ = new HashMap(); addHints_.put( RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY ); addHints_.put( RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC ); } /** * Attempts to return a human-readable text label for the given client. * * @param client to find label for * @return human-readable label for client if available; if nothing * better than the public ID can be found, null is returned */ protected String getLabel( Client client ) { Metadata meta = client.getMetadata(); return meta != null ? meta.getName() : null; } protected void paintComponent( Graphics g ) { // Improve the rendering as much as possible, since we are typically // rendering some very small graphics that can look ugly if // the resampling puts pixels out of place. Graphics2D g2 = (Graphics2D) g; RenderingHints oldHints = g2.getRenderingHints(); g2.addRenderingHints( addHints_ ); super.paintComponent( g ); g2.setRenderingHints( oldHints ); } public Component getListCellRendererComponent( JList list, Object value, int index, boolean isSel, boolean hasFocus ) { Component c = super.getListCellRendererComponent( list, value, index, isSel, hasFocus ); if ( c instanceof JLabel && value instanceof Client ) { JLabel jl = (JLabel) c; Client client = (Client) value; String label = getLabel( client ); String text = label == null ? client.getId() : label; Font font = getLabelFont( label == null ); int size; try { size = (int) Math.ceil( font.getMaxCharBounds( ((Graphics2D) list.getGraphics()) .getFontRenderContext() ) .getHeight() ); } catch ( NullPointerException e ) { size = 16; } jl.setText( text ); jl.setFont( font ); jl.setIcon( reshapeIcon( iconStore_.getIcon( client ), size ) ); } return c; } /** * Returns the font used by this label, or a variant. * * @param special true if the font is to look a bit different * @return font */ private Font getLabelFont( boolean special ) { if ( labelFonts_ == null ) { Font normalFont = getFont().deriveFont( Font.BOLD ); Font aliasFont = getFont().deriveFont( Font.PLAIN ); labelFonts_ = new Font[] { normalFont, aliasFont }; } return labelFonts_[ special ? 1 : 0 ]; } /** * Modifies an icon so that it has a fixed shape and positioning. * * @param icon input icon * @param height fixed icon height * @return reshaped icon */ static Icon reshapeIcon( Icon icon, final int height ) { double aspect = 2.0; final int width = (int) Math.ceil( aspect * height ); final Icon sIcon = IconStore.scaleIcon( icon, height, aspect, true ); final int xoff = ( width - sIcon.getIconWidth() ) / 2; return new Icon() { public int getIconWidth() { return width; } public int getIconHeight() { return height; } public void paintIcon( Component c, Graphics g, int x, int y ) { sIcon.paintIcon( c, g, x + xoff, y ); } }; } } jsamp/src/java/org/astrogrid/samp/gui/GuiHubConnector.java0000664000175000017500000006430712730747754023444 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.BorderLayout; import java.awt.Color; import java.awt.Component; import java.awt.Dimension; import java.awt.Graphics; import java.awt.Insets; import java.awt.Toolkit; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.WeakHashMap; import javax.swing.AbstractAction; import javax.swing.Action; import javax.swing.BorderFactory; import javax.swing.Icon; import javax.swing.ImageIcon; import javax.swing.JButton; import javax.swing.JComponent; import javax.swing.JDialog; import javax.swing.JFrame; import javax.swing.JLabel; import javax.swing.JOptionPane; import javax.swing.JTextField; import javax.swing.ListCellRenderer; import javax.swing.ListModel; import javax.swing.SwingUtilities; import javax.swing.border.Border; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; import javax.swing.event.ListDataEvent; import javax.swing.event.ListDataListener; import org.astrogrid.samp.Client; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.HubConnector; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.client.TrackedClientSet; import org.astrogrid.samp.hub.Hub; import org.astrogrid.samp.hub.HubServiceMode; import org.astrogrid.samp.xmlrpc.XmlRpcKit; /** * Extends HubConnector to provide additional graphical functionality. * In particular Swing {@link javax.swing.Action}s are provided for * hub connection/disconnection * and the client list is made available as a {@link javax.swing.ListModel}. * See the {@link org.astrogrid.samp.client.HubConnector superclass} * documentation for details of how to use this class. * A number of utility methods build on these features to provide * Swing components and Actions which can be used directly to populate * application menus etc. * * @author Mark Taylor * @since 25 Nov 2008 */ public class GuiHubConnector extends HubConnector { private final ListModel clientListModel_; private final List connectionListenerList_; private final Map updateMap_; private boolean wasConnected_; static ConnectionUpdate ENABLE_ACTION = new ConnectionUpdate() { public void setConnected( Object action, boolean isConnected ) { ((Action) action).setEnabled( isConnected ); } }; static ConnectionUpdate DISABLE_ACTION = new ConnectionUpdate() { public void setConnected( Object action, boolean isConnected ) { ((Action) action).setEnabled( ! isConnected ); } }; static ConnectionUpdate REPAINT_COMPONENT = new ConnectionUpdate() { public void setConnected( Object comp, boolean isConnected ) { ((Component) comp).repaint(); } }; static ConnectionUpdate ENABLE_COMPONENT = new ConnectionUpdate() { public void setConnected( Object comp, boolean isConnected ) { ((Component) comp).setEnabled( isConnected ); } }; /** * Constructs a hub connector based on a given profile instance. * * @param profile profile implementation */ public GuiHubConnector( ClientProfile profile ) { super( profile, new ListModelTrackedClientSet() ); clientListModel_ = (ListModelTrackedClientSet) getClientSet(); connectionListenerList_ = new ArrayList(); updateMap_ = new WeakHashMap(); // Update state when hub connection starts/stops. addConnectionListener( new ChangeListener() { public void stateChanged( ChangeEvent evt ) { updateConnectionState(); } } ); updateConnectionState(); } protected void connectionChanged( boolean isConnected ) { super.connectionChanged( isConnected ); SwingUtilities.invokeLater( new Runnable() { public void run() { ChangeEvent evt = new ChangeEvent( GuiHubConnector.this ); for ( Iterator it = connectionListenerList_.iterator(); it.hasNext(); ) { ((ChangeListener) it.next()).stateChanged( evt ); } } } ); } /** * Adds a listener which will be notified when this connector * registers or unregisters with a hub. * * @param listener listener to add */ public void addConnectionListener( ChangeListener listener ) { connectionListenerList_.add( listener ); } /** * Removes a listener previously added by * {@link #addConnectionListener addConnectionListener}. * * @param listener listener to remove */ public void removeConnectionListener( ChangeListener listener ) { connectionListenerList_.remove( listener ); } /** * Returns a ListModel containing the registered clients. * Listeners to this model are correctly notified whenever any change * in its contents takes place. * * @return list model containing {@link Client} objects */ public ListModel getClientListModel() { return clientListModel_; } /** * Returns a list cell renderer suitable for use with the * client list model returned by {@link #getClientListModel}. * * @return list cell renderer for Client objects */ public ListCellRenderer createClientListCellRenderer() { return new ClientListCellRenderer(); } /** * Returns an action which attempts to register with the hub. * Disabled when already registered. * * @return registration action */ public Action createRegisterAction() { Action regAct = new RegisterAction( true ); registerUpdater( regAct, DISABLE_ACTION ); return regAct; } /** * Returns an action which attempts to unregister from the hub. * Disabled when already unregistered. * * @return unregistration action */ public Action createUnregisterAction() { Action unregAct = new RegisterAction( false ); registerUpdater( unregAct, ENABLE_ACTION ); return unregAct; } /** * Returns an action which toggles hub registration. * * @return registration toggler action */ public Action createToggleRegisterAction() { RegisterAction toggleRegAct = new RegisterAction(); registerUpdater( toggleRegAct, new ConnectionUpdate() { public void setConnected( Object item, boolean isConnected ) { ((RegisterAction) item).setSense( ! isConnected ); } } ); return toggleRegAct; } /** * Returns a new action which will register with a hub if one is running, * and if not, will offer to start a hub. * The exact options for starting a hub are given by the * hubStartActions parameter - the elements of this array * will normally be generated by calling the * {@link #createHubAction createHubAction} method. * * @param parent parent component, used for placing dialogue * @param hubStartActions actions which start a hub, * or null for a default list */ public Action createRegisterOrHubAction( final Component parent, Action[] hubStartActions ) { final Action[] hubActs; if ( hubStartActions != null ) { hubActs = hubStartActions; } else { HubServiceMode internalMode = SysTray.getInstance().isSupported() ? HubServiceMode.CLIENT_GUI : HubServiceMode.NO_GUI; hubActs = new Action[] { createHubAction( false, internalMode ), createHubAction( true, HubServiceMode.MESSAGE_GUI ), }; } Action regAct = new RegisterAction() { protected void registerFailed() { Object msg = new String[] { "No SAMP hub is running.", "You may start a hub if you wish.", }; List buttList = new ArrayList(); JButton[] options = new JButton[ hubActs.length + 1 ]; for ( int i = 0; i < hubActs.length; i++ ) { options[ i ] = new JButton( hubActs[ i ] ); } options[ hubActs.length ] = new JButton( "Cancel" ); final JDialog dialog = new JOptionPane( msg, JOptionPane.WARNING_MESSAGE, JOptionPane.DEFAULT_OPTION, null, options, null ) .createDialog( parent, "No Hub" ); ActionListener closeListener = new ActionListener() { public void actionPerformed( ActionEvent evt ) { dialog.dispose(); } }; for ( int iopt = 0; iopt < options.length; iopt++ ) { options[ iopt ].addActionListener( closeListener ); } dialog.setVisible( true ); } }; registerUpdater( regAct, new ConnectionUpdate() { public void setConnected( Object item, boolean isConnected ) { ((RegisterAction) item).setSense( ! isConnected ); } } ); return regAct; } /** * Returns an action which will display a SAMP hub monitor window. * * @return monitor window action */ public Action createShowMonitorAction() { return new MonitorAction(); } /** * Returns an action which will start up a SAMP hub. * You can specify whether it runs in the current JVM or a newly * created one; in the former case, it will shut down when the * current application does. * * @param external false to run in the current JVM, * true to run in a new one * @param hubMode hub mode */ public Action createHubAction( boolean external, HubServiceMode hubMode ) { return new HubAction( external, hubMode ); } /** * Creates a component which indicates whether this connector is currently * connected or not, using supplied icons. * * @param onIcon icon indicating connection * @param offIcon icon indicating no connection * @return connection indicator */ public JComponent createConnectionIndicator( final Icon onIcon, final Icon offIcon ) { JLabel label = new JLabel( new Icon() { private Icon effIcon() { return isConnected() ? onIcon : offIcon; } public int getIconWidth() { return effIcon().getIconWidth(); } public int getIconHeight() { return effIcon().getIconHeight(); } public void paintIcon( Component c, Graphics g, int x, int y ) { effIcon().paintIcon( c, g, x, y ); } } ); registerUpdater( label, REPAINT_COMPONENT ); return label; } /** * Creates a component which indicates whether this connector is currently * connected or not, using default icons. * * @return connection indicator */ public JComponent createConnectionIndicator() { return createConnectionIndicator( new ImageIcon( Client.class .getResource( "images/connected-24.gif" ) ), new ImageIcon( Client.class .getResource( "images/disconnected-24.gif" ) ) ); } /** * Creates a component which shows an icon for each registered client. * * @param vertical true for vertical box, false for horizontal * @param iconSize dimension in pixel of each icon (square) */ public JComponent createClientBox( final boolean vertical, int iconSize ) { final IconStore iconStore = new IconStore( IconStore.createMinimalIcon( iconSize ) ); IconBox box = new IconBox( iconSize ); box.setVertical( vertical ); box.setBorder( createBoxBorder() ); box.setModel( clientListModel_ ); box.setRenderer( new IconBox.CellRenderer() { public Icon getIcon( IconBox iconBox, Object value, int index ) { return IconStore.scaleIcon( iconStore.getIcon( (Client) value ), iconBox.getTransverseSize(), 2.0, ! vertical ); } public String getToolTipText( IconBox iconBox, Object value, int index ) { return ((Client) value).toString(); } } ); Dimension boxSize = box.getPreferredSize(); boxSize.width = 128; box.setPreferredSize( boxSize ); registerUpdater( box, ENABLE_COMPONENT ); return box; } /** * Returns a new component which displays status for this connector. * * @return new hub connection monitor component */ public JComponent createMonitorPanel() { HubView view = new HubView( false ); view.setClientListModel( getClientListModel() ); view.getClientList().setCellRenderer( createClientListCellRenderer() ); return view; } /** * Called when the connection status (registered/unregistered) may have * changed. May be called from any thread. */ private void scheduleConnectionChange() { SwingUtilities.invokeLater( new Runnable() { public void run() { boolean isConnected = isConnected(); if ( isConnected != wasConnected_ ) { wasConnected_ = isConnected; ChangeEvent evt = new ChangeEvent( GuiHubConnector.this ); for ( Iterator it = connectionListenerList_.iterator(); it.hasNext(); ) { ((ChangeListener) it.next()).stateChanged( evt ); } } } } ); } /** * Called when the connection status has changed, or may have changed. */ private void updateConnectionState() { boolean isConn = isConnected(); for ( Iterator it = updateMap_.entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); Object item = entry.getKey(); ConnectionUpdate update = (ConnectionUpdate) entry.getValue(); update.setConnected( item, isConn ); } } /** * Returns a border suitable for icon boxes. * * @return border */ static Border createBoxBorder() { return BorderFactory.createCompoundBorder( new JTextField().getBorder(), BorderFactory.createEmptyBorder( 1, 1, 1, 1 ) ); } /** * Adds a given item to the list of objects which will be notified * when the hub is connected/disconnected. By doing it like this * rather than with the usual listener mechanism the problem of * retaining references to otherwise unused listeners is circumvented. * * @param item object to be notified * @param updater object which performs the notification on hub * connect/disconnect */ void registerUpdater( Object item, ConnectionUpdate updater ) { updater.setConnected( item, isConnected() ); updateMap_.put( item, updater ); } /** * Interface defining how an object is to be notified when the hub * connection status changes. */ interface ConnectionUpdate { /** * Invoked when hub connection status changes. * * @param item which is being notified * @param isConnected whether the hub is now connected or not */ void setConnected( Object item, boolean isConnected ); } /** * TrackedClientSet implementation used by this class. * Implements ListModel as well. */ private static class ListModelTrackedClientSet extends TrackedClientSet implements ListModel { private final List clientList_; private final List listenerList_; /** * Constructor. */ ListModelTrackedClientSet() { clientList_ = new ArrayList(); listenerList_ = new ArrayList(); } public int getSize() { return clientList_.size(); } public Object getElementAt( int index ) { return clientList_.get( index ); } public void addListDataListener( ListDataListener listener ) { listenerList_.add( listener ); } public void removeListDataListener( ListDataListener listener ) { listenerList_.remove( listener ); } public void addClient( final Client client ) { super.addClient( client ); SwingUtilities.invokeLater( new Runnable() { public void run() { int index = clientList_.size(); clientList_.add( client ); ListDataEvent evt = new ListDataEvent( ListModelTrackedClientSet.this, ListDataEvent.INTERVAL_ADDED, index, index ); for ( Iterator it = listenerList_.iterator(); it.hasNext(); ) { ((ListDataListener) it.next()).intervalAdded( evt ); } } } ); } public void removeClient( final Client client ) { super.removeClient( client ); SwingUtilities.invokeLater( new Runnable() { public void run() { int index = clientList_.indexOf( client ); assert index >= 0; if ( index >= 0 ) { clientList_.remove( index ); ListDataEvent evt = new ListDataEvent( ListModelTrackedClientSet.this, ListDataEvent.INTERVAL_REMOVED, index, index ); for ( Iterator it = listenerList_.iterator(); it.hasNext(); ) { ((ListDataListener) it.next()) .intervalRemoved( evt ); } } } } ); } public void setClients( final Client[] clients ) { super.setClients( clients ); SwingUtilities.invokeLater( new Runnable() { public void run() { int oldSize = clientList_.size(); if ( oldSize > 0 ) { clientList_.clear(); ListDataEvent removeEvt = new ListDataEvent( ListModelTrackedClientSet.this, ListDataEvent.INTERVAL_REMOVED, 0, oldSize - 1); for ( Iterator it = listenerList_.iterator(); it.hasNext(); ) { ((ListDataListener) it.next()) .intervalRemoved( removeEvt ); } } if ( clients.length > 0 ) { clientList_.addAll( Arrays.asList( clients ) ); int newSize = clientList_.size(); ListDataEvent addEvt = new ListDataEvent( ListModelTrackedClientSet.this, ListDataEvent.INTERVAL_ADDED, 0, newSize - 1); for ( Iterator it = listenerList_.iterator(); it.hasNext(); ) { ((ListDataListener) it.next()) .intervalAdded( addEvt ); } } } } ); } public void updateClient( final Client client, boolean metaChanged, boolean subsChanged ) { super.updateClient( client, metaChanged, subsChanged ); SwingUtilities.invokeLater( new Runnable() { public void run() { int index = clientList_.indexOf( client ); if ( index >= 0 ) { ListDataEvent evt = new ListDataEvent( ListModelTrackedClientSet.this, ListDataEvent.CONTENTS_CHANGED, index, index ); for ( Iterator it = listenerList_.iterator(); it.hasNext(); ) { ((ListDataListener) it.next()) .contentsChanged( evt ); } } } } ); } } /** * Action which registers and unregisters with the hub. */ private class RegisterAction extends AbstractAction { /** * Constructs in an unarmed state. */ public RegisterAction() { } /** * Constructs with a given (initial) sense. * * @param active true to register, false to unregister */ public RegisterAction( boolean active ) { this(); setSense( active ); } /** * Sets whether this action registers or unregisters. * * @param active true to register, false to unregister */ public void setSense( boolean active ) { putValue( ACTION_COMMAND_KEY, active ? "REGISTER" : "UNREGISTER" ); putValue( NAME, active ? "Register with Hub" : "Unregister from Hub" ); putValue( SHORT_DESCRIPTION, active ? "Attempt to connect to SAMP hub" : "Disconnect from SAMP hub" ); } public void actionPerformed( ActionEvent evt ) { String cmd = evt.getActionCommand(); if ( "REGISTER".equals( cmd ) ) { setActive( true ); if ( ! isConnected() ) { registerFailed(); } } else if ( "UNREGISTER".equals( cmd ) ) { setActive( false ); } else { throw new UnsupportedOperationException( "Unknown action " + cmd ); } } protected void registerFailed() { Toolkit.getDefaultToolkit().beep(); } } /** * Action subclass for popping up a monitor window. */ private class MonitorAction extends AbstractAction { private JFrame monitorWindow_; /** * Constructor. */ MonitorAction() { super( "Show Hub Status" ); putValue( SHORT_DESCRIPTION, "Display a window showing client applications" + " registered with the SAMP hub" ); } public void actionPerformed( ActionEvent evt ) { if ( monitorWindow_ == null ) { monitorWindow_ = new JFrame( "SAMP Status" ); monitorWindow_.getContentPane() .add( createMonitorPanel(), BorderLayout.CENTER ); monitorWindow_.pack(); } monitorWindow_.setVisible( true ); } } /** * Action subclass for running a hub. */ private class HubAction extends AbstractAction { private final boolean external_; private final HubServiceMode hubMode_; private final boolean isAvailable_; /** * Constructor. * * @param external false to run in the current JVM, * true to run in a new one * @param hubMode hub mode */ HubAction( boolean external, HubServiceMode hubMode ) { external_ = external; hubMode_ = hubMode; putValue( NAME, "Start " + ( external ? "external" : "internal" ) + " hub" ); putValue( SHORT_DESCRIPTION, "Attempts to start up a SAMP hub" + ( external ? " running independently of this application" : " running within this application" ) ); setEnabled( ! isConnected() ); registerUpdater( this, DISABLE_ACTION ); boolean isAvailable = true; if ( external ) { try { Hub.checkExternalHubAvailability(); } catch ( Exception e ) { isAvailable = false; } } isAvailable_ = isAvailable; } public void actionPerformed( ActionEvent evt ) { try { attemptRunHub(); } catch ( Exception e ) { ErrorDialog.showError( null, "Hub Start Failed", e.getMessage(), e ); } setActive( true ); } public boolean isEnabled() { return isAvailable_ && super.isEnabled(); } /** * Tries to start a hub, but may throw an exception. */ private void attemptRunHub() throws IOException { if ( external_ ) { Hub.runExternalHub( hubMode_ ); } else { Hub.runHub( hubMode_ ); } setActive( true ); } } } jsamp/src/java/org/astrogrid/samp/gui/MessageTrackerHubService.java0000664000175000017500000004271512730747754025265 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.BorderLayout; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Random; import javax.swing.ImageIcon; import javax.swing.ListModel; import javax.swing.ListSelectionModel; import javax.swing.JComponent; import javax.swing.JList; import javax.swing.JFrame; import javax.swing.JPanel; import javax.swing.JTabbedPane; import javax.swing.SwingUtilities; import javax.swing.event.ListDataEvent; import javax.swing.event.ListDataListener; import org.astrogrid.samp.Client; import org.astrogrid.samp.Message; import org.astrogrid.samp.Response; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.hub.ClientSet; import org.astrogrid.samp.hub.HubClient; import org.astrogrid.samp.hub.ProfileToken; /** * GuiHubService subclass which additionally keeps track of which messages * have been sent and received, and can provide a graphical display of these. * The overhead in maintaining the GUI display can be significant if there is * high volume of message traffic. * * @author Mark Taylor * @since 20 Nov 2008 */ public class MessageTrackerHubService extends GuiHubService implements ClientTransmissionHolder { private final CallMap callMap_; private final TransmissionTableModel transTableModel_; private final int listRemoveDelay_; private final int tableRemoveDelay_; private final int tableMaxRows_; private MessageTrackerClientSet clientSet_; private ListSelectionModel selectionModel_; /** * Constructs a hub service with default message tracker GUI expiry times. * * @param random random number generator */ public MessageTrackerHubService( Random random ) { this( random, 0, 20000, 100 ); } /** * Constructs a hub service with specified message tracker GUI expiry times. * The delay times are times in milliseconds after message resolution * before message representations expire and hence remove themselves * from gui components. * * @param random random number generator * @param listRemoveDelay expiry delay for summary icons in client * list display * @param tableRemoveDelay expiry delay for rows in message * table display * @param tableMaxRows maximum number of rows in message table * (beyond this limit resolved messages may be * removed early) */ public MessageTrackerHubService( Random random, int listRemoveDelay, int tableRemoveDelay, int tableMaxRows ) { super( random ); listRemoveDelay_ = listRemoveDelay; tableRemoveDelay_ = tableRemoveDelay; tableMaxRows_ = tableMaxRows; callMap_ = new CallMap(); // access only from EDT; transTableModel_ = new TransmissionTableModel( true, true, tableRemoveDelay_, tableMaxRows_ ); } public void start() { super.start(); clientSet_ = (MessageTrackerClientSet) getClientSet(); } public ClientSet createClientSet() { return new MessageTrackerClientSet( getIdComparator() ); } public HubClient createClient( String publicId, ProfileToken ptoken ) { return new MessageTrackerHubClient( publicId, ptoken ); } public JComponent createHubPanel() { JTabbedPane tabber = new JTabbedPane(); // Add client view tab. HubView hubView = new HubView( true ); hubView.setClientListModel( getClientListModel() ); JList jlist = hubView.getClientList(); jlist.setCellRenderer( new MessageTrackerListCellRenderer( this ) ); jlist.addMouseListener( new HubClientPopupListener( this ) ); selectionModel_ = jlist.getSelectionModel(); tabber.add( "Clients", hubView ); // Add messages tab. tabber.add( "Messages", new TransmissionView( transTableModel_ ) ); // Position and return. JComponent panel = new JPanel( new BorderLayout() ); panel.add( tabber, BorderLayout.CENTER ); return panel; } public ListSelectionModel getClientSelectionModel() { return selectionModel_; } /** * Returns a ListModel representing the pending messages sent * from a given client. * Elements of the model are {@link Transmission} objects. * * @param client client owned by this hub service * @return transmission list model */ public ListModel getTxListModel( Client client ) { return client instanceof MessageTrackerHubClient ? ((MessageTrackerHubClient) client).txListModel_ : null; } /** * Returns a ListModel representing the pending messages received * by a given client. * Elements of the model are {@link Transmission} objects. * * @param client client owned by this hub service * @return transmission list model */ public ListModel getRxListModel( Client client ) { return client instanceof MessageTrackerHubClient ? ((MessageTrackerHubClient) client).rxListModel_ : null; } protected void reply( HubClient caller, String msgId, final Map response ) throws SampException { // Notify the transmission object corresponding to this response // that the response has been received. final Object callKey = getCallKey( caller, msgId ); SwingUtilities.invokeLater( new Runnable() { public void run() { Transmission trans = callMap_.remove( callKey ); if ( trans != null ) { trans.setResponse( Response.asResponse( response ) ); } } } ); // Forward the call to the base implementation. super.reply( caller, msgId, response ); } /** * Registers a newly created transmission with internal data models * as required. * * @param trans new transmission to track */ private void addTransmission( Transmission trans ) { ((MessageTrackerHubClient) trans.getSender()) .txListModel_.addTransmission( trans ); ((MessageTrackerHubClient) trans.getReceiver()) .rxListModel_.addTransmission( trans ); transTableModel_.addTransmission( trans ); } /** * Returns a key for use in the call map. * Identifies a call/response mode transmission. * * @param receiver message receiver * @param msgId message ID */ private static Object getCallKey( Client receiver, String msgId ) { return new StringBuffer() .append( msgId ) .append( "->" ) .append( receiver.getId() ) .toString(); } /** * HubClient class used by this HubService implementation. */ private class MessageTrackerHubClient extends HubClient { final TransmissionListModel rxListModel_; final TransmissionListModel txListModel_; /** * Constructor. * * @param publicId public ID * @param ptoken connection source */ public MessageTrackerHubClient( String publicId, ProfileToken ptoken ) { super( publicId, ptoken ); // Prepare list models for the transmissions sent/received by // a given client. These models are updated as the hub forwards // messages and responses. The contents of these models are // Transmission objects. txListModel_ = new TransmissionListModel( listRemoveDelay_ ); rxListModel_ = new TransmissionListModel( listRemoveDelay_ ); } public void setCallable( CallableClient callable ) { super.setCallable( callable == null ? null : new MessageTrackerCallableClient( callable, this ) ); } } /** * Wrapper implementation for the CallableClient class which intercepts * calls to update sent and received transmission list models. */ private class MessageTrackerCallableClient implements CallableClient { private final CallableClient base_; private final MessageTrackerHubClient client_; /** * Constructor. * * @param base callable on which this one is based * @param client hub client for which this receiver is operating */ MessageTrackerCallableClient( CallableClient base, MessageTrackerHubClient client ) { base_ = base; client_ = client; } public void receiveCall( String senderId, String msgId, Message msg ) throws SampException { // When a call is received, create a corresponding Transmission // object and add it to both the send list of the sender and // the receive list of the recipient. MessageTrackerHubClient sender = (MessageTrackerHubClient) clientSet_.getFromPublicId( senderId ); MessageTrackerHubClient recipient = client_; final Transmission trans = new Transmission( sender, recipient, msg, null, msgId ); final Object callKey = getCallKey( recipient, msgId ); SwingUtilities.invokeLater( new Runnable() { public void run() { callMap_.add( callKey, trans ); addTransmission( trans ); } } ); // Forward the call to the base implementation. try { base_.receiveCall( senderId, msgId, msg ); } catch ( final Exception e ) { SwingUtilities.invokeLater( new Runnable() { public void run() { trans.setError( e ); } } ); } } public void receiveNotification( String senderId, Message msg ) throws SampException { // When a notification is received, create a corresponding // Transmission object and add it to both the send list of the // sender and the receive list of the recipient. MessageTrackerHubClient sender = (MessageTrackerHubClient) clientSet_.getFromPublicId( senderId ); MessageTrackerHubClient recipient = client_; final Transmission trans = new Transmission( sender, recipient, msg, null, null ); SwingUtilities.invokeLater( new Runnable() { public void run() { addTransmission( trans ); } } ); // Forward the call to the base implementation. Exception error; try { base_.receiveNotification( senderId, msg ); error = null; } catch ( Exception e ) { error = e; } // Since it's a notify, no response will be forthcoming. // So signal a no-response (or send failure) directly. final Throwable err2 = error; SwingUtilities.invokeLater( new Runnable() { public void run() { if ( err2 == null ) { trans.setResponse( null ); } else { trans.setError( err2 ); } } } ); } public void receiveResponse( String responderId, String msgTag, Response response ) throws Exception { // Just forward the call to the base implementation. // Handling the responses happens elsewhere (where we have the // msgId not the msgTag). base_.receiveResponse( responderId, msgTag, response ); } } /** * ClientSet implementation used by this hub service. */ private class MessageTrackerClientSet extends GuiClientSet { private final ListDataListener transListener_; /** * Constructor. * * @param clientIdComparator comparator for client IDs */ MessageTrackerClientSet( Comparator clientIdComparator ) { super( clientIdComparator ); // Prepare a listener which will be notified when a client's // send or receive list changes, or when any of the Transmission // objects in those lists changes state. transListener_ = new ListDataListener() { public void contentsChanged( ListDataEvent evt ) { transmissionChanged( evt ); } public void intervalAdded( ListDataEvent evt ) { transmissionChanged( evt ); } public void intervalRemoved( ListDataEvent evt ) { transmissionChanged( evt ); } private void transmissionChanged( ListDataEvent evt ) { Object src = evt.getSource(); assert src instanceof Transmission; if ( src instanceof Transmission ) { Transmission trans = (Transmission) src; int nclient = getSize(); for ( int ic = 0; ic < nclient; ic++ ) { Client client = (Client) getElementAt( ic ); if ( trans.getSender().equals( client ) || trans.getReceiver().equals( client ) ) { ListDataEvent clientEvt = new ListDataEvent( trans, ListDataEvent .CONTENTS_CHANGED, ic, ic ); fireListDataEvent( clientEvt ); } } } } }; } public void add( HubClient client ) { MessageTrackerHubClient mtClient = (MessageTrackerHubClient) client; final ListModel txListModel = mtClient.txListModel_; final ListModel rxListModel = mtClient.rxListModel_; SwingUtilities.invokeLater( new Runnable() { public void run() { txListModel.addListDataListener( transListener_ ); rxListModel.addListDataListener( transListener_ ); } } ); super.add( client ); } public void remove( HubClient client ) { super.remove( client ); MessageTrackerHubClient mtClient = (MessageTrackerHubClient) client; final TransmissionListModel txListModel = mtClient.txListModel_; final TransmissionListModel rxListModel = mtClient.rxListModel_; SwingUtilities.invokeLater( new Runnable() { public void run() { for ( int i = 0; i < txListModel.getSize(); i++ ) { ((Transmission) txListModel.getElementAt( i )) .setSenderUnregistered(); } for ( int i = 0; i < rxListModel.getSize(); i++ ) { ((Transmission) rxListModel.getElementAt( i )) .setReceiverUnregistered(); } txListModel.removeListDataListener( transListener_ ); rxListModel.removeListDataListener( transListener_ ); } } ); } } /** * Keeps track of transmissions by key. * It works somewhat like a Map, but with the difference that multiple * values may be stored under a single key. */ private static class CallMap { private final Map map_ = new HashMap(); /** * Adds a new entry. * * @param key key * @param trans value */ public void add( Object key, Transmission trans ) { assert SwingUtilities.isEventDispatchThread(); if ( ! map_.containsKey( key ) ) { map_.put( key, new ArrayList( 1 ) ); } ((List) map_.get( key )).add( trans ); } /** * Reads and removes an entry. If multiple values are stored under * the given key, one of them (the first to have been stored) * is returned, and any others are unaffected. * * @param key key * @returh a value correspondig to key */ public Transmission remove( Object key ) { assert SwingUtilities.isEventDispatchThread(); List transList = (List) map_.get( key ); if ( transList == null ) { return null; } else { assert ! transList.isEmpty(); Transmission trans = (Transmission) transList.remove( 0 ); if ( transList.isEmpty() ) { map_.remove( key ); } return trans; } } } } jsamp/src/java/org/astrogrid/samp/gui/HubView.java0000664000175000017500000001166512730747754021756 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.BorderLayout; import java.awt.Dimension; import javax.swing.JList; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JSplitPane; import javax.swing.ListModel; import javax.swing.ListSelectionModel; import javax.swing.event.ListDataEvent; import javax.swing.event.ListDataListener; import javax.swing.event.ListSelectionEvent; import javax.swing.event.ListSelectionListener; import org.astrogrid.samp.Client; import org.astrogrid.samp.Metadata; /** * Component displaying a list of SAMP {@link org.astrogrid.samp.Client}s, * usually those registered with a hub. * * @author Mark Taylor * @since 16 Jul 2008 */ public class HubView extends JPanel { private final JList jlist_; private final ClientPanel clientPanel_; private final ListDataListener listListener_; /** * Constructor. * * @param hubLike true if this will be displaying clients implementing * the HubClient interface */ public HubView( boolean hubLike ) { super( new BorderLayout() ); // Set up a JList to display the list of clients. // If a selection is made, update the client detail panel. jlist_ = new JList(); jlist_.setCellRenderer( new ClientListCellRenderer() ); ListSelectionModel selModel = jlist_.getSelectionModel(); selModel.setSelectionMode( ListSelectionModel.SINGLE_SELECTION ); selModel.addListSelectionListener( new ListSelectionListener() { public void valueChanged( ListSelectionEvent evt ) { if ( ! evt.getValueIsAdjusting() ) { updateClientView(); } } } ); // Watch the list; if any change occurs which may affect the currently- // selected client, update the client detail panel. listListener_ = new ListDataListener() { public void contentsChanged( ListDataEvent evt ) { preferSelection(); int isel = jlist_.getSelectedIndex(); int i0 = evt.getIndex0(); int i1 = evt.getIndex1(); if ( isel >= 0 && ( i0 < 0 || i1 < 0 || ( i0 - isel ) * ( i1 - isel ) <= 0 ) ) { updateClientView(); } } public void intervalRemoved( ListDataEvent evt ) { if ( clientPanel_.getClient() != null && jlist_.getSelectedIndex() < 0 ) { updateClientView(); } } public void intervalAdded( ListDataEvent evt ) { preferSelection(); } }; // Construct and place subcomponents. clientPanel_ = new ClientPanel( hubLike ); JSplitPane splitter = new JSplitPane(); splitter.setOneTouchExpandable( true ); JScrollPane listScroller = new JScrollPane( jlist_ ); listScroller.setPreferredSize( new Dimension( 200, 400 ) ); listScroller.setBorder( ClientPanel.createTitledBorder( "Clients" ) ); splitter.setLeftComponent( listScroller ); splitter.setRightComponent( clientPanel_ ); add( splitter ); } /** * Sets the client list model which is displayed in this component. * * @param clientModel list model whose elements are * {@link org.astrogrid.samp.Client}s */ public void setClientListModel( ListModel clientModel ) { ListModel oldModel = jlist_.getModel(); jlist_.getSelectionModel().clearSelection(); if ( oldModel != null ) { oldModel.removeListDataListener( listListener_ ); } jlist_.setModel( clientModel ); if ( clientModel != null ) { clientModel.addListDataListener( listListener_ ); preferSelection(); } } /** * Returns the JList component which houses the active list of clients. * This can be manipulated to, for instance, customise the renderer * if required. Its model should not be set directly however; use the * {@link #setClientListModel} method. * * @return client JList */ public JList getClientList() { return jlist_; } /** * Ensure that the client panel is up to date with respect to the currently * selected client. */ private void updateClientView() { int isel = jlist_.getSelectedIndex(); clientPanel_.setClient( isel >= 0 ? (Client) jlist_.getModel().getElementAt( isel ) : null ); } /** * Invoked when the list may have just acquired more than zero elements * to select one rather than none of them. */ private void preferSelection() { if ( jlist_.getSelectedIndex() < 0 && jlist_.getModel().getSize() > 0 ) { jlist_.setSelectedIndex( 0 ); } } } jsamp/src/java/org/astrogrid/samp/gui/ClientPanel.java0000664000175000017500000002616512730747754022604 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.BorderLayout; import java.awt.Color; import java.awt.Dimension; import java.awt.Toolkit; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.MouseListener; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.logging.Logger; import javax.swing.BorderFactory; import javax.swing.Box; import javax.swing.AbstractListModel; import javax.swing.JComponent; import javax.swing.JEditorPane; import javax.swing.JLabel; import javax.swing.JList; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JSplitPane; import javax.swing.JTextField; import javax.swing.ListModel; import javax.swing.border.Border; import org.astrogrid.samp.Client; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.hub.HubClient; /** * Component which displays details about a {@link org.astrogrid.samp.Client}. * * @author Mark Taylor * @since 16 Jul 2008 */ public class ClientPanel extends JPanel { private final JTextField idField_; private final JTextField profileField_; private final Box metaBox_; private final JList subsList_; private Client client_; private static final int INFO_WIDTH = 240; private final Logger logger_ = Logger.getLogger( ClientPanel.class.getName() ); /** * Constructor. * * @param hubLike true if this will be displaying clients implementing * the HubClient interface */ public ClientPanel( boolean hubLike ) { super( new BorderLayout() ); JSplitPane splitter = new JSplitPane( JSplitPane.VERTICAL_SPLIT ); splitter.setBorder( BorderFactory.createEmptyBorder() ); splitter.setOneTouchExpandable( true ); splitter.setResizeWeight( 0.5 ); add( splitter, BorderLayout.CENTER ); // Construct and place registration subpanel. Box regBox = Box.createVerticalBox(); regBox.setBorder( createTitledBorder( "Registration" ) ); Box idLine = Box.createHorizontalBox(); idField_ = new JTextField(); idField_.setEditable( false ); idField_.setBorder( BorderFactory.createEmptyBorder() ); idLine.add( new JLabel( "Public ID: " ) ); idLine.add( idField_ ); regBox.add( idLine ); if ( hubLike ) { profileField_ = new JTextField(); profileField_.setEditable( false ); profileField_.setBorder( BorderFactory.createEmptyBorder() ); Box profileLine = Box.createHorizontalBox(); profileLine.add( new JLabel( "Profile: " ) ); profileLine.add( profileField_ ); regBox.add( profileLine ); } else { profileField_ = null; } add( regBox, BorderLayout.NORTH ); // Construct and place metadata subpanel. metaBox_ = Box.createVerticalBox(); JPanel metaPanel = new JPanel( new BorderLayout() ); metaPanel.add( metaBox_, BorderLayout.NORTH ); JScrollPane metaScroller = new JScrollPane( metaPanel ); metaScroller.setBorder( createTitledBorder( "Metadata" ) ); metaScroller.setPreferredSize( new Dimension( INFO_WIDTH, 120 ) ); splitter.setTopComponent( metaScroller ); // Construct and place subscriptions subpanel. Box subsBox = Box.createVerticalBox(); subsList_ = new JList(); JScrollPane subsScroller = new JScrollPane( subsList_ ); subsScroller.setBorder( createTitledBorder( "Subscriptions" ) ); subsScroller.setPreferredSize( new Dimension( INFO_WIDTH, 120 ) ); subsBox.add( subsScroller ); splitter.setBottomComponent( subsBox ); } /** * Updates this component to display the current state of a given client. * * @param client client, or null to clear display */ public void setClient( Client client ) { idField_.setText( client == null ? null : client.getId() ); if ( profileField_ != null ) { profileField_.setText( client instanceof HubClient ? ((HubClient) client).getProfileToken() .getProfileName() : null ); } setMetadata( client == null ? null : client.getMetadata() ); setSubscriptions( client == null ? null : client.getSubscriptions() ); client_ = client; } /** * Returns the most recently displayed client. * * @return client */ public Client getClient() { return client_; } /** * Updates this component's metadata panel to display the current state * of a given metadata object. * * @param meta metadata map, or null to clear metadata display */ public void setMetadata( Metadata meta ) { metaBox_.removeAll(); if ( meta != null ) { for ( Iterator it = meta.entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); String key = (String) entry.getKey(); Object value = entry.getValue(); Box keyBox = Box.createHorizontalBox(); keyBox.add( new JLabel( key + ":" ) ); keyBox.add( Box.createHorizontalGlue() ); metaBox_.add( keyBox ); Box valueBox = Box.createHorizontalBox(); valueBox.add( Box.createHorizontalStrut( 24 ) ); valueBox.add( createViewer( value ) ); metaBox_.add( valueBox ); } } metaBox_.add( Box.createVerticalGlue() ); metaBox_.repaint(); metaBox_.revalidate(); } /** * Updates this component's subscriptions panel to display the current * state of a given subscriptions object. * * @param subs subscriptions map, or null to clear subscriptions display */ public void setSubscriptions( Subscriptions subs ) { final Object[] subscriptions = subs == null ? new Object[ 0 ] : subs.keySet().toArray(); subsList_.setModel( new AbstractListModel() { public int getSize() { return subscriptions.length; } public Object getElementAt( int index ) { return subscriptions[ index ]; } } ); } /** * Attempts to open a URL in some kind of external browser. * * @param url URL to view */ public void openURL( URL url ) throws IOException { BrowserLauncher.openURL( url.toString() ); } /** * Returns a graphical component which displays a legal SAMP object * (SAMP map, list or string). * * @param value SAMP object * @return new component displaying value */ private JComponent createViewer( Object value ) { if ( value instanceof String ) { JTextField field = new JTextField(); field.setEditable( false ); field.setText( (String) value ); field.setCaretPosition( 0 ); try { final URL url = new URL( (String) value ); field.setForeground( Color.BLUE ); field.addMouseListener( new MouseAdapter() { public void mouseClicked( MouseEvent evt ) { try { openURL( url ); } catch ( IOException e ) { Toolkit.getDefaultToolkit().beep(); logger_.warning( "Can't open URL " + url + e ); } } } ); } catch ( MalformedURLException e ) { // not a URL - fine } return field; } else if ( value instanceof List ) { return new JList( ((List) value).toArray() ); } else if ( value instanceof Map ) { JEditorPane edPane = new JEditorPane( "text/html", toHtml( value ) ); edPane.setEditable( false ); edPane.setCaretPosition( 0 ); return edPane; } else { return new JLabel( "???" ); } } /** * Returns an HTML representation of a legal SAMP object * (SAMP map, list or string). * * @param data SAMP object * @return HTML representation of data */ private static String toHtml( Object data ) { StringBuffer sbuf = new StringBuffer(); if ( data instanceof Map ) { sbuf.append( "

\n" ); for ( Iterator it = ((Map) data).entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); sbuf.append( "
" ) .append( htmlEscape( String.valueOf( entry.getKey() ) ) ) .append( "
\n" ) .append( "
" ) .append( toHtml( entry.getValue() ) ) .append( "
\n" ); } sbuf.append( "
\n" ); } else if ( data instanceof List ) { sbuf.append( "
    \n" ); for ( Iterator it = ((List) data).iterator(); it.hasNext(); ) { sbuf.append( "
  • " ) .append( toHtml( it.next() ) ) .append( "
  • \n" ); } sbuf.append( "
\n" ); } else if ( data instanceof String ) { sbuf.append( htmlEscape( (String) data ) ); } else { sbuf.append( "???" ); } return sbuf.toString(); } /** * Escapes a literal string for use within HTML text. * * @param text literal string * @return escaped version of text safe for use within HTML */ private static String htmlEscape( String text ) { int leng = text.length(); StringBuffer sbuf = new StringBuffer( leng ); for ( int i = 0; i < leng; i++ ) { char c = text.charAt( i ); switch ( c ) { case '<': sbuf.append( "<" ); break; case '>': sbuf.append( ">" ); break; case '&': sbuf.append( "&" ); break; default: sbuf.append( c ); } } return sbuf.toString(); } /** * Creates a titled border with a uniform style. * * @param title title text * @return border */ static Border createTitledBorder( String title ) { return BorderFactory.createCompoundBorder( BorderFactory.createEmptyBorder( 5, 5, 5, 5 ), BorderFactory.createTitledBorder( BorderFactory.createLineBorder( Color.BLACK ), title ) ); } } jsamp/src/java/org/astrogrid/samp/gui/GuiClientSet.java0000664000175000017500000001171612730747754022741 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.Iterator; import java.util.List; import javax.swing.ListModel; import javax.swing.SwingUtilities; import javax.swing.event.ListDataEvent; import javax.swing.event.ListDataListener; import org.astrogrid.samp.hub.BasicClientSet; import org.astrogrid.samp.hub.HubClient; import org.astrogrid.samp.hub.MessageRestriction; import org.astrogrid.samp.hub.ProfileToken; /** * ClientSet implementation used by GuiHubService. * It also implements {@link javax.swing.ListModel}. * * @author Mark Taylor * @since 20 Nov 2008 */ class GuiClientSet extends BasicClientSet implements ListModel { private final List clientList_; private final List listenerList_; private final static HubClient MORIBUND_CLIENT = new HubClient( "", new ProfileToken() { public String getProfileName() { return ""; } public MessageRestriction getMessageRestriction() { return null; } } ); /** * Constructor. * * @param clientIdComparator comparator for client IDs */ public GuiClientSet( Comparator clientIdComparator ) { super( clientIdComparator ); clientList_ = Collections.synchronizedList( new ArrayList() ); listenerList_ = new ArrayList(); } public synchronized void add( HubClient client ) { super.add( client ); int index = clientList_.size(); clientList_.add( client ); scheduleListDataEvent( ListDataEvent.INTERVAL_ADDED, index, index ); } public synchronized void remove( HubClient client ) { super.remove( client ); clientList_.remove( client ); int index = clientList_.size(); scheduleListDataEvent( ListDataEvent.INTERVAL_REMOVED, index, index ); } public synchronized HubClient[] getClients() { return (HubClient[]) clientList_.toArray( new HubClient[ 0 ] ); } public Object getElementAt( int index ) { try { return clientList_.get( index ); } // May be called from other than the event dispatch thread. catch ( IndexOutOfBoundsException e ) { return MORIBUND_CLIENT; } } public int getSize() { return clientList_.size(); } public void addListDataListener( ListDataListener l ) { listenerList_.add( l ); } public void removeListDataListener( ListDataListener l ) { listenerList_.remove( l ); } /** * Schedules notification of list data listeners that the attributes * of a client have changed. * May be called from any thread. * * @param client client which has changed */ public synchronized void scheduleClientChanged( HubClient client ) { for ( int ix = 0; ix < clientList_.size(); ix++ ) { if ( clientList_.get( ix ).equals( client ) ) { scheduleListDataEvent( ListDataEvent.CONTENTS_CHANGED, ix, ix ); return; } } scheduleListDataEvent( ListDataEvent.CONTENTS_CHANGED, 0, clientList_.size() ); } /** * Schedules notification of list data listeners about an event. * May be called from any thread. * * @param type ListDataEvent event type * @param int index0 ListDataEvent start index * @param int index1 ListDataEvent end index */ private void scheduleListDataEvent( int type, int index0, int index1 ) { if ( ! listenerList_.isEmpty() ) { final ListDataEvent evt = new ListDataEvent( this, type, index0, index1 ); SwingUtilities.invokeLater( new Runnable() { public void run() { fireListDataEvent( evt ); } } ); } } /** * Passes a ListDataEvent to all listeners. * Must be called from AWT event dispatch thread. * * @param evt event to forward */ public void fireListDataEvent( ListDataEvent evt ) { assert SwingUtilities.isEventDispatchThread(); int type = evt.getType(); for ( Iterator it = listenerList_.iterator(); it.hasNext(); ) { ListDataListener listener = (ListDataListener) it.next(); if ( type == ListDataEvent.INTERVAL_ADDED ) { listener.intervalAdded( evt ); } else if ( type == ListDataEvent.INTERVAL_REMOVED ) { listener.intervalRemoved( evt ); } else if ( type == ListDataEvent.CONTENTS_CHANGED ) { listener.contentsChanged( evt ); } else { assert false; } } } } jsamp/src/java/org/astrogrid/samp/gui/TransmissionListModel.java0000664000175000017500000000730712730747754024711 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.util.ArrayList; import java.util.List; import javax.swing.AbstractListModel; import javax.swing.SwingUtilities; import javax.swing.Timer; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; /** * ListModel implementation for containing {@link Transmission} objects. * This extends the basic ListModel contract as follows: * all ListDataEvents sent to ListDataListeners will have their * source set to the {@link Transmission} object concerned, * and will have both index values equal to each other. * * @author Mark Taylor * @since 24 Nov 2008 */ class TransmissionListModel extends AbstractListModel { private final List list_; private final ChangeListener changeListener_; private int removeDelay_; /** * Constructor. * * @param removeDelay delay in milliseconds after message completion * before transmission is removed from list */ public TransmissionListModel( int removeDelay ) { removeDelay_ = removeDelay; list_ = new ArrayList(); changeListener_ = new ChangeListener() { public void stateChanged( ChangeEvent evt ) { Object src = evt.getSource(); assert src instanceof Transmission; if ( src instanceof Transmission ) { transmissionChanged( (Transmission) src ); } } }; } /** * Called whenever a transmission which is in this list has changed * state. * * @param trans transmission */ private void transmissionChanged( final Transmission trans ) { int index = list_.indexOf( trans ); if ( index >= 0 ) { fireContentsChanged( trans, index, index ); if ( trans.isDone() && removeDelay_ >= 0 ) { long sinceDone = System.currentTimeMillis() - trans.getDoneTime(); long delay = removeDelay_ - sinceDone; if ( delay <= 0 ) { removeTransmission( trans ); } else { ActionListener remover = new ActionListener() { public void actionPerformed( ActionEvent evt ) { removeTransmission( trans ); } }; new Timer( (int) delay + 1, remover ).start(); } } } } public int getSize() { return list_.size(); } public Object getElementAt( int index ) { return list_.get( index ); } /** * Adds a transmission to this list. * * @param trans transmission to add */ public void addTransmission( Transmission trans ) { int index = list_.size(); list_.add( trans ); fireIntervalAdded( trans, index, index ); trans.addChangeListener( changeListener_ ); } /** * Removes a transmission from this list. * * @param trans transmission to remove */ public void removeTransmission( final Transmission trans ) { int index = list_.indexOf( trans ); if ( index >= 0 ) { list_.remove( index ); fireIntervalRemoved( trans, index, index ); } // Defer listener removal to avoid concurrency problems // (trying to remove a listener which generated this event). SwingUtilities.invokeLater( new Runnable() { public void run() { trans.removeChangeListener( changeListener_ ); } } ); } } jsamp/src/java/org/astrogrid/samp/gui/WrapperHubConnection.java0000664000175000017500000000520012730747754024470 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.util.List; import java.util.Map; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.RegInfo; import org.astrogrid.samp.Response; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; /** * HubConnection implementation which delegates all behaviour to a base * implementation. Intended for subclassing. * * @author Mark Taylor * @since 24 Nov 2008 */ class WrapperHubConnection implements HubConnection { private final HubConnection base_; public WrapperHubConnection( HubConnection base ) { base_ = base; } public RegInfo getRegInfo() { return base_.getRegInfo(); } public void setCallable( CallableClient callable ) throws SampException { base_.setCallable( callable ); } public void ping() throws SampException { base_.ping(); } public void unregister() throws SampException { base_.unregister(); } public void declareMetadata( Map meta ) throws SampException { base_.declareMetadata( meta ); } public Metadata getMetadata( String clientId ) throws SampException { return base_.getMetadata( clientId ); } public void declareSubscriptions( Map subs ) throws SampException { base_.declareSubscriptions( subs ); } public Subscriptions getSubscriptions( String clientId ) throws SampException { return base_.getSubscriptions( clientId ); } public String[] getRegisteredClients() throws SampException { return base_.getRegisteredClients(); } public Map getSubscribedClients( String mtype ) throws SampException { return base_.getSubscribedClients( mtype ); } public void notify( String recipientId, Map msg ) throws SampException { base_.notify( recipientId, msg ); } public List notifyAll( Map msg ) throws SampException { return base_.notifyAll( msg ); } public String call( String recipientId, String msgTag, Map msg ) throws SampException { return base_.call( recipientId, msgTag, msg ); } public Map callAll( String msgTag, Map msg ) throws SampException { return base_.callAll( msgTag, msg ); } public Response callAndWait( String recipientId, Map msg, int timeout ) throws SampException { return base_.callAndWait( recipientId, msg, timeout ); } public void reply( String msgId, Map response ) throws SampException { base_.reply( msgId, response ); } } jsamp/src/java/org/astrogrid/samp/gui/SysTray.java0000664000175000017500000001641012730747754022014 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.AWTException; import java.awt.Image; import java.awt.PopupMenu; import java.awt.event.ActionListener; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.logging.Logger; /** * Provides basic access to the windowing system's System Tray. * This is a facade for a subset of the Java 1.6 java.awt.SystemTray * functionality. When running in a J2SE1.6 JRE it will use reflection * to access the underlying classes. In an earlier JRE, it will report * lack of support. * * @author Mark Taylor * @since 20 Jul 2010 */ public abstract class SysTray { private static SysTray instance_; private static final Logger logger_ = Logger.getLogger( SysTray.class.getName() ); /** * Indicates whether system tray functionality is available. * * @return true iff the addIcon/removeIcon methods are expected to work */ public abstract boolean isSupported(); /** * Adds an icon to the system tray. * * @param im image for display * @param tooltip tooltip text, or null * @param popup popup menu, or null * @param iconListener listener triggered when icon is activated, or null * @return tray icon object, may be used for later removal */ public abstract Object addIcon( Image im, String tooltip, PopupMenu popup, ActionListener iconListener ) throws AWTException; /** * Removes a previously-added icon from the tray. * * @param trayIcon object obtained from a previous invocation of * addIcon */ public abstract void removeIcon( Object trayIcon ) throws AWTException; /** * Returns an instance of this class. * * @return instance */ public static SysTray getInstance() { if ( instance_ == null ) { String jvers = System.getProperty( "java.specification.version" ); boolean isJava6 = jvers != null && jvers.matches( "^[0-9]+\\.[0-9]+$" ) && Double.parseDouble( jvers ) > 1.5999; if ( ! isJava6 ) { logger_.info( "Not expecting system tray support" + " (java version < 1.6)" ); } SysTray instance; try { instance = new Java6SysTray(); } catch ( Throwable e ) { if ( isJava6 ) { logger_.info( "No system tray support: " + e ); } instance = new NoSysTray(); } instance_ = instance; } return instance_; } /** * Implementation which provides no system tray access. */ private static class NoSysTray extends SysTray { public boolean isSupported() { return false; } public Object addIcon( Image im, String tooltip, PopupMenu popup, ActionListener iconListener ) { throw new UnsupportedOperationException(); } public void removeIcon( Object trayIcon ) { throw new UnsupportedOperationException(); } } /** * Implementation which provides system tray access using J2SE 1.6 classes * by reflection. */ private static class Java6SysTray extends SysTray { private final Class systemTrayClass_; private final Method addMethod_; private final Method removeMethod_; private final Class trayIconClass_; private final Constructor trayIconConstructor_; private final Method setImageAutoSizeMethod_; private final Method addActionListenerMethod_; private final Object systemTrayInstance_; /** * Constructor. */ Java6SysTray() throws ClassNotFoundException, IllegalAccessException, NoSuchMethodException, InvocationTargetException { systemTrayClass_ = Class.forName( "java.awt.SystemTray" ); trayIconClass_ = Class.forName( "java.awt.TrayIcon" ); addMethod_ = systemTrayClass_ .getMethod( "add", new Class[] { trayIconClass_ } ); removeMethod_ = systemTrayClass_ .getMethod( "remove", new Class[] { trayIconClass_ } ); trayIconConstructor_ = trayIconClass_ .getConstructor( new Class[] { Image.class, String.class, PopupMenu.class } ); setImageAutoSizeMethod_ = trayIconClass_ .getMethod( "setImageAutoSize", new Class[] { boolean.class } ); addActionListenerMethod_ = trayIconClass_ .getMethod( "addActionListener", new Class[] { ActionListener.class } ); boolean isSupported = Boolean.TRUE .equals( systemTrayClass_ .getMethod( "isSupported", new Class[ 0 ] ) .invoke( null, new Object[ 0 ] ) ); systemTrayInstance_ = isSupported ? systemTrayClass_ .getMethod( "getSystemTray", new Class[ 0 ] ) .invoke( null, new Object[ 0 ] ) : null; } public boolean isSupported() { return systemTrayInstance_ != null; } public Object addIcon( Image im, String tooltip, PopupMenu popup, ActionListener iconListener ) throws AWTException { try { Object trayIcon = trayIconConstructor_ .newInstance( new Object[] { im, tooltip, popup } ); setImageAutoSizeMethod_ .invoke( trayIcon, new Object[] { Boolean.TRUE } ); if ( iconListener != null ) { addActionListenerMethod_ .invoke( trayIcon, new Object[] { iconListener } ); } addMethod_.invoke( systemTrayInstance_, new Object[] { trayIcon } ); return trayIcon; } catch ( InvocationTargetException e ) { String msg = e.getCause() instanceof AWTException ? e.getCause().getMessage() : "Add tray icon invocation failed"; throw (AWTException) new AWTException( msg ).initCause( e ); } catch ( Exception e ) { throw (AWTException) new AWTException( "Add tray icon invocation failed" ) .initCause( e ); } } public void removeIcon( Object trayIcon ) throws AWTException { try { removeMethod_.invoke( systemTrayInstance_, new Object[] { trayIcon } ); } catch ( Exception e ) { throw (AWTException) new AWTException( "Remove tray icon invocation failed" ) .initCause( e ); } } } } jsamp/src/java/org/astrogrid/samp/gui/UniformCallActionManager.java0000664000175000017500000001252112730747754025241 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.Component; import java.awt.event.ActionEvent; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; import javax.swing.AbstractAction; import javax.swing.Action; import javax.swing.JMenu; import org.astrogrid.samp.Client; import org.astrogrid.samp.Message; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.HubConnector; import org.astrogrid.samp.client.ResultHandler; /** * SendActionManager subclass which works with messages of a single MType, * using the Aysnchronous Call/Response delivery pattern. * Concrete subclasses need only implement {@link #createMessage()}. * * @author Mark Taylor * @since 11 Nov 2008 */ public abstract class UniformCallActionManager extends AbstractCallActionManager { private final Component parent_; private final String mtype_; private final String sendType_; /** * Constructor. * * @param parent parent component * @param connector hub connector * @param mtype MType for messages transmitted by this object's actions * @param sendType short string identifying the kind of thing being * sent (used for action descriptions etc) */ public UniformCallActionManager( Component parent, GuiHubConnector connector, String mtype, String sendType ) { super( parent, connector, new SubscribedClientListModel( connector, mtype ) ); parent_ = parent; mtype_ = mtype; sendType_ = sendType; } /** * Generates the message which is sent to one or all clients * by this object's actions. * * @return {@link org.astrogrid.samp.Message}-like Map representing * message to transmit */ protected abstract Map createMessage() throws Exception; /** * Implemented simply by calling {@link #createMessage()}. */ protected Map createMessage( Client client ) throws Exception { return createMessage(); } protected Action createBroadcastAction() { return new BroadcastAction(); } /** * Returns a new targetted send menu with a title suitable for this object. * * @return new send menu */ public JMenu createSendMenu() { JMenu menu = super.createSendMenu( "Send " + sendType_ + " to..." ); menu.setIcon( getSendIcon() ); return menu; } public Action getSendAction( Client client ) { Action action = super.getSendAction( client ); action.putValue( Action.SHORT_DESCRIPTION, "Transmit to " + client + " using SAMP " + mtype_ ); return action; } /** * Action for sending broadcast messages. */ private class BroadcastAction extends AbstractAction { /** * Constructor. */ BroadcastAction() { putValue( NAME, "Broadcast " + sendType_ ); putValue( SHORT_DESCRIPTION, "Transmit " + sendType_ + " to all applications" + " listening using the SAMP protocol" ); putValue( SMALL_ICON, getBroadcastIcon() ); } public void actionPerformed( ActionEvent evt ) { HubConnector connector = getConnector(); Set recipientIdSet = null; Message msg = null; HubConnection connection = null; String tag = null; // Attempt to send the message. try { msg = Message.asMessage( createMessage() ); msg.check(); connection = connector.getConnection(); if ( connection != null ) { tag = createTag(); recipientIdSet = connection.callAll( tag, msg ).keySet(); } } catch ( Exception e ) { ErrorDialog.showError( parent_, "Send Error", "Send failure " + e.getMessage(), e ); } // If it was sent, arrange for the results to be passed to // a suitable result handler. if ( recipientIdSet != null ) { assert connection != null; assert msg != null; assert tag != null; List recipientList = new ArrayList(); Map clientMap = connector.getClientMap(); for ( Iterator it = recipientIdSet.iterator(); it.hasNext(); ) { String id = (String) it.next(); Client recipient = (Client) clientMap.get( id ); if ( recipient != null ) { recipientList.add( recipient ); } } Client[] recipients = (Client[]) recipientList.toArray( new Client[ 0 ] ); ResultHandler handler = createResultHandler( connection, msg, recipients ); if ( recipients.length == 0 ) { if ( handler != null ) { handler.done(); } handler = null; } registerHandler( tag, recipients, handler ); } } } } jsamp/src/java/org/astrogrid/samp/gui/GuiHubService.java0000664000175000017500000002147012730747754023104 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.util.Map; import java.util.Random; import java.awt.event.ActionEvent; import java.awt.event.KeyEvent; import javax.swing.AbstractAction; import javax.swing.Action; import javax.swing.ImageIcon; import javax.swing.JComponent; import javax.swing.JFrame; import javax.swing.JList; import javax.swing.JMenu; import javax.swing.JMenuItem; import javax.swing.ListModel; import javax.swing.ListSelectionModel; import javax.swing.event.ListSelectionEvent; import javax.swing.event.ListSelectionListener; import org.astrogrid.samp.Client; import org.astrogrid.samp.Message; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.hub.BasicHubService; import org.astrogrid.samp.hub.ClientSet; import org.astrogrid.samp.hub.HubClient; /** * BasicHubService subclass which provides a GUI window displaying hub * status as well as the basic hub services. * * @author Mark Taylor * @since 16 Jul 2008 */ public class GuiHubService extends BasicHubService { private GuiClientSet clientSet_; private ListSelectionModel selectionModel_; /** * Constructor. * * @param random random number generator used for message tags etc */ public GuiHubService( Random random ) { super( random ); } public void start() { super.start(); clientSet_ = (GuiClientSet) getClientSet(); } protected ClientSet createClientSet() { return new GuiClientSet( getIdComparator() ); } /** * Creates a new component containing a display of the current hub * internal state. * * @return new hub viewer panel */ public JComponent createHubPanel() { HubView hubView = new HubView( true ); hubView.setClientListModel( getClientListModel() ); JList jlist = hubView.getClientList(); jlist.setCellRenderer( new ClientListCellRenderer() ); jlist.addMouseListener( new HubClientPopupListener( this ) ); selectionModel_ = jlist.getSelectionModel(); return hubView; } /** * Creates a new window which maintains a display of the current hub * internal state. * * @return new hub viewer window */ public JFrame createHubWindow() { JFrame frame = new JFrame( "SAMP Hub" ); frame.getContentPane().add( createHubPanel() ); frame.setIconImage( new ImageIcon( Client.class .getResource( "images/hub.png" ) ) .getImage() ); frame.pack(); return frame; } protected void declareMetadata( HubClient caller, Map meta ) throws SampException { super.declareMetadata( caller, meta ); clientSet_.scheduleClientChanged( caller ); } protected void declareSubscriptions( HubClient caller, Map subscriptions ) throws SampException { super.declareSubscriptions( caller, subscriptions ); clientSet_.scheduleClientChanged( caller ); } /** * Returns a ListModel containing information about clients currently * registered with this hub. * * @return list model in which each element is a * {@link org.astrogrid.samp.Client} */ public ListModel getClientListModel() { return clientSet_; } /** * Returns the selection model corresponding to this service's client * list model. * * @return list selection model for client selection */ public ListSelectionModel getClientSelectionModel() { return selectionModel_; } /** * Returns the client object currently selected in the GUI, if any. * * @return currently selected client, or null */ private Client getSelectedClient() { ListSelectionModel selModel = getClientSelectionModel(); int isel = selModel.getMinSelectionIndex(); Object selected = isel >= 0 ? getClientListModel().getElementAt( isel ) : null; return selected instanceof Client ? (Client) selected : null; } /** * Returns an array of menus which may be added to a window * containing this service's window. * * @return menu array */ public JMenu[] createMenus() { final HubConnection serviceConnection = getServiceConnection(); final String hubId = serviceConnection.getRegInfo().getSelfId(); /* Broadcast ping action. */ final Message pingMessage = new Message( "samp.app.ping" ); final Action pingAllAction = new AbstractAction( "Ping all" ) { public void actionPerformed( ActionEvent evt ) { new SampThread( evt, "Ping Error", "Error broadcasting ping" ) { protected void sampRun() throws SampException { serviceConnection.callAll( "ping-tag", pingMessage ); } }.start(); } }; pingAllAction.putValue( Action.SHORT_DESCRIPTION, "Send ping message to all clients" ); /* Single client ping action. */ final String pingSelectedName = "Ping selected client"; final Action pingSelectedAction = new AbstractAction( pingSelectedName ) { public void actionPerformed( ActionEvent evt ) { final Client client = getSelectedClient(); if ( client != null ) { new SampThread( evt, "Ping Error", "Error sending ping to " + client ) { protected void sampRun() throws SampException { serviceConnection.call( client.getId(), "ping-tag", pingMessage ); } }.start(); } } }; pingSelectedAction.putValue( Action.SHORT_DESCRIPTION, "Send ping message to selected client" ); /* Single client disconnect action. */ final String disconnectSelectedName = "Disconnect selected client"; final Action disconnectSelectedAction = new AbstractAction( disconnectSelectedName ) { public void actionPerformed( ActionEvent evt ) { final Client client = getSelectedClient(); if ( client != null ) { new SampThread( evt, "Disconnect Error", "Error disconnecting " + client ) { protected void sampRun() throws SampException { disconnect( client.getId(), "GUI hub user requested ejection" ); } }.start(); } } }; disconnectSelectedAction.putValue( Action.SHORT_DESCRIPTION, "Forcibly disconnect selected client" + " from the hub" ); /* Ensure that actions are kept up to date. */ ListSelectionListener selListener = new ListSelectionListener() { public void valueChanged( ListSelectionEvent evt ) { Client client = getSelectedClient(); boolean isSel = client != null; boolean canPing = isSel && client.getSubscriptions() .isSubscribed( pingMessage.getMType() ); boolean canDisco = isSel && ! hubId.equals( client.getId() ); pingSelectedAction.setEnabled( canPing ); disconnectSelectedAction.setEnabled( canDisco ); String clientDesignation = client == null ? "" : ( " (" + client + ")" ); pingSelectedAction.putValue( Action.NAME, pingSelectedName + clientDesignation ); disconnectSelectedAction.putValue( Action.NAME, disconnectSelectedName + clientDesignation ); } }; getClientSelectionModel().addListSelectionListener( selListener ); selListener.valueChanged( null ); /* Prepare and return menus containing the actions. */ JMenu clientMenu = new JMenu( "Clients" ); clientMenu.setMnemonic( KeyEvent.VK_C ); clientMenu.add( new JMenuItem( pingAllAction ) ); clientMenu.add( new JMenuItem( pingSelectedAction ) ); clientMenu.add( new JMenuItem( disconnectSelectedAction ) ); return new JMenu[] { clientMenu }; } } jsamp/src/java/org/astrogrid/samp/gui/ClientTransmissionHolder.java0000664000175000017500000000132312730747754025361 0ustar sladensladenpackage org.astrogrid.samp.gui; import javax.swing.ListModel; import org.astrogrid.samp.Client; /** * Provides the means to obtain list models containing pending sent and * received transmissions. * * @author Mark Taylor * @since 26 Nov 2008 */ interface ClientTransmissionHolder { /** * Returns a list model containing messages sent by a given client. * * @return list model containing {@link Transmission} objects */ ListModel getTxListModel( Client client ); /** * Returns a list model containing messages received by a given client. * * @return list model containing {@link Transmission} objects */ ListModel getRxListModel( Client client ); } jsamp/src/java/org/astrogrid/samp/gui/PopupResultHandler.java0000664000175000017500000003305712730747754024204 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.BorderLayout; import java.awt.Color; import java.awt.Component; import java.awt.Container; import java.awt.Dimension; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; import java.awt.Insets; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.util.HashMap; import java.util.Iterator; import java.util.Map; import javax.swing.AbstractAction; import javax.swing.Action; import javax.swing.BorderFactory; import javax.swing.Box; import javax.swing.JButton; import javax.swing.JCheckBox; import javax.swing.JComponent; import javax.swing.JDialog; import javax.swing.JFrame; import javax.swing.JLabel; import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JTextArea; import javax.swing.Timer; import org.astrogrid.samp.Client; import org.astrogrid.samp.ErrInfo; import org.astrogrid.samp.Message; import org.astrogrid.samp.Response; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.client.ResultHandler; /** * ResultHandler which pops up a window displaying progress and success * of a message sent to one or many recipients. * * @author Mark Taylor * @since 12 Nov 2008 */ public class PopupResultHandler extends JFrame implements ResultHandler { private final Map clientMap_; private final int closeDelayMillis_; private final JCheckBox autoCloser_; /** * Constructor. * * @param parent parent component, used for window placement; * may be null * @param title window title * @param msg message which was sent * @param recipients clients from which responses are expected * @param closeDelay number of seconds after final response received * that window closes automatically; if <0, no auto close */ public PopupResultHandler( Component parent, String title, final Message msg, Client[] recipients, int closeDelay ) { super( title ); closeDelayMillis_ = closeDelay * 1000; // Set up manual window close action. Action closeAction = new AbstractAction( "Close" ) { public void actionPerformed( ActionEvent evt ) { PopupResultHandler.this.dispose(); } }; // Set up check box for configuring auto close autoCloser_ = closeDelayMillis_ >= 0 ? new JCheckBox( "Auto Close", true ) : null; // Set up per-client response handler objects clientMap_ = new HashMap(); for ( int i = 0; i < recipients.length; i++ ) { Client client = recipients[ i ]; clientMap_.put( client, new ClientHandler( client ) ); } // Prepare component layout. Container content = getContentPane(); content.setLayout( new BorderLayout() ); JComponent main = new JPanel( new BorderLayout() ); content.add( main ); // Panel containing control buttons (window close) Box buttonBox = Box.createHorizontalBox(); buttonBox.setBorder( BorderFactory.createEmptyBorder( 5, 5, 5, 5 ) ); if ( closeDelayMillis_ >= 0 ) { buttonBox.add( Box.createHorizontalGlue() ); buttonBox.add( autoCloser_ ); } buttonBox.add( Box.createHorizontalGlue() ); buttonBox.add( new JButton( closeAction ) ); buttonBox.add( Box.createHorizontalGlue() ); main.add( buttonBox, BorderLayout.SOUTH ); // Panel containing information about sent message. Box msgBox = Box.createVerticalBox(); msgBox.setBorder( ClientPanel.createTitledBorder( "Message" ) ); Box mtypeLine = Box.createHorizontalBox(); mtypeLine.add( new JLabel( msg.getMType() ) ); mtypeLine.add( Box.createHorizontalStrut( 10 ) ); mtypeLine.add( Box.createHorizontalGlue() ); Action msgDetailAction = new DetailAction( "Detail", "Show details of message sent", "Message Sent", "SAMP message sent:" ) { protected Map getSampMap() { return msg; } }; mtypeLine.add( new JButton( msgDetailAction ) ); msgBox.add( mtypeLine ); main.add( msgBox, BorderLayout.NORTH ); // Panel containing per-client response information. GridBagLayout layer = new GridBagLayout(); GridBagConstraints cons = new GridBagConstraints(); cons.weighty = 1; cons.gridx = 0; cons.gridy = 0; cons.insets = new Insets( 0, 2, 0, 2 ); JComponent resultBox = new JPanel( layer ); resultBox.setBorder( ClientPanel.createTitledBorder( "Responses" ) ); for ( Iterator it = clientMap_.entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); ClientHandler ch = (ClientHandler) entry.getValue(); // Client name. cons.anchor = GridBagConstraints.WEST; cons.weightx = 0; cons.fill = GridBagConstraints.NONE; Component nameLabel = ch.nameLabel_; layer.setConstraints( nameLabel, cons ); resultBox.add( nameLabel ); // Response status. cons.gridx++; cons.anchor = GridBagConstraints.CENTER; cons.weightx = 1; cons.fill = GridBagConstraints.NONE; Component statusLabel = ch.statusLabel_; layer.setConstraints( statusLabel, cons ); resultBox.add( statusLabel ); // Detail button. cons.gridx++; cons.anchor = GridBagConstraints.WEST; cons.weightx = 0; cons.fill = GridBagConstraints.NONE; Component detailButton = new JButton( ch.responseDetailAction_ ); layer.setConstraints( detailButton, cons ); resultBox.add( detailButton ); // Prepare for next row. cons.gridy++; cons.gridx = 0; } main.add( resultBox, BorderLayout.CENTER ); // Display popup window. pack(); if ( parent != null ) { setLocationRelativeTo( parent ); } setVisible( true ); } public void result( Client client, Response response ) { ClientHandler ch = (ClientHandler) clientMap_.get( client ); if ( ch == null ) { throw new IllegalArgumentException( "Shouldn't happen" ); } else { ch.result( response ); } } public void done() { // Any clients which have not received responses yet, will not do so. // Make this clear in the status labels. for ( Iterator it = clientMap_.entrySet().iterator(); it.hasNext(); ) { ClientHandler ch = (ClientHandler) ((Map.Entry) it.next()).getValue(); if ( ch.response_ == null ) { ch.result( null ); } } // Arrange for auto close of window if required. if ( isVisible() && closeDelayMillis_ >= 0 && autoCloser_.isSelected() ) { if ( closeDelayMillis_ == 0 ) { dispose(); } else { new Timer( closeDelayMillis_, new ActionListener() { public void actionPerformed( ActionEvent evt ) { if ( autoCloser_.isSelected() ) { dispose(); } } } ).start(); } } } /** * Handles per-client state for message wait/response information. */ private class ClientHandler { final JLabel nameLabel_; final JLabel statusLabel_; final Action responseDetailAction_; Response response_; /** * Constructor. * * @param client client */ ClientHandler( Client client ) { final String cName = client.toString(); nameLabel_ = new JLabel( cName ); statusLabel_ = new JLabel( "... Waiting ..." ); statusLabel_.setForeground( new Color( 0x40, 0x40, 0x40 ) ); responseDetailAction_ = new DetailAction( "Detail", "Show details of response", cName + " Response", "SAMP Response from client " + cName + ":" ) { protected Map getSampMap() { return response_; } }; responseDetailAction_.setEnabled( response_ != null ); } /** * Updates status according to a received response. * If the response is null, it indicates that no response is expected * in the future. * * @param response received response, or null */ public void result( Response response ) { response_ = response; String status; String tip; boolean success; if ( response_ == null ) { success = false; status = "Aborted"; tip = "Interruption in messaging system prevented " + "receipt of response?"; } else if ( response.isOK() ) { success = true; status = "OK"; tip = "Message processed successfully"; } else { success = false; status = "Fail"; ErrInfo errInfo = response.getErrInfo(); String errtxt = errInfo == null ? null : errInfo.getErrortxt(); tip = errtxt; if ( errtxt != null ) { status += " (" + errtxt + ")"; } } statusLabel_.setText( status ); statusLabel_.setForeground( success ? new Color( 0, 0x80, 0 ) : new Color( 0x80, 0, 0 ) ); statusLabel_.setToolTipText( tip ); responseDetailAction_.setEnabled( response_ != null ); } } /** * Action which will display a SampMap in a popup window. */ private abstract class DetailAction extends AbstractAction { private final String popupTitle_; private final String heading_; /** * Constructor. * * @param name action name * @param shortDesc action short description (tool tip) * @param popupTitle title of popup window */ DetailAction( String name, String shortDesc, String popupTitle, String heading ) { super( name ); putValue( SHORT_DESCRIPTION, shortDesc ); popupTitle_ = popupTitle; heading_ = heading; } /** * Returns the map object which is to be displayed. * Invoked by {@link #actionPerformed}. * * @return object to display */ protected abstract Map getSampMap(); public void actionPerformed( ActionEvent evt ) { // Make sure that autoclose is off, since otherwise these dialogues // will get disappeared at the same time as the owner window, // which is probably not what the user would expect. autoCloser_.setSelected( false ); // Set up a text component containing the full map serialization. JTextArea ta = new JTextArea(); ta.setLineWrap( false ); ta.setEditable( false ); ta.append( SampUtils.formatObject( getSampMap(), 3 ) ); ta.setCaretPosition( 0 ); // Wrap it in a scroll pane and size appropriately. Dimension size = ta.getPreferredSize(); size.height = Math.min( size.height + 20, 200 ); size.width = Math.min( size.width + 20, 360 ); JScrollPane scroller = new JScrollPane( ta ); scroller.setPreferredSize( size ); // Put into a non-modal dialogue with decorations. final JDialog dialog = new JDialog( PopupResultHandler.this, popupTitle_, false ); dialog.setDefaultCloseOperation( JDialog.DISPOSE_ON_CLOSE ); JComponent main = new JPanel( new BorderLayout() ); main.setBorder( BorderFactory.createEmptyBorder( 5, 5, 5, 5 ) ); dialog.getContentPane().add( main ); Action closeAction = new AbstractAction( "Close" ) { public void actionPerformed( ActionEvent evt ) { dialog.dispose(); } }; Box buttonBox = Box.createHorizontalBox(); buttonBox.add( Box.createHorizontalGlue() ); buttonBox.add( new JButton( closeAction ) ); buttonBox.add( Box.createHorizontalGlue() ); Box headBox = Box.createHorizontalBox(); headBox.add( new JLabel( heading_ ) ); headBox.add( Box.createHorizontalGlue() ); headBox.setBorder( BorderFactory.createEmptyBorder( 0, 0, 5, 0 ) ); buttonBox.setBorder( BorderFactory .createEmptyBorder( 5, 0, 0, 0 ) ); main.add( headBox, BorderLayout.NORTH ); main.add( buttonBox, BorderLayout.SOUTH ); main.add( scroller, BorderLayout.CENTER ); // Post the dialogue. dialog.setLocationRelativeTo( PopupResultHandler.this ); dialog.pack(); dialog.setVisible( true ); } } } jsamp/src/java/org/astrogrid/samp/gui/TransmissionTableModel.java0000664000175000017500000003132012730747754025015 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.Component; import java.awt.Graphics2D; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.util.ArrayList; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import javax.swing.JLabel; import javax.swing.JTable; import javax.swing.SwingUtilities; import javax.swing.Timer; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; import javax.swing.event.TableModelEvent; import javax.swing.event.TableModelListener; import javax.swing.table.DefaultTableCellRenderer; import javax.swing.table.TableCellRenderer; import javax.swing.table.TableColumn; import javax.swing.table.TableModel; import org.astrogrid.samp.Client; /** * TableModel implementation which displays Transmission objects. * * @author Mark Taylor * @since 5 Dec 2008 */ class TransmissionTableModel implements TableModel { private final List transList_; private final List tableListenerList_; private final ChangeListener changeListener_; private final Column[] columns_; private int maxRows_; private int removeDelay_; /** Cell renderer for Transmission.Status objects. */ public static final TableCellRenderer STATUS_RENDERER = createStatusCellRenderer(); /** Cell renderer for Client objects. */ private static final TableCellRenderer CLIENT_RENDERER = createClientCellRenderer(); /** * Constructor. * * @param showSender true if a Sender column is required * @param showReceiver true if a Receiver column is required * @param removeDelay time in milliseconds after transmission resolution * that it will stay in the table - after this it will be * removed automatically * @param maxRows maximum row count for table - if not set to a finite * value, Swing can get overloaded in very high message traffic */ public TransmissionTableModel( final boolean showSender, final boolean showReceiver, int removeDelay, int maxRows ) { removeDelay_ = removeDelay; maxRows_ = maxRows; transList_ = new LinkedList(); tableListenerList_ = new ArrayList(); // Set up table columns. List colList = new ArrayList(); int charWidth = 8; int icol = 0; colList.add( new Column( "MType", String.class, new TableColumn( icol++, 30 * charWidth ) ) { Object getValue( Transmission trans ) { return trans.getMessage().getMType(); } } ); if ( showSender ) { colList.add( new Column( "Sender", Object.class, new TableColumn( icol++, 20 * charWidth, CLIENT_RENDERER, null ) ) { Object getValue( Transmission trans ) { return trans.getSender(); } } ); } if ( showReceiver ) { colList.add( new Column( "Receiver", Object.class, new TableColumn( icol++, 20 * charWidth, CLIENT_RENDERER, null ) ) { Object getValue( Transmission trans ) { return trans.getReceiver(); } } ); } colList.add( new Column( "Status", Object.class, new TableColumn( icol++, 16 * charWidth, STATUS_RENDERER, null ) ) { Object getValue( Transmission trans ) { return trans.getStatus(); } } ); columns_ = (Column[]) colList.toArray( new Column[ 0 ] ); // Set up listener to monitor changes of transmissions. changeListener_ = new ChangeListener() { public void stateChanged( ChangeEvent evt ) { Object src = evt.getSource(); assert src instanceof Transmission; if ( src instanceof Transmission ) { transmissionChanged( (Transmission) src ); } } }; } /** * Returns the transmission corresponding to a given table row. * * @param irow row index * @param transmission displayed in row irow */ public Transmission getTransmission( int irow ) { return (Transmission) transList_.get( irow ); } /** * Adds a transmission (row) to this model. It will appear at the top. * * @param trans transmission to add */ public void addTransmission( Transmission trans ) { while ( transList_.size() > maxRows_ ) { transList_.remove( maxRows_ ); fireTableChanged( new TableModelEvent( this, maxRows_, maxRows_, TableModelEvent.ALL_COLUMNS, TableModelEvent.DELETE ) ); } transList_.add( 0, trans ); trans.addChangeListener( changeListener_ ); fireTableChanged( new TableModelEvent( this, 0, 0, TableModelEvent.ALL_COLUMNS, TableModelEvent.INSERT ) ); } /** * Removes a transmission from this model. * * @param trans transmission to remove */ public void removeTransmission( final Transmission trans ) { int index = transList_.indexOf( trans ); if ( index >= 0 ) { transList_.remove( index ); fireTableChanged( new TableModelEvent( this, index, index, TableModelEvent.ALL_COLUMNS, TableModelEvent.DELETE ) ); } // Defer listener removal to avoid concurrency problems // (trying to remove a listener which generated this event). SwingUtilities.invokeLater( new Runnable() { public void run() { trans.removeChangeListener( changeListener_ ); } } ); } public int getColumnCount() { return columns_.length; } public int getRowCount() { return transList_.size(); } public Object getValueAt( int irow, int icol ) { return columns_[ icol ].getValue( getTransmission( irow ) ); } public String getColumnName( int icol ) { return columns_[ icol ].name_; } public Class getColumnClass( int icol ) { return columns_[ icol ].clazz_; } public boolean isCellEditable( int irow, int icol ) { return false; } public void setValueAt( Object value, int irow, int icol ) { throw new UnsupportedOperationException(); } public void addTableModelListener( TableModelListener listener ) { tableListenerList_.add( listener ); } public void removeTableModelListener( TableModelListener listener ) { tableListenerList_.remove( listener ); } /** * Returns a TableColumn suitable for a given column of this table. * Can be used for more customised presentation. * * @param icol column index * @return table column */ public TableColumn getTableColumn( int icol ) { return columns_[ icol ].tcol_; } /** * Called whenever a transmission which is in this list has changed * state. * * @param trans transmission */ private void transmissionChanged( final Transmission trans ) { int index = transList_.indexOf( trans ); if ( index >= 0 ) { fireTableChanged( new TableModelEvent( this, index ) ); if ( trans.isDone() && removeDelay_ >= 0 ) { long sinceDone = System.currentTimeMillis() - trans.getDoneTime(); long delay = removeDelay_ - sinceDone; if ( delay <= 0 ) { removeTransmission( trans ); } else { ActionListener remover = new ActionListener() { public void actionPerformed( ActionEvent evt ) { removeTransmission( trans ); } }; new Timer( (int) delay + 1, remover ).start(); } } } } /** * Passes a table event to all registered listeners. * * @param evt event to forward */ private void fireTableChanged( TableModelEvent evt ) { for ( Iterator it = tableListenerList_.iterator(); it.hasNext(); ) { ((TableModelListener) it.next()).tableChanged( evt ); } } /** * Describes metadata and data for a table column. */ private abstract class Column { final String name_; final Class clazz_; final TableColumn tcol_; /** * Constructor. * * @param name column name * @param clazz column content class */ Column( String name, Class clazz, TableColumn tcol ) { name_ = name; clazz_ = clazz; tcol_ = tcol; tcol_.setHeaderValue( name ); } /** * Returns the item in this column for a given transmission. * * @param trans transmission * @return cell value */ abstract Object getValue( Transmission trans ); } /** * Template custom TableCellRenderer for subclassing. */ private static abstract class CustomTableCellRenderer extends DefaultTableCellRenderer { public Component getTableCellRendererComponent( JTable table, Object value, boolean isSel, boolean hasFocus, int irow, int icol ) { int size; try { size = (int) Math.ceil( getFont() .getMaxCharBounds( ((Graphics2D) table.getGraphics()) .getFontRenderContext() ) .getHeight() ); } catch ( NullPointerException e ) { size = 16; } Component comp = super.getTableCellRendererComponent( table, value, isSel, hasFocus, irow, icol ); if ( comp instanceof JLabel ) { configureLabel( (JLabel) comp, value, size - 2 ); } return comp; } /** * Configures a JLabel given the value to render and the * component size. * * @param label renderer component to configure * @param value object to render * @param height component height in pixels */ abstract void configureLabel( JLabel label, Object value, int height ); } /** * Returns a cell renderer for Transmission.Status objects. * * @return table cell renderer */ private static TableCellRenderer createStatusCellRenderer() { return new CustomTableCellRenderer() { void configureLabel( JLabel label, Object value, int height ) { if ( value instanceof Transmission.Status ) { Transmission.Status status = (Transmission.Status) value; label.setText( status.getText() ); label.setIcon( status.getIcon( height ) ); } } }; } /** * Returns a cell renderer for Client objects. * * @return table cell renderer */ private static TableCellRenderer createClientCellRenderer() { final IconStore iconStore = new IconStore( IconStore.createEmptyIcon( 16 ) ); return new CustomTableCellRenderer() { void configureLabel( JLabel label, Object value, int height ) { if ( value instanceof Client ) { Client client = (Client) value; label.setText( client.toString() ); label.setIcon( ClientListCellRenderer .reshapeIcon( iconStore.getIcon( client ), height ) ); } } }; } } jsamp/src/java/org/astrogrid/samp/gui/SampThread.java0000664000175000017500000000416512730747754022432 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.Component; import java.util.EventObject; import javax.swing.SwingUtilities; import org.astrogrid.samp.client.SampException; /** * Helper class for performing a SAMP operation from within the * Event Dispatch Thread. * You must implement the {@link #runSamp} method to do the work; * any resulting SampException will be suitably displayed to the user. * * @author Mark Taylor * @since 27 Jul 2011 */ abstract class SampThread extends Thread { private final Component parent_; private final String errTitle_; private final String errText_; /** * Constructs a SampThread given a parent component. * Arguments are required for posting an error if one occurs. * * @param parent parent component * @param errTitle title of error window if one is needed * @param errText text of error messsage if one is needed */ public SampThread( Component parent, String errTitle, String errText ) { super( "SAMP call" ); parent_ = parent; errTitle_ = errTitle; errText_ = errText; } /** * Constructs a SampThread given an event object with a source which * presumably corresponds to a parent component. * * @param evt triggering event * @param errTitle title of error window if one is needed * @param errText text of error messsage if one is needed */ public SampThread( EventObject evt, String errTitle, String errText ) { this( evt.getSource() instanceof Component ? (Component) evt.getSource() : null, errTitle, errText ); } /** * Called from the {@link #run} method. */ protected abstract void sampRun() throws SampException; public void run() { try { sampRun(); } catch ( final SampException e ) { SwingUtilities.invokeLater( new Runnable() { public void run() { ErrorDialog.showError( parent_, errTitle_, errText_, e ); } } ); } } } jsamp/src/java/org/astrogrid/samp/gui/Transmission.java0000664000175000017500000002525312730747754023074 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.Color; import java.awt.Component; import java.awt.Graphics; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; import javax.swing.Icon; import javax.swing.SwingUtilities; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; import org.astrogrid.samp.Client; import org.astrogrid.samp.Message; import org.astrogrid.samp.Response; /** * Describes the properties of a message which has been sent from one * client to another. Methods which might change the state of instances * of this class should be invoked only from the AWT event dispatch thread. * * @author Mark Taylor * @since 20 Nov 2008 */ public class Transmission { private final Client sender_; private final Client receiver_; private final Message msg_; private final String msgId_; private final String msgTag_; private final List listenerList_; private final ChangeEvent evt_; private Response response_; private Throwable error_; private boolean senderUnreg_; private boolean receiverUnreg_; private long doneTime_; private static final Logger logger_ = Logger.getLogger( Transmission.class.getName() ); /** * Constructor. * * @param sender sender * @param receiver receiver * @param msg message * @param msgTag message tag * @param msgId message ID */ public Transmission( Client sender, Client receiver, Message msg, String msgTag, String msgId ) { sender_ = sender; receiver_ = receiver; msg_ = msg; msgTag_ = msgTag; msgId_ = msgId; listenerList_ = new ArrayList(); evt_ = new ChangeEvent( this ); doneTime_ = Long.MAX_VALUE; } /** * Returns the client which sent this transmission. * * @return sender */ public Client getSender() { return sender_; } /** * Returns the client to which this transmission was sent. * * @return receiver */ public Client getReceiver() { return receiver_; } /** * Returns the message which was sent. * * @return message */ public Message getMessage() { return msg_; } /** * Returns the message tag corresponding to this transmission. * Will be null for notify-type sends. * * @return msg tag */ public String getMessageTag() { return msgTag_; } /** * Returns the message ID associated with this message. * This is the identifier passed to the receiver which it uses to * match messages with responses; it will be null iff the transmission * used the notify delivery pattern (no response expected). * * @return msgId; possibly null */ public String getMessageId() { return msgId_; } /** * Sets the response for this transmission. * * @param response response */ public void setResponse( Response response ) { response_ = response; fireChange(); } /** * Returns the response for this transmission. * Will be null if no response has (yet) arrived. * * @return response */ public Response getResponse() { return response_; } /** * Associates an error with this transmission. * This is probably an indication that the send failed or some other * non-SAMP event intervened to prevent normal resolution. * * @param error throwable causing the failure */ public void setError( Throwable error ) { error_ = error; logger_.log( Level.WARNING, "Error in hub operation: " + error.getMessage(), error ); fireChange(); } /** * Returns a Throwable which prevented normal resolution of this * transmission. * * @return error */ public Throwable getError() { return error_; } /** * Indicates that the sender of this transmission has unregistered. */ public void setSenderUnregistered() { senderUnreg_ = true; fireChange(); } /** * Indicates that the receiver of this transmission has unregistered. */ public void setReceiverUnregistered() { receiverUnreg_ = true; fireChange(); } /** * Returns the epoch at which this transmission was completed. * If it is still pending ({@link #isDone()}==false), * the returned value will be (way) in the future. * * @return value of System.currentTimeMillis() at which * {@link #isDone} first returned true */ public long getDoneTime() { return doneTime_; } /** * Indicates whether further changes to the state of this object * are expected, that is if a response/failure is yet to be received. * * @return true iff no further changes are expected */ public boolean isDone() { return error_ != null || response_ != null || ( msgId_ == null && msgTag_ == null ) || receiverUnreg_; } /** * Returns an object which describes the current status of this * transmission in terms which can be presented to the GUI. */ public Status getStatus() { if ( error_ != null ) { return Status.EXCEPTION; } else if ( response_ != null ) { String status = response_.getStatus(); if ( Response.OK_STATUS.equals( status ) ) { return Status.OK; } else if ( Response.WARNING_STATUS.equals( status ) ) { return Status.WARNING; } else if ( Response.ERROR_STATUS.equals( status ) ) { return Status.ERROR; } else if ( status == null ) { return Status.NONE; } else { return new Status( "Completed (" + status + ")", Status.WARNING_COLOR, true ); } } else if ( msgId_ == null && msgTag_ == null ) { return Status.NOTIFIED; } else if ( receiverUnreg_ ) { return Status.ORPHANED; } else { assert ! isDone(); return Status.PENDING; } } /** * Adds a listener which will be notified if the state of this transmission * changes (if a response or failure is signalled). * The {@link javax.swing.event.ChangeEvent}s sent to these listeners * will have a source which is this Transmission. * * @param listener listener to add */ public void addChangeListener( ChangeListener listener ) { listenerList_.add( listener ); } /** * Removes a listener previously added by {@link #addChangeListener}. * * @param listener listener to remove */ public void removeChangeListener( ChangeListener listener ) { listenerList_.remove( listener ); } /** * Notifies listeners of a state change. */ private void fireChange() { if ( doneTime_ == Long.MAX_VALUE && isDone() ) { doneTime_ = System.currentTimeMillis(); } for ( Iterator it = listenerList_.iterator(); it.hasNext(); ) { ChangeListener listener = (ChangeListener) it.next(); listener.stateChanged( evt_ ); } } /** * Describes the status of a transmission in terms that can be * presented in the GUI. */ public static class Status { private final String text_; private final Color iconColor_; private final boolean isDone_; private final static Color PENDING_COLOR = Color.BLACK; private final static Color OK_COLOR = new Color( 0x00c000 ); private final static Color ERROR_COLOR = new Color( 0xc00000 ); private final static Color WARNING_COLOR = new Color( 0x806030 ); private final static Color NOTIFY_COLOR = new Color( 0x808080 ); private final static Status OK = new Status( "Success", OK_COLOR, true ); private final static Status WARNING = new Status( "Warning", WARNING_COLOR, true ); private final static Status ERROR = new Status( "Error", ERROR_COLOR, true ); private final static Status NONE = new Status( "Completed (??)", WARNING_COLOR, true ); private final static Status NOTIFIED = new Status( "Notified", NOTIFY_COLOR, true ); private final static Status EXCEPTION = new Status( "Exception", ERROR_COLOR, true ); private final static Status ORPHANED = new Status( "Orphaned", WARNING_COLOR, true ); private final static Status PENDING = new Status( "...pending...", PENDING_COLOR, false ); /** * Constructor. * * @param text short status summary * @param iconColor colour to plot icon * @param isDone whether status represents completed processing */ Status( String text, Color iconColor, boolean isDone ) { text_ = text; iconColor_ = iconColor; isDone_ = isDone; } /** * Returns the text for this status. * * @return short summmary */ public String getText() { return text_; } /** * Returns a little icon representing status. * * @param height required height of icon * @return icon */ public Icon getIcon( final int height ) { final int width = (int) Math.floor( 0.866 * height ); return new Icon() { public void paintIcon( Component c, Graphics g, int x, int y ) { int[] xs = new int[] { x, x + width, x, }; int[] ys = new int[] { y, y + height / 2, y + height, }; Color gcolor = g.getColor(); g.setColor( iconColor_ ); if ( isDone_ ) { g.drawPolygon( xs, ys, 3 ); } else { g.fillPolygon( xs, ys, 3 ); } g.setColor( gcolor ); } public int getIconWidth() { return width; } public int getIconHeight() { return height; } }; } public String toString() { return text_; } } } jsamp/src/java/org/astrogrid/samp/gui/ErrorDialog.java0000664000175000017500000001105012730747754022602 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.BorderLayout; import java.awt.Component; import java.awt.Dimension; import java.awt.Frame; import java.awt.event.ActionEvent; import java.io.StringWriter; import java.io.PrintWriter; import javax.swing.AbstractAction; import javax.swing.BorderFactory; import javax.swing.Box; import javax.swing.JButton; import javax.swing.JComponent; import javax.swing.JDialog; import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JTextArea; import javax.swing.SwingUtilities; import javax.swing.UIManager; import javax.swing.border.Border; /** * Dialog window which displays an error message, possibly with some * verbose details optionally visible. * * @author Mark Taylor * @since 5 Sep 2008 */ public abstract class ErrorDialog extends JDialog { /** * Constructor. * * @param owner parent frame * @param title dialog title * @param summary short text string describing what's up */ protected ErrorDialog( Frame owner, String title, String summary ) { super( owner, title == null ? "Error" : title, true ); setDefaultCloseOperation( DISPOSE_ON_CLOSE ); // Define buttons. final JPanel main = new JPanel( new BorderLayout() ); JButton disposeButton = new JButton(); JButton detailButton = new JButton(); final JComponent dataBox = new JPanel( new BorderLayout() ); dataBox.add( new JLabel( summary ) ); JComponent buttonBox = Box.createHorizontalBox(); // Populate main panel. Border gapBorder = BorderFactory.createEmptyBorder( 5, 5, 5, 5 ); JComponent iconLabel = new JLabel( UIManager.getIcon( "OptionPane.errorIcon" ) ); iconLabel.setBorder( gapBorder ); main.add( iconLabel, BorderLayout.WEST ); buttonBox.add( Box.createHorizontalGlue() ); buttonBox.add( disposeButton ); buttonBox.add( Box.createHorizontalStrut( 10 ) ); buttonBox.add( detailButton ); buttonBox.add( Box.createHorizontalGlue() ); dataBox.setBorder( gapBorder ); main.add( buttonBox, BorderLayout.SOUTH ); main.add( dataBox, BorderLayout.CENTER ); main.setBorder( gapBorder ); // Set button action for dismiss button. disposeButton.setAction( new AbstractAction( "OK" ) { public void actionPerformed( ActionEvent evt ) { dispose(); } } ); // Set button action for display detail button. detailButton.setAction( new AbstractAction( "Show Details" ) { public void actionPerformed( ActionEvent evt ) { JTextArea ta = new JTextArea(); ta.setLineWrap( false ); ta.setEditable( false ); ta.append( getDetailText() ); ta.setCaretPosition( 0 ); JScrollPane scroller = new JScrollPane( ta ); dataBox.removeAll(); dataBox.add( scroller ); Dimension size = dataBox.getPreferredSize(); size.height = Math.min( size.height, 300 ); size.width = Math.min( size.width, 500 ); dataBox.revalidate(); dataBox.setPreferredSize( size ); pack(); setEnabled( false ); } } ); getContentPane().add( main ); } /** * Supplies the text to be displayed in the detail panel. * * @return detail text */ protected abstract String getDetailText(); /** * Pops up a window which shows the content of a exception. * * @param parent parent component * @param title window title * @param summary short text string * @param error throwable */ public static void showError( Component parent, String title, String summary, final Throwable error ) { Frame fparent = parent == null ? null : (Frame) SwingUtilities .getAncestorOfClass( Frame.class, parent ); JDialog dialog = new ErrorDialog( fparent, title, summary ) { protected String getDetailText() { StringWriter traceWriter = new StringWriter(); error.printStackTrace( new PrintWriter( traceWriter ) ); return traceWriter.toString(); } }; dialog.setLocationRelativeTo( parent ); dialog.pack(); dialog.setVisible( true ); } } jsamp/src/java/org/astrogrid/samp/gui/TransmissionPanel.java0000664000175000017500000002216512730747754024053 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.BorderLayout; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; import java.awt.Insets; import java.io.StringWriter; import java.io.PrintWriter; import javax.swing.BorderFactory; import javax.swing.Box; import javax.swing.JComponent; import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JSplitPane; import javax.swing.JTextField; import javax.swing.JTextArea; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; import org.astrogrid.samp.Client; import org.astrogrid.samp.Message; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.Response; import org.astrogrid.samp.SampMap; import org.astrogrid.samp.SampUtils; /** * Component which displays the details of a given Transmission object. * * @author Mark Taylor * @since 5 Dec 2008 */ public class TransmissionPanel extends JPanel { private final JTextField mtypeField_; private final JTextField idField_; private final JTextField senderField_; private final JTextField receiverField_; private final JTextField statusField_; private final JTextArea messageField_; private final JTextArea responseField_; private final ChangeListener changeListener_; private Transmission trans_; /** * Constructor. */ public TransmissionPanel() { super( new BorderLayout() ); // Panel displaying one-line metadata items. Stack metaPanel = new Stack(); metaPanel.setBorder( BorderFactory.createEmptyBorder( 2, 2, 2, 2 ) ); mtypeField_ = metaPanel.addField( "MType" ); idField_ = metaPanel.addField( "Message ID" ); senderField_ = metaPanel.addField( "Sender" ); receiverField_ = metaPanel.addField( "Receiver" ); statusField_ = metaPanel.addField( "Status" ); // Panel displaying Message content. messageField_ = new JTextArea(); messageField_.setEditable( false ); messageField_.setLineWrap( false ); JComponent messagePanel = new JPanel( new BorderLayout() ); JComponent messageHeadBox = Box.createHorizontalBox(); messageHeadBox.add( new JLabel( "Message" ) ); messageHeadBox.add( Box.createHorizontalGlue() ); messagePanel.add( messageHeadBox, BorderLayout.NORTH ); messagePanel.add( new JScrollPane( messageField_ ), BorderLayout.CENTER ); // Panel displaying Response content. responseField_ = new JTextArea(); responseField_.setEditable( false ); responseField_.setLineWrap( false ); JComponent responsePanel = new JPanel( new BorderLayout() ); JComponent responseHeadBox = Box.createHorizontalBox(); responseHeadBox.add( new JLabel( "Response" ) ); responseHeadBox.add( Box.createHorizontalGlue() ); responsePanel.add( responseHeadBox, BorderLayout.NORTH ); responsePanel.add( new JScrollPane( responseField_ ), BorderLayout.CENTER ); // Place panels. JSplitPane splitter = new JSplitPane( JSplitPane.VERTICAL_SPLIT ); splitter.setTopComponent( messagePanel ); splitter.setBottomComponent( responsePanel ); splitter.setBorder( BorderFactory.createEmptyBorder( 2, 2, 2, 2 ) ); splitter.setResizeWeight( 0.5 ); add( metaPanel, BorderLayout.NORTH ); add( splitter, BorderLayout.CENTER ); // Prepare a listener to react to changes in the displayed // transmission object. changeListener_ = new ChangeListener() { public void stateChanged( ChangeEvent evt ) { updateState(); } }; } /** * Sets the transmission object being displayed. * * @param trans transmission object to display */ public void setTransmission( Transmission trans ) { if ( trans_ != null ) { trans_.removeChangeListener( changeListener_ ); } trans_ = trans; if ( trans_ != null ) { trans_.addChangeListener( changeListener_ ); } updateState(); } /** * Returns the transmission object currently being displayed. * * @return transmission */ public Transmission getTransmission() { return trans_; } /** * Invoked whenever the displayed transmission, or its characteristics, * change. */ private void updateState() { if ( trans_ == null ) { mtypeField_.setText( null ); idField_.setText( null ); senderField_.setText( null ); receiverField_.setText( null ); statusField_.setText( null ); messageField_.setText( null ); responseField_.setText( null ); } else { Message msg = trans_.getMessage(); Response response = trans_.getResponse(); Throwable error = trans_.getError(); mtypeField_.setText( msg.getMType() ); mtypeField_.setCaretPosition( 0 ); idField_.setText( formatId( trans_ ) ); idField_.setCaretPosition( 0 ); senderField_.setText( formatClient( trans_.getSender() ) ); senderField_.setCaretPosition( 0 ); receiverField_.setText( formatClient( trans_.getReceiver() ) ); receiverField_.setCaretPosition( 0 ); statusField_.setText( trans_.getStatus().getText() ); statusField_.setCaretPosition( 0 ); messageField_.setText( SampUtils.formatObject( msg, 2 ) ); messageField_.setCaretPosition( 0 ); String responseText = response == null ? null : SampUtils.formatObject( response, 2 ); final String errorText; if ( error == null ) { errorText = null; } else { StringWriter traceWriter = new StringWriter(); error.printStackTrace( new PrintWriter( traceWriter ) ); errorText = traceWriter.toString(); } StringBuffer rbuf = new StringBuffer(); if ( responseText != null ) { rbuf.append( responseText ); } if ( errorText != null ) { if ( rbuf.length() > 0 ) { rbuf.append( "\n\n" ); } rbuf.append( errorText ); } responseField_.setText( rbuf.toString() ); responseField_.setCaretPosition( 0 ); } } /** * Formats the identifier of a transmission as a string. * * @param trans transmission * @return id string */ private static String formatId( Transmission trans ) { String msgTag = trans.getMessageTag(); String msgId = trans.getMessageId(); StringBuffer idBuf = new StringBuffer(); if ( msgTag != null ) { idBuf.append( "Tag: " ) .append( msgTag ); } if ( msgId != null ) { if ( idBuf.length() > 0 ) { idBuf.append( "; " ); } idBuf.append( "ID: " ) .append( msgId ); } return idBuf.toString(); } /** * Formats a client as a string. * * @param client client * @return string */ private static String formatClient( Client client ) { StringBuffer sbuf = new StringBuffer(); sbuf.append( client.getId() ); Metadata meta = client.getMetadata(); if ( meta != null ) { String name = meta.getName(); if ( name != null ) { sbuf.append( ' ' ) .append( '(' ) .append( name ) .append( ')' ); } } return sbuf.toString(); } /** * Component for aligning headings and text fields for metadata display. */ private class Stack extends JPanel { private final GridBagLayout layer_; private int line_; /** * Constructor. */ Stack() { layer_ = new GridBagLayout(); setLayout( layer_ ); } /** * Adds an item. * * @param heading text heading for item * @return text field associated with heading */ JTextField addField( String heading ) { GridBagConstraints cons = new GridBagConstraints(); cons.gridy = line_++; cons.weighty = 1; cons.insets = new Insets( 2, 2, 2, 2 ); cons.gridx = 0; cons.fill = GridBagConstraints.HORIZONTAL; cons.anchor = GridBagConstraints.WEST; cons.weightx = 0; JLabel label = new JLabel( heading + ":" ); JTextField field = new JTextField(); field.setEditable( false ); layer_.setConstraints( label, cons ); add( label ); cons.gridx++; cons.weightx = 1; layer_.setConstraints( field, cons ); add( field ); return field; } } } jsamp/src/java/org/astrogrid/samp/gui/SelectiveClientListModel.java0000664000175000017500000001256412730747754025303 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.util.Arrays; import javax.swing.AbstractListModel; import javax.swing.ListModel; import javax.swing.event.ListDataEvent; import javax.swing.event.ListDataListener; import org.astrogrid.samp.Client; /** * ListModel implementation which sits on top of an existing ListModel * containing {@link org.astrogrid.samp.Client}s, but only includes a * subset of its elements. * *

Concrete subclasses must *

    *
  1. implement the {@link #isIncluded} method to determine which clients * from the base list appear in this one
  2. *
  3. call {@link #init} before the class is used * (for instance in their constructor)
  4. *
      * * @author Mark Taylor * @since 1 Sep 2008 */ public abstract class SelectiveClientListModel extends AbstractListModel { private final ListModel baseModel_; private final ListDataListener listDataListener_; private int[] map_; /** * Constructor. * * @param clientListModel base ListModel containing * {@link org.astrogrid.samp.Client} objects */ public SelectiveClientListModel( ListModel clientListModel ) { baseModel_ = clientListModel; // Somewhat haphazard implementation. The ListDataListener interface // is not constructed (or documented) so as to make it easy to // fire the right events. Some efficiency measures are taken here, // but it would be possible to do more. listDataListener_ = new ListDataListener() { public void contentsChanged( ListDataEvent evt ) { int[] oldMap = map_; map_ = calculateMap(); if ( Arrays.equals( oldMap, map_ ) && evt.getIndex0() == evt.getIndex1() && evt.getIndex0() >= 0 ) { int index = evt.getIndex0(); for ( int i = 0; i < map_.length; i++ ) { if ( map_[ i ] == index ) { fireContentsChanged( this, index, index ); } } } else { fireContentsChanged( this, -1, -1 ); } } public void intervalAdded( ListDataEvent evt ) { int[] oldMap = map_; map_ = calculateMap(); if ( ! Arrays.equals( oldMap, map_ ) ) { int leng = Math.min( map_.length, oldMap.length ); int index0 = -1; for ( int i = 0; i < leng; i++ ) { if ( oldMap[ i ] != map_[ i ] ) { index0 = i; break; } } int index1 = -1; for ( int i = 0; i < leng; i++ ) { if ( oldMap[ oldMap.length - 1 - i ] != map_[ map_.length - 1 - i ] ) { index1 = map_.length - 1 - i; break; } } if ( index0 >= 0 && index1 >= 0 ) { fireIntervalAdded( this, index0, index1 ); } else { fireContentsChanged( this, -1, -1 ); } } } public void intervalRemoved( ListDataEvent evt ) { int[] oldMap = map_; map_ = calculateMap(); if ( ! Arrays.equals( oldMap, map_ ) ) { fireContentsChanged( this, -1, -1 ); } } }; } /** * Implement this method to determine which clients are included in * this list. * * @param client client for consideration * @return true iff client is to be included in this list */ protected abstract boolean isIncluded( Client client ); /** * Must be called by subclass prior to use. */ protected void init() { refresh(); baseModel_.addListDataListener( listDataListener_ ); } /** * Recalculates the inclusions. This should be called if the return * value from {@link #isIncluded} might have changed for some of the * elements. */ protected void refresh() { map_ = calculateMap(); } public int getSize() { return map_.length; } public Object getElementAt( int index ) { return baseModel_.getElementAt( map_[ index ] ); } /** * Releases any resources associated with this transmitter. */ public void dispose() { baseModel_.removeListDataListener( listDataListener_ ); } /** * Recalculates the this list -> base list lookup table. * * @return array whose indices represent elements of this list, and * values represent elements of the base list */ private int[] calculateMap() { int nc = baseModel_.getSize(); int[] map = new int[ nc ]; int ij = 0; for ( int ic = 0; ic < nc; ic++ ) { Client client = (Client) baseModel_.getElementAt( ic ); if ( isIncluded( client ) ) { map[ ij++ ] = ic; } } int[] map1 = new int[ ij ]; System.arraycopy( map, 0, map1, 0, ij ); return map1; } } jsamp/src/java/org/astrogrid/samp/gui/IconStore.java0000664000175000017500000002613212730747754022305 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.Color; import java.awt.Component; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.geom.AffineTransform; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import java.util.HashMap; import java.util.Map; import java.util.logging.Logger; import javax.swing.Icon; import javax.swing.ImageIcon; import org.astrogrid.samp.Client; import org.astrogrid.samp.Metadata; /** * Manages client icons. Images are cached where appropriate. * A size may be supplied so that all icons returned by this object's methods * are of a given standard size. * Also provides some icon utility methods. * * @author Mark Taylor * @since 17 Nov 2008 */ public class IconStore { private final Icon defaultIcon_; private static final Map urlIconMap_ = new HashMap(); private static final Logger logger_ = Logger.getLogger( IconStore.class.getName() ); /** * Constructor. * * @param defaultIcon icon returned if no client icon is available */ public IconStore( Icon defaultIcon ) { defaultIcon_ = defaultIcon; } /** * Returns the icon supplied by the graphic file at a given URL. * Icons are cached, so that repeated invocations with the same url * are not expensive. * * @param url URL of image * @return image icon, resized if appropriate */ public Icon getIcon( String url ) { if ( ! urlIconMap_.containsKey( url ) ) { try { Icon icon = readIcon( url, 5 ); synchronized ( urlIconMap_ ) { urlIconMap_.put( url, icon ); } } catch ( IOException e ) { logger_.warning( "Icon not found \"" + url + "\" " + e ); synchronized ( urlIconMap_ ) { urlIconMap_.put( url, defaultIcon_ ); } } } Icon icon = (Icon) urlIconMap_.get( url ); if ( icon.getIconWidth() < 0 ) { icon = defaultIcon_; } return icon; } /** * Returns the icon associated with a given client. * This is either the icon described in its metadata or the default icon * if there isn't one. * * @param client client whose icon is required * @return associated icon, resized if appropriate */ public Icon getIcon( Client client ) { Metadata meta = client.getMetadata(); if ( meta != null ) { Object url = meta.get( Metadata.ICONURL_KEY ); if ( url instanceof String ) { return getIcon( (String) url ); } } return defaultIcon_; } /** * Returns an icon with no content but a given size. * * @param size edge size in pixels * @return emtpy square icon */ public static Icon createEmptyIcon( final int size ) { return new Icon() { public int getIconWidth() { return size; } public int getIconHeight() { return size; } public void paintIcon( Component c, Graphics g, int x, int y ) { } }; } /** * Returns an icon which indicates a shape but doesn't look like much. * Currently it's a kind of open square. * * @param size dimension in pixels * @return minimal icon */ public static Icon createMinimalIcon( final int size ) { return new Icon() { int gap = 2; int size = 24; public int getIconWidth() { return size; } public int getIconHeight() { return size; } public void paintIcon( Component c, Graphics g, int x, int y ) { Color color = g.getColor(); int nl = 5; for ( int i = 0; i < nl; i++ ) { int lo = gap + i; int dim = size - 2 * ( gap + i ); if ( dim <= 0 ) { break; } int glevel = 255 * ( i + 1 ) / ( nl + 1 ); g.setColor( new Color( glevel, glevel, glevel ) ); g.drawRect( x + lo, y + lo, dim, dim ); } g.setColor( color ); } }; } /** * Constructs an icon given a file name in the images directory. * * @param fileName file name omitting directory * @return icon */ static Icon createResourceIcon( String fileName ) { String relLoc = "images/" + fileName; URL resource = Client.class.getResource( relLoc ); if ( resource != null ) { return new ImageIcon( resource ); } else { logger_.warning( "Failed to load icon " + relLoc ); return new Icon() { public int getIconWidth() { return 24; } public int getIconHeight() { return 24; } public void paintIcon( Component c, Graphics g, int x, int y ) { } }; } } /** * Return an icon based on an existing one, but drawn to an exact size. * * @param icon original icon, or null for blank * @param size number of horizontal and vertical pixels in output * @return resized version of icon */ public static Icon sizeIcon( Icon icon, final int size ) { if ( icon == null ) { return new Icon() { public int getIconWidth() { return size; } public int getIconHeight() { return size; } public void paintIcon( Component c, Graphics g, int x, int y ) { } }; } else if ( icon.getIconWidth() == size && icon.getIconHeight() == size ) { return icon; } else { return new SizedIcon( icon, size ); } } /** * Icon implementation which is rescaled to so that one dimension * (either width or height) has a fixed value. * * @param icon input icon * @param fixDim the fixed dimension in pixels * @param maxAspect maximum aspect ratio (>= 1) * @param fixVertical true to fix height, false to fix width */ public static Icon scaleIcon( final Icon icon, final int fixDim, double maxAspect, boolean fixVertical ) { final int w = icon.getIconWidth(); final int h = icon.getIconHeight(); if ( ( fixVertical ? h : w ) == fixDim && ( fixVertical ? h / (double) w : w / (double) h ) <= maxAspect ) { return icon; } double factor = fixDim / (double) ( fixVertical ? h : w ); if ( factor > 1.0 && factor < 2.0 ) { factor = 1.0; } double aspect = factor * ( fixVertical ? h : w ) / fixDim; if ( aspect > maxAspect ) { factor *= maxAspect / aspect; } final int width = fixVertical ? (int) Math.ceil( factor * w ) : fixDim; final int height = fixVertical ? fixDim : (int) Math.ceil( factor * h ); final double fact = factor; return new Icon() { public int getIconWidth() { return width; } public int getIconHeight() { return height; } public void paintIcon( Component c, Graphics g, int x, int y ) { if ( fact == 1.0 ) { icon.paintIcon( c, g, x + ( width - w ) / 2, y + ( height - h ) / 2 ); } else { Graphics2D g2 = (Graphics2D) g; AffineTransform trans = g2.getTransform(); g2.translate( x + ( width - w * fact ) / 2, y + ( height - h * fact ) / 2 ); g2.scale( fact, fact ); icon.paintIcon( c, g2, 0, 0 ); g2.setTransform( trans ); } } }; } /** * Reads an icon from a URL, with a maximum wait time. * If the timeout is exceeded, an exception will be thrown. * * @param url icon URL * @param waitSecs maximum time in seconds to wait * @return icon from url * @throws IOException if timeout has been exceeded */ private static Icon readIcon( String url, int waitSecs ) throws IOException { final URL urlLoc = new URL( url ); final Icon[] icons = new Icon[ 1 ]; Thread loader = new Thread( "IconLoader " + url ) { public void run() { icons[ 0 ] = new ImageIcon( urlLoc ); } }; loader.start(); try { loader.join( waitSecs * 1000 ); Icon icon = icons[ 0 ]; if ( icon != null ) { return icon; } else { throw new IOException( "Icon load timeout (" + waitSecs + "s)" ); } } catch ( InterruptedException e ) { throw (IOException) new IOException( "Load interrupted" ) .initCause( e ); } } /** * Icon implementation which looks like an existing one, but is resized * down if necessary. */ private static class SizedIcon implements Icon { private final Icon icon_; private final int size_; private final double factor_; /** * Constructor. * * @param icon original icon * @param size number of horizontal and vertical pixels in this icon */ public SizedIcon( Icon icon, int size ) { icon_ = icon; size_ = size; factor_ = Math.min( 1.0, Math.min( size / (double) icon.getIconWidth(), size / (double) icon.getIconHeight() ) ); } public int getIconWidth() { return size_; } public int getIconHeight() { return size_; } public void paintIcon( Component c, Graphics g, int x, int y ) { int iw = icon_.getIconWidth(); int ih = icon_.getIconHeight(); if ( factor_ == 1.0 ) { icon_.paintIcon( c, g, x + ( size_ - iw ) / 2, y + ( size_ - ih ) / 2 ); } else { Graphics2D g2 = (Graphics2D) g; AffineTransform trans = g2.getTransform(); g2.translate( x + ( size_ - iw * factor_ ) / 2, y + ( size_ - ih * factor_ ) / 2 ); g2.scale( factor_, factor_ ); icon_.paintIcon( c, g2, 0, 0 ); g2.setTransform( trans ); } } } } jsamp/src/java/org/astrogrid/samp/gui/TransmissionListIcon.java0000664000175000017500000002136512730747754024541 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.Color; import java.awt.Component; import java.awt.Dimension; import java.awt.Graphics; import java.awt.Insets; import java.awt.Point; import java.awt.Rectangle; import java.awt.event.MouseEvent; import javax.swing.BorderFactory; import javax.swing.Icon; import javax.swing.JComponent; import javax.swing.ListModel; import javax.swing.ToolTipManager; import javax.swing.event.ListDataEvent; import javax.swing.event.ListDataListener; /** * Icon which paints a graphical representation of a list of Transmissions. * * @author Mark Taylor * @since 21 Nov 2008 */ public class TransmissionListIcon implements Icon { private final ListModel rxModel_; private final ListModel txModel_; private final int size_; private final int transIconWidth_; private final Icon targetIcon_; /** * Constructor. * * @param rxModel list of messages received; * all elements must be {@link Transmission} objects * @param txModel list of messages sent; * all elements must be {@link Transmission} objects * @param size height of icon in pixels; this also scales the width */ public TransmissionListIcon( ListModel rxModel, ListModel txModel, int size ) { rxModel_ = rxModel; txModel_ = txModel; size_ = size; transIconWidth_ = (int) Math.floor( size_ * 0.866 ); // equilateral final boolean hasTx = txModel_ != null; final boolean hasRx = rxModel_ != null; targetIcon_ = new Icon() { public int getIconWidth() { return size_; } public int getIconHeight() { return size_; } public void paintIcon( Component c, Graphics g, int x, int y ) { g.drawOval( x + 1, y + 1, size_ - 2, size_ - 2 ); } }; } /** * Returns the transmission (if any) which is painted at a given point. * * @param point screen point relative to the origin of this icon * @return transmission painted at point or null if there * isn't one */ public Transmission getTransmissionAt( Point point ) { int x = point.x; int y = point.y; if ( x < 0 || x > getIconWidth() || y < 0 || y > getIconHeight() ) { return null; } int x0 = 0; int rxWidth = rxModel_.getSize() * transIconWidth_; if ( x - x0 < rxWidth ) { int ir = ( x - x0 ) / transIconWidth_; return (Transmission) rxModel_.getElementAt( ir ); } x0 += rxWidth; int targetWidth = targetIcon_.getIconWidth(); if ( x - x0 < targetWidth ) { return null; } x0 += targetWidth; int txWidth = txModel_.getSize() * transIconWidth_; if ( x - x0 < txWidth ) { int it = ( x - x0 ) / transIconWidth_; return (Transmission) txModel_.getElementAt( it ); } x0 += txWidth; assert x > x0; return null; } public int getIconWidth() { return ( rxModel_ != null ? rxModel_.getSize() * transIconWidth_ : 0 ) + targetIcon_.getIconWidth() + ( txModel_ != null ? txModel_.getSize() * transIconWidth_ : 0 ); } public int getIconHeight() { return size_; } public void paintIcon( Component c, Graphics g, int x, int y ) { if ( rxModel_ != null ) { for ( int i = 0; i < rxModel_.getSize(); i++ ) { Transmission trans = (Transmission) rxModel_.getElementAt( i ); Icon transIcon = getTransIcon( trans, false ); transIcon.paintIcon( c, g, x, y ); x += transIcon.getIconWidth(); } } targetIcon_.paintIcon( c, g, x, y ); x += targetIcon_.getIconWidth(); if ( txModel_ != null ) { for ( int i = 0; i < txModel_.getSize(); i++ ) { Transmission trans = (Transmission) txModel_.getElementAt( i ); Icon transIcon = getTransIcon( trans, true ); transIcon.paintIcon( c, g, x, y ); x += transIcon.getIconWidth(); } } } /** * Returns an icon which can paint a particular transmission. * * @param trans transmission * @param isTx true if trans represents a send, * false if it represents a receive */ private Icon getTransIcon( Transmission trans, final boolean isTx ) { return new Icon() { public int getIconHeight() { return size_; } public int getIconWidth() { return transIconWidth_; } public void paintIcon( Component c, Graphics g, int x, int y ) { int xlo = x + 1; int xhi = x + transIconWidth_ - 1; int[] xs = isTx ? new int[] { xhi, xlo, xhi, } : new int[] { xlo, xhi, xlo, }; int[] ys = new int[] { y, y + size_ / 2, y + size_ - 1 }; g.fillPolygon( xs, ys, 3 ); } }; } public JComponent createBox( int nTrans ) { return new TransmissionListBox( this, nTrans ); } private static class TransmissionListBox extends JComponent { private final TransmissionListIcon icon_; private final int nTrans_; private final boolean hasRx_; private final boolean hasTx_; private Dimension minSize_; TransmissionListBox( TransmissionListIcon icon, int nTrans ) { icon_ = icon; nTrans_ = nTrans; setOpaque( true ); setBackground( Color.WHITE ); setBorder( BorderFactory.createLineBorder( Color.BLACK ) ); ListDataListener listener = new ListDataListener() { public void contentsChanged( ListDataEvent evt ) { repaint(); } public void intervalAdded( ListDataEvent evt ) { repaint(); } public void intervalRemoved( ListDataEvent evt ) { repaint(); } }; hasRx_ = icon.rxModel_ != null; hasTx_ = icon.txModel_ != null; if ( hasRx_ ) { icon.rxModel_.addListDataListener( listener ); } if ( hasTx_ ) { icon.txModel_.addListDataListener( listener ); } setPreferredSize( getSizeForCount( nTrans ) ); setMinimumSize( getSizeForCount( Math.max( nTrans, 4 ) ) ); ToolTipManager.sharedInstance().registerComponent( this ); } public Dimension getSizeForCount( int nTrans ) { int width = icon_.targetIcon_.getIconWidth(); width += ( ( hasRx_ ? 1 : 0 ) + ( hasTx_ ? 1 : 0 ) ) * nTrans_ * icon_.transIconWidth_; int height = icon_.size_; Dimension size = new Dimension( width, height ); Insets insets = getInsets(); size.width += insets.left + insets.right; size.height += insets.top + insets.bottom; return size; } public String getToolTipText( MouseEvent evt ) { Point p = evt.getPoint(); Point iconPos = getIconPosition(); p.x -= iconPos.x; p.y -= iconPos.y; Transmission trans = icon_.getTransmissionAt( p ); if ( trans != null ) { return new StringBuffer() .append( trans.getMessage().getMType() ) .append( ": " ) .append( trans.getSender().toString() ) .append( " -> " ) .append( trans.getReceiver().toString() ) .toString(); } else { return null; } } private Point getIconPosition() { Insets insets = getInsets(); return new Point( insets.left, insets.top ); } protected void paintComponent( Graphics g ) { super.paintComponent( g ); Color color = g.getColor(); Rectangle bounds = getBounds(); Insets insets = getInsets(); if ( isOpaque() ) { g.setColor( getBackground() ); g.fillRect( insets.left, insets.top, bounds.width - insets.left - insets.right, bounds.height - insets.top - insets.bottom ); } g.setColor( color ); Point p = getIconPosition(); icon_.paintIcon( this, g, p.x, p.y ); } } } jsamp/src/java/org/astrogrid/samp/gui/TransmissionCellRenderer.java0000664000175000017500000000236612730747754025363 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.Component; import java.awt.Graphics; import javax.swing.Icon; /** * CellRenderer for transmission objects. * * @author Mark Taylor * @since 27 Nov 2008 */ class TransmissionCellRenderer implements IconBox.CellRenderer { public Icon getIcon( IconBox iconBox, Object value, int index ) { final int size = iconBox.getTransverseSize(); if ( value instanceof Transmission ) { return ((Transmission) value).getStatus().getIcon( size ); } else { return new Icon() { public void paintIcon( Component c, Graphics g, int x, int y ) { int s = size - 3 + ( size % 2 ); g.drawOval( x + 1, y + 1, s, s ); } public int getIconWidth() { return size; } public int getIconHeight() { return size; } }; } } public String getToolTipText( IconBox iconBox, Object value, int index ) { if ( value instanceof Transmission ) { return ((Transmission) value).getMessage().getMType(); } else { return null; } } } jsamp/src/java/org/astrogrid/samp/gui/SubscribedClientListModel.java0000664000175000017500000000540712730747754025443 0ustar sladensladenpackage org.astrogrid.samp.gui; import org.astrogrid.samp.Client; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; /** * Selective client list model which contains only those non-self clients * which are subscribed to one or more of a given list of MTypes. * * @author Mark Taylor * @since 1 Sep 2008 */ public class SubscribedClientListModel extends SelectiveClientListModel { private final GuiHubConnector connector_; private String[] mtypes_; /** * Constructor for multiple MTypes. * * @param connector hub connector * @param mtypes mtypes of interest (may have wildcards) */ public SubscribedClientListModel( GuiHubConnector connector, String[] mtypes ) { super( connector.getClientListModel() ); connector_ = connector; mtypes_ = (String[]) mtypes.clone(); init(); } /** * Constructor for single MType. * * @param connector hub connector * @param mtype mtype of interest (may have wildcards) */ public SubscribedClientListModel( GuiHubConnector connector, String mtype ) { this( connector, new String[] { mtype } ); } /** * Sets the list of MTypes which defines the elements of this list. * Any client subscribed to one or more of these MTypes is included. * * @param mtypes new MType list */ public void setMTypes( String[] mtypes ) { mtypes_ = (String[]) mtypes.clone(); refresh(); fireContentsChanged( this, -1, -1 ); } /** * Returns the list of MTypes which defines the elements of this list. * * @return MType list */ public String[] getMTypes() { return mtypes_; } /** * Returns true if client is subscribed to one of this * model's MTypes. */ protected boolean isIncluded( Client client ) { String selfId; try { HubConnection connection = connector_.getConnection(); if ( connection == null ) { return false; } else { selfId = connection.getRegInfo().getSelfId(); } } catch ( SampException e ) { return false; } if ( client.getId().equals( selfId ) ) { return false; } Subscriptions subs = client.getSubscriptions(); if ( subs != null ) { for ( int im = 0; im < mtypes_.length; im++ ) { if ( subs.isSubscribed( mtypes_[ im ] ) ) { return true; } } } return false; } } jsamp/src/java/org/astrogrid/samp/gui/BrowserLauncher.java0000664000175000017500000006752412730747754023517 0ustar sladensladen// package edu.stanford.ejalbert; // // This class taken from http://browserlauncher.sourceforge.net/ // and renamed with a couple of tweaks for convenience. // Original version is BrowserLauncher 1.4b1. package org.astrogrid.samp.gui; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; /** * BrowserLauncher is a class that provides one static method, openURL, which opens the default * web browser for the current user of the system to the given URL. It may support other * protocols depending on the system -- mailto, ftp, etc. -- but that has not been rigorously * tested and is not guaranteed to work. *

      * Yes, this is platform-specific code, and yes, it may rely on classes on certain platforms * that are not part of the standard JDK. What we're trying to do, though, is to take something * that's frequently desirable but inherently platform-specific -- opening a default browser -- * and allow programmers (you, for example) to do so without worrying about dropping into native * code or doing anything else similarly evil. *

      * Anyway, this code is completely in Java and will run on all JDK 1.1-compliant systems without * modification or a need for additional libraries. All classes that are required on certain * platforms to allow this to run are dynamically loaded at runtime via reflection and, if not * found, will not cause this to do anything other than returning an error when opening the * browser. *

      * There are certain system requirements for this class, as it's running through Runtime.exec(), * which is Java's way of making a native system call. Currently, this requires that a Macintosh * have a Finder which supports the GURL event, which is true for Mac OS 8.0 and 8.1 systems that * have the Internet Scripting AppleScript dictionary installed in the Scripting Additions folder * in the Extensions folder (which is installed by default as far as I know under Mac OS 8.0 and * 8.1), and for all Mac OS 8.5 and later systems. On Windows, it only runs under Win32 systems * (Windows 95, 98, and NT 4.0, as well as later versions of all). On other systems, this drops * back from the inherently platform-sensitive concept of a default browser and simply attempts * to launch Netscape via a shell command. *

      * This code is Copyright 1999-2001 by Eric Albert (ejalbert@cs.stanford.edu) and may be * redistributed or modified in any form without restrictions as long as the portion of this * comment from this paragraph through the end of the comment is not removed. The author * requests that he be notified of any application, applet, or other binary that makes use of * this code, but that's more out of curiosity than anything and is not required. This software * includes no warranty. The author is not repsonsible for any loss of data or functionality * or any adverse or unexpected effects of using this software. *

      * Credits: *
      Steven Spencer, JavaWorld magazine (Java Tip 66) *
      Thanks also to Ron B. Yeh, Eric Shapiro, Ben Engber, Paul Teitlebaum, Andrea Cantatore, * Larry Barowski, Trevor Bedzek, Frank Miedrich, and Ron Rabakukk * * @author Eric Albert (ejalbert@cs.stanford.edu) * @version 1.4b1 (Released June 20, 2001) */ class BrowserLauncher { /** * The Java virtual machine that we are running on. Actually, in most cases we only care * about the operating system, but some operating systems require us to switch on the VM. */ private static int jvm; /** The browser for the system */ private static Object browser; /** * Caches whether any classes, methods, and fields that are not part of the JDK and need to * be dynamically loaded at runtime loaded successfully. *

      * Note that if this is false, openURL() will always return an * IOException. */ private static boolean loadedWithoutErrors; /** The com.apple.mrj.MRJFileUtils class */ private static Class mrjFileUtilsClass; /** The com.apple.mrj.MRJOSType class */ private static Class mrjOSTypeClass; /** The com.apple.MacOS.AEDesc class */ private static Class aeDescClass; /** The (int) method of com.apple.MacOS.AETarget */ private static Constructor aeTargetConstructor; /** The (int, int, int) method of com.apple.MacOS.AppleEvent */ private static Constructor appleEventConstructor; /** The (String) method of com.apple.MacOS.AEDesc */ private static Constructor aeDescConstructor; /** The findFolder method of com.apple.mrj.MRJFileUtils */ private static Method findFolder; /** The getFileCreator method of com.apple.mrj.MRJFileUtils */ private static Method getFileCreator; /** The getFileType method of com.apple.mrj.MRJFileUtils */ private static Method getFileType; /** The openURL method of com.apple.mrj.MRJFileUtils */ private static Method openURL; /** The makeOSType method of com.apple.MacOS.OSUtils */ private static Method makeOSType; /** The putParameter method of com.apple.MacOS.AppleEvent */ private static Method putParameter; /** The sendNoReply method of com.apple.MacOS.AppleEvent */ private static Method sendNoReply; /** Actually an MRJOSType pointing to the System Folder on a Macintosh */ private static Object kSystemFolderType; /** The keyDirectObject AppleEvent parameter type */ private static Integer keyDirectObject; /** The kAutoGenerateReturnID AppleEvent code */ private static Integer kAutoGenerateReturnID; /** The kAnyTransactionID AppleEvent code */ private static Integer kAnyTransactionID; /** The linkage object required for JDirect 3 on Mac OS X. */ private static Object linkage; /** The framework to reference on Mac OS X */ private static final String JDirect_MacOSX = "/System/Library/Frameworks/Carbon.framework/Frameworks/HIToolbox.framework/HIToolbox"; /** JVM constant for MRJ 2.0 */ private static final int MRJ_2_0 = 0; /** JVM constant for MRJ 2.1 or later */ private static final int MRJ_2_1 = 1; /** JVM constant for Java on Mac OS X 10.0 (MRJ 3.0) */ private static final int MRJ_3_0 = 3; /** JVM constant for MRJ 3.1 */ private static final int MRJ_3_1 = 4; /** JVM constant for any Windows NT JVM */ private static final int WINDOWS_NT = 5; /** JVM constant for any Windows 9x JVM */ private static final int WINDOWS_9x = 6; /** JVM constant for any other platform */ private static final int OTHER = -1; /** * The file type of the Finder on a Macintosh. Hardcoding "Finder" would keep non-U.S. English * systems from working properly. */ private static final String FINDER_TYPE = "FNDR"; /** * The creator code of the Finder on a Macintosh, which is needed to send AppleEvents to the * application. */ private static final String FINDER_CREATOR = "MACS"; /** The name for the AppleEvent type corresponding to a GetURL event. */ private static final String GURL_EVENT = "GURL"; /** * The first parameter that needs to be passed into Runtime.exec() to open the default web * browser on Windows. */ private static final String FIRST_WINDOWS_PARAMETER = "/c"; /** The second parameter for Runtime.exec() on Windows. */ private static final String SECOND_WINDOWS_PARAMETER = "start"; /** * The third parameter for Runtime.exec() on Windows. This is a "title" * parameter that the command line expects. Setting this parameter allows * URLs containing spaces to work. */ private static final String THIRD_WINDOWS_PARAMETER = "\"\""; /** * The shell parameters for Netscape that opens a given URL in an already-open copy of Netscape * on many command-line systems. */ private static final String NETSCAPE_REMOTE_PARAMETER = "-remote"; private static final String NETSCAPE_OPEN_PARAMETER_START = "'openURL("; private static final String NETSCAPE_OPEN_PARAMETER_END = ")'"; /** * The message from any exception thrown throughout the initialization process. */ private static String errorMessage; /** * An initialization block that determines the operating system and loads the necessary * runtime data. */ static { loadedWithoutErrors = true; String osName = java.lang.System.getProperty("os.name"); if (osName.startsWith("Mac OS")) { String mrjVersion = java.lang.System.getProperty("mrj.version"); String majorMRJVersion = mrjVersion.substring(0, 3); try { double version = Double.valueOf(majorMRJVersion).doubleValue(); if (version == 2) { jvm = MRJ_2_0; } else if (version >= 2.1 && version < 3) { // Assume that all 2.x versions of MRJ work the same. MRJ 2.1 actually // works via Runtime.exec() and 2.2 supports that but has an openURL() method // as well that we currently ignore. jvm = MRJ_2_1; } else if (version == 3.0) { jvm = MRJ_3_0; } else if (version >= 3.1) { // Assume that all 3.1 and later versions of MRJ work the same. jvm = MRJ_3_1; } else { loadedWithoutErrors = false; errorMessage = "Unsupported MRJ version: " + version; } } catch (NumberFormatException nfe) { loadedWithoutErrors = false; errorMessage = "Invalid MRJ version: " + mrjVersion; } } else if (osName.startsWith("Windows")) { if (osName.indexOf("9") != -1) { jvm = WINDOWS_9x; } else { jvm = WINDOWS_NT; } } else { jvm = OTHER; } if (loadedWithoutErrors) { // if we haven't hit any errors yet loadedWithoutErrors = loadClasses(); } } /** * This class should be never be instantiated; this just ensures so. */ private BrowserLauncher() { } /** * Called by a static initializer to load any classes, fields, and methods required at runtime * to locate the user's web browser. * @return true if all intialization succeeded * false if any portion of the initialization failed */ private static boolean loadClasses() { switch (jvm) { case MRJ_2_0: try { Class aeTargetClass = Class.forName("com.apple.MacOS.AETarget"); Class osUtilsClass = Class.forName("com.apple.MacOS.OSUtils"); Class appleEventClass = Class.forName("com.apple.MacOS.AppleEvent"); Class aeClass = Class.forName("com.apple.MacOS.ae"); aeDescClass = Class.forName("com.apple.MacOS.AEDesc"); aeTargetConstructor = aeTargetClass.getDeclaredConstructor(new Class [] { int.class }); appleEventConstructor = appleEventClass.getDeclaredConstructor(new Class[] { int.class, int.class, aeTargetClass, int.class, int.class }); aeDescConstructor = aeDescClass.getDeclaredConstructor(new Class[] { String.class }); makeOSType = osUtilsClass.getDeclaredMethod("makeOSType", new Class [] { String.class }); putParameter = appleEventClass.getDeclaredMethod("putParameter", new Class[] { int.class, aeDescClass }); sendNoReply = appleEventClass.getDeclaredMethod("sendNoReply", new Class[] { }); Field keyDirectObjectField = aeClass.getDeclaredField("keyDirectObject"); keyDirectObject = (Integer) keyDirectObjectField.get(null); Field autoGenerateReturnIDField = appleEventClass.getDeclaredField("kAutoGenerateReturnID"); kAutoGenerateReturnID = (Integer) autoGenerateReturnIDField.get(null); Field anyTransactionIDField = appleEventClass.getDeclaredField("kAnyTransactionID"); kAnyTransactionID = (Integer) anyTransactionIDField.get(null); } catch (ClassNotFoundException cnfe) { errorMessage = cnfe.getMessage(); return false; } catch (NoSuchMethodException nsme) { errorMessage = nsme.getMessage(); return false; } catch (NoSuchFieldException nsfe) { errorMessage = nsfe.getMessage(); return false; } catch (IllegalAccessException iae) { errorMessage = iae.getMessage(); return false; } break; case MRJ_2_1: try { mrjFileUtilsClass = Class.forName("com.apple.mrj.MRJFileUtils"); mrjOSTypeClass = Class.forName("com.apple.mrj.MRJOSType"); Field systemFolderField = mrjFileUtilsClass.getDeclaredField("kSystemFolderType"); kSystemFolderType = systemFolderField.get(null); findFolder = mrjFileUtilsClass.getDeclaredMethod("findFolder", new Class[] { mrjOSTypeClass }); getFileCreator = mrjFileUtilsClass.getDeclaredMethod("getFileCreator", new Class[] { File.class }); getFileType = mrjFileUtilsClass.getDeclaredMethod("getFileType", new Class[] { File.class }); } catch (ClassNotFoundException cnfe) { errorMessage = cnfe.getMessage(); return false; } catch (NoSuchFieldException nsfe) { errorMessage = nsfe.getMessage(); return false; } catch (NoSuchMethodException nsme) { errorMessage = nsme.getMessage(); return false; } catch (SecurityException se) { errorMessage = se.getMessage(); return false; } catch (IllegalAccessException iae) { errorMessage = iae.getMessage(); return false; } break; case MRJ_3_0: try { Class linker = Class.forName("com.apple.mrj.jdirect.Linker"); Constructor constructor = linker.getConstructor(new Class[]{ Class.class }); linkage = constructor.newInstance(new Object[] { BrowserLauncher.class }); } catch (ClassNotFoundException cnfe) { errorMessage = cnfe.getMessage(); return false; } catch (NoSuchMethodException nsme) { errorMessage = nsme.getMessage(); return false; } catch (InvocationTargetException ite) { errorMessage = ite.getMessage(); return false; } catch (InstantiationException ie) { errorMessage = ie.getMessage(); return false; } catch (IllegalAccessException iae) { errorMessage = iae.getMessage(); return false; } break; case MRJ_3_1: try { mrjFileUtilsClass = Class.forName("com.apple.mrj.MRJFileUtils"); openURL = mrjFileUtilsClass.getDeclaredMethod("openURL", new Class[] { String.class }); } catch (ClassNotFoundException cnfe) { errorMessage = cnfe.getMessage(); return false; } catch (NoSuchMethodException nsme) { errorMessage = nsme.getMessage(); return false; } break; default: break; } return true; } /** * Attempts to locate the default web browser on the local system. Caches results so it * only locates the browser once for each use of this class per JVM instance. * @return The browser for the system. Note that this may not be what you would consider * to be a standard web browser; instead, it's the application that gets called to * open the default web browser. In some cases, this will be a non-String object * that provides the means of calling the default browser. */ private static Object locateBrowser() { if (browser != null) { return browser; } switch (jvm) { case MRJ_2_0: try { Integer finderCreatorCode = (Integer) makeOSType.invoke(null, new Object[] { FINDER_CREATOR }); Object aeTarget = aeTargetConstructor.newInstance(new Object[] { finderCreatorCode }); Integer gurlType = (Integer) makeOSType.invoke(null, new Object[] { GURL_EVENT }); Object appleEvent = appleEventConstructor.newInstance(new Object[] { gurlType, gurlType, aeTarget, kAutoGenerateReturnID, kAnyTransactionID }); // Don't set browser = appleEvent because then the next time we call // locateBrowser(), we'll get the same AppleEvent, to which we'll already have // added the relevant parameter. Instead, regenerate the AppleEvent every time. // There's probably a way to do this better; if any has any ideas, please let // me know. return appleEvent; } catch (IllegalAccessException iae) { browser = null; errorMessage = iae.getMessage(); return browser; } catch (InstantiationException ie) { browser = null; errorMessage = ie.getMessage(); return browser; } catch (InvocationTargetException ite) { browser = null; errorMessage = ite.getMessage(); return browser; } case MRJ_2_1: File systemFolder; try { systemFolder = (File) findFolder.invoke(null, new Object[] { kSystemFolderType }); } catch (IllegalArgumentException iare) { browser = null; errorMessage = iare.getMessage(); return browser; } catch (IllegalAccessException iae) { browser = null; errorMessage = iae.getMessage(); return browser; } catch (InvocationTargetException ite) { browser = null; errorMessage = ite.getTargetException().getClass() + ": " + ite.getTargetException().getMessage(); return browser; } String[] systemFolderFiles = systemFolder.list(); // Avoid a FilenameFilter because that can't be stopped mid-list for(int i = 0; i < systemFolderFiles.length; i++) { try { File file = new File(systemFolder, systemFolderFiles[i]); if (!file.isFile()) { continue; } // We're looking for a file with a creator code of 'MACS' and // a type of 'FNDR'. Only requiring the type results in non-Finder // applications being picked up on certain Mac OS 9 systems, // especially German ones, and sending a GURL event to those // applications results in a logout under Multiple Users. Object fileType = getFileType.invoke(null, new Object[] { file }); if (FINDER_TYPE.equals(fileType.toString())) { Object fileCreator = getFileCreator.invoke(null, new Object[] { file }); if (FINDER_CREATOR.equals(fileCreator.toString())) { browser = file.toString(); // Actually the Finder, but that's OK return browser; } } } catch (IllegalArgumentException iare) { browser = browser; errorMessage = iare.getMessage(); return null; } catch (IllegalAccessException iae) { browser = null; errorMessage = iae.getMessage(); return browser; } catch (InvocationTargetException ite) { browser = null; errorMessage = ite.getTargetException().getClass() + ": " + ite.getTargetException().getMessage(); return browser; } } browser = null; break; case MRJ_3_0: case MRJ_3_1: browser = ""; // Return something non-null break; case WINDOWS_NT: browser = "cmd.exe"; break; case WINDOWS_9x: browser = "command.com"; break; case OTHER: default: browser = "firefox"; break; } return browser; } /** * Attempts to open the default web browser to the given URL. * @param url The URL to open * @throws IOException If the web browser could not be located or does not run */ public static void openURL(String url) throws IOException { if (!loadedWithoutErrors) { throw new IOException("Exception in finding browser: " + errorMessage); } Object browser = locateBrowser(); if (browser == null) { throw new IOException("Unable to locate browser: " + errorMessage); } switch (jvm) { case MRJ_2_0: Object aeDesc = null; try { aeDesc = aeDescConstructor.newInstance(new Object[] { url }); putParameter.invoke(browser, new Object[] { keyDirectObject, aeDesc }); sendNoReply.invoke(browser, new Object[] { }); } catch (InvocationTargetException ite) { throw new IOException("InvocationTargetException while creating AEDesc: " + ite.getMessage()); } catch (IllegalAccessException iae) { throw new IOException("IllegalAccessException while building AppleEvent: " + iae.getMessage()); } catch (InstantiationException ie) { throw new IOException("InstantiationException while creating AEDesc: " + ie.getMessage()); } finally { aeDesc = null; // Encourage it to get disposed if it was created browser = null; // Ditto } break; case MRJ_2_1: Runtime.getRuntime().exec(new String[] { (String) browser, url } ); break; case MRJ_3_0: int[] instance = new int[1]; int result = ICStart(instance, 0); if (result == 0) { int[] selectionStart = new int[] { 0 }; byte[] urlBytes = url.getBytes(); int[] selectionEnd = new int[] { urlBytes.length }; result = ICLaunchURL(instance[0], new byte[] { 0 }, urlBytes, urlBytes.length, selectionStart, selectionEnd); if (result == 0) { // Ignore the return value; the URL was launched successfully // regardless of what happens here. ICStop(instance); } else { throw new IOException("Unable to launch URL: " + result); } } else { throw new IOException("Unable to create an Internet Config instance: " + result); } break; case MRJ_3_1: try { openURL.invoke(null, new Object[] { url }); } catch (InvocationTargetException ite) { throw new IOException("InvocationTargetException while calling openURL: " + ite.getMessage()); } catch (IllegalAccessException iae) { throw new IOException("IllegalAccessException while calling openURL: " + iae.getMessage()); } break; case WINDOWS_NT: case WINDOWS_9x: // Add quotes around the URL to allow ampersands and other special // characters to work. Process process = Runtime.getRuntime().exec(new String[] { (String) browser, FIRST_WINDOWS_PARAMETER, SECOND_WINDOWS_PARAMETER, THIRD_WINDOWS_PARAMETER, '"' + url + '"' }); // This avoids a memory leak on some versions of Java on Windows. // That's hinted at in . try { process.waitFor(); process.exitValue(); } catch (InterruptedException ie) { throw new IOException("InterruptedException while launching browser: " + ie.getMessage()); } break; case OTHER: // Assume that we're on Unix and that Netscape is installed // First, attempt to open the URL in a currently running session of Netscape process = Runtime.getRuntime().exec(new String[] { (String) browser, NETSCAPE_REMOTE_PARAMETER, NETSCAPE_OPEN_PARAMETER_START + url + NETSCAPE_OPEN_PARAMETER_END }); try { int exitCode = process.waitFor(); if (exitCode != 0) { // if Netscape was not open Runtime.getRuntime().exec(new String[] { (String) browser, url }); } } catch (InterruptedException ie) { throw new IOException("InterruptedException while launching browser: " + ie.getMessage()); } break; default: // This should never occur, but if it does, we'll try the simplest thing possible Runtime.getRuntime().exec(new String[] { (String) browser, url }); break; } } /** * Methods required for Mac OS X. The presence of native methods does not cause * any problems on other platforms. */ private native static int ICStart(int[] instance, int signature); private native static int ICStop(int[] instance); private native static int ICLaunchURL(int instance, byte[] hint, byte[] data, int len, int[] selectionStart, int[] selectionEnd); } jsamp/src/java/org/astrogrid/samp/gui/HubMonitor.java0000664000175000017500000002376312730747754022475 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.BorderLayout; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; import javax.swing.Box; import javax.swing.ImageIcon; import javax.swing.JButton; import javax.swing.JComponent; import javax.swing.JFrame; import javax.swing.JPanel; import javax.swing.SwingUtilities; import org.astrogrid.samp.ErrInfo; import org.astrogrid.samp.Message; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.Response; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.DefaultClientProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.HubConnector; import org.astrogrid.samp.client.MessageHandler; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.httpd.UtilServer; /** * Client application which uses a {@link GuiHubConnector} * to connect to any running hub and display information about all currently * registered clients. * * @author Mark Taylor * @since 16 Jul 2008 */ public class HubMonitor extends JPanel { private final GuiHubConnector connector_; private static Logger logger_ = Logger.getLogger( HubMonitor.class.getName() ); /** * Constructor. * * @param profile SAMP profile * @param trackMessages if true, the GUI will contain a visual * representation of messages sent and received * @param autoSec number of seconds between automatic hub connection * attempts; <=0 means no automatic connections */ public HubMonitor( ClientProfile profile, boolean trackMessages, int autoSec ) { super( new BorderLayout() ); // Set up a new GuiHubConnector and GUI decorations. connector_ = trackMessages ? new MessageTrackerHubConnector( profile ) : new GuiHubConnector( profile ); // Declare the default subscriptions. This is required so that // the hub knows the client is subscribed to those hub.event // MTypes which inform about client registration, hub shutdown etc. connector_.declareSubscriptions( connector_.computeSubscriptions() ); // Declare metadata about this application. Metadata meta = new Metadata(); meta.setName( "HubMonitor" ); meta.setDescriptionText( "GUI hub monitor utility" ); try { meta.setIconUrl( UtilServer.getInstance() .exportResource( "/org/astrogrid/samp/images/" + "eye.gif" ) .toString() ); } catch ( IOException e ) { logger_.warning( "Can't set icon" ); } meta.put( "author", "Mark Taylor" ); connector_.declareMetadata( meta ); // Create and place a component which maintains a display of // currently registered clients. A more modest GUI could just use // connector.getClientListModel() as a model for a JList component. add( connector_.createMonitorPanel(), BorderLayout.CENTER ); // Prepare a container for other widgets at the bottom of the window. JPanel infoBox = new JPanel( new BorderLayout() ); add( infoBox, BorderLayout.SOUTH ); // Create and place components which allow the user to // view and control registration/unregistration explicitly. JComponent connectBox = new JPanel( new BorderLayout() ); connectBox.add( new JButton( connector_.createToggleRegisterAction() ), BorderLayout.CENTER ); connectBox.add( connector_.createConnectionIndicator(), BorderLayout.EAST ); infoBox.add( connectBox, BorderLayout.EAST ); // Create and place components which provide a compact display // of the connector's status. JComponent statusBox = Box.createHorizontalBox(); statusBox.add( connector_.createClientBox( false, 24 ) ); if ( connector_ instanceof MessageTrackerHubConnector ) { statusBox.add( ((MessageTrackerHubConnector) connector_) .createMessageBox( 24 ) ); } infoBox.add( statusBox, BorderLayout.CENTER ); // Attempt registration, and arrange that if/when unregistered we look // for a hub to register with on a regular basis. connector_.setActive( true ); connector_.setAutoconnect( autoSec ); } /** * Returns this monitor's HubConnector. * * @return hub connector */ public GuiHubConnector getHubConnector() { return connector_; } /** * Does the work for the main method. */ public static int runMain( String[] args ) { String usage = new StringBuffer() .append( "\n Usage:" ) .append( "\n " ) .append( HubMonitor.class.getName() ) .append( "\n " ) .append( " [-help]" ) .append( " [+/-verbose]" ) .append( "\n " ) .append( " [-auto ]" ) .append( " [-nomsg]" ) .append( " [-nogui]" ) .append( "\n " ) .append( " [-mtype ]" ) .append( "\n" ) .toString(); List argList = new ArrayList( Arrays.asList( args ) ); int verbAdjust = 0; boolean gui = true; boolean trackMsgs = true; int autoSec = 3; Subscriptions subs = new Subscriptions(); for ( Iterator it = argList.iterator(); it.hasNext(); ) { String arg = (String) it.next(); if ( arg.startsWith( "-auto" ) && it.hasNext() ) { it.remove(); String sauto = (String) it.next(); it.remove(); autoSec = Integer.parseInt( sauto ); } else if ( arg.equals( "-gui" ) ) { it.remove(); gui = true; } else if ( arg.equals( "-nogui" ) ) { it.remove(); gui = false; } else if ( arg.equals( "-msg" ) ) { it.remove(); trackMsgs = true; } else if ( arg.equals( "-nomsg" ) ) { it.remove(); trackMsgs = false; } else if ( arg.startsWith( "-mtype" ) && it.hasNext() ) { it.remove(); String mpat = (String) it.next(); it.remove(); subs.addMType( mpat ); } else if ( arg.startsWith( "-v" ) ) { it.remove(); verbAdjust--; } else if ( arg.startsWith( "+v" ) ) { it.remove(); verbAdjust++; } else if ( arg.startsWith( "-h" ) ) { it.remove(); System.out.println( usage ); return 0; } else { it.remove(); System.err.println( usage ); return 1; } } assert argList.isEmpty(); // Adjust logging in accordance with verboseness flags. int logLevel = Level.WARNING.intValue() + 100 * verbAdjust; Logger.getLogger( "org.astrogrid.samp" ) .setLevel( Level.parse( Integer.toString( logLevel ) ) ); // Get profile. final ClientProfile profile =DefaultClientProfile.getProfile(); // Create the HubMonitor. final HubMonitor monitor = new HubMonitor( profile, trackMsgs, autoSec ); // Add a handler for extra MTypes if so requested. if ( ! subs.isEmpty() ) { final Subscriptions extraSubs = subs; HubConnector connector = monitor.getHubConnector(); final Response dummyResponse = new Response(); dummyResponse.setStatus( Response.WARNING_STATUS ); dummyResponse.setResult( new HashMap() ); dummyResponse.setErrInfo( new ErrInfo( "Message logged, " + "no other action taken" ) ); connector.addMessageHandler( new MessageHandler() { public Map getSubscriptions() { return extraSubs; } public void receiveNotification( HubConnection connection, String senderId, Message msg ) { } public void receiveCall( HubConnection connection, String senderId, String msgId, Message msg ) throws SampException { connection.reply( msgId, dummyResponse ); } } ); connector.declareSubscriptions( connector.computeSubscriptions() ); } // Start the gui in a new window. final boolean isVisible = gui; SwingUtilities.invokeLater( new Runnable() { public void run() { JFrame frame = new JFrame( "SAMP HubMonitor" ); frame.getContentPane().add( monitor ); frame.setIconImage( new ImageIcon( Metadata.class .getResource( "images/eye.gif" ) ) .getImage() ); frame.pack(); frame.setVisible( isVisible ); frame.setDefaultCloseOperation( JFrame.EXIT_ON_CLOSE ); } } ); return 0; } /** * Displays a HubMonitor in a window. * Use -help flag. */ public static void main( String[] args ) { int status = runMain( args ); if ( status != 0 ) { System.exit( status ); } } } jsamp/src/java/org/astrogrid/samp/gui/IconBox.java0000664000175000017500000004017212730747754021741 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.Color; import java.awt.Component; import java.awt.Dimension; import java.awt.Graphics; import java.awt.Insets; import java.awt.Point; import java.awt.event.MouseEvent; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; import java.util.List; import javax.swing.AbstractListModel; import javax.swing.Icon; import javax.swing.JComponent; import javax.swing.ListModel; import javax.swing.ToolTipManager; import javax.swing.event.ListDataEvent; import javax.swing.event.ListDataListener; /** * Component which displays the contents of a ListModel as icons. * Custom icon and tooltip generation are supported by use of a separate * renderer object. * * @author Mark Taylor * @since 26 Nov 2008 */ class IconBox extends JComponent { private final List entryList_; private final ListDataListener modelListener_; private final Color enabledBg_; private final Color disabledBg_; private boolean vertical_; private boolean trailing_; private boolean reversed_; private int transSize_; private int gap_; private CellRenderer renderer_; private ListModel model_; private Dimension minSize_; private Dimension maxSize_; private Dimension prefSize_; private static final ListModel EMPTY_LIST_MODEL = createEmptyListModel(); /** * Constructor. * * @param transSize the transverse (horizontal/vertical) size * available for icons in pixels */ public IconBox( int transSize ) { transSize_ = transSize; setOpaque( true ); disabledBg_ = null; enabledBg_ = Color.WHITE; setBackground( enabledBg_ ); modelListener_ = new ListDataListener() { public void contentsChanged( ListDataEvent evt ) { int i0 = evt.getIndex0(); int i1 = evt.getIndex1(); if ( 0 <= i0 && i0 <= i1 && i1 <= entryList_.size() ) { for ( int i = i0; i <= i1; i++ ) { entryList_.set( i, createEntry( i ) ); } repaint(); // could be more efficient } else { refreshState(); } } public void intervalAdded( ListDataEvent evt ) { int i0 = evt.getIndex0(); int i1 = evt.getIndex1(); if ( 0 <= i0 && i0 <= i1 && i1 <= model_.getSize() ) { for ( int i = i0; i <= i1; i++ ) { entryList_.add( i, createEntry( i ) ); } repaint(); // could be more efficient } else { refreshState(); } } public void intervalRemoved( ListDataEvent evt ) { int i0 = evt.getIndex0(); int i1 = evt.getIndex1(); if ( 0 <= i0 && i0 <= i1 && i1 <= entryList_.size() ) { for ( int i = i1; i >= i0; i-- ) { entryList_.remove( i ); } repaint(); // could be more efficient } else { refreshState(); } } }; entryList_ = new ArrayList(); gap_ = 4; ToolTipManager.sharedInstance().registerComponent( this ); setModel( EMPTY_LIST_MODEL ); setRenderer( new DefaultRenderer() ); } /** * Sets whether icons will be lined up in a horizontal or vertical line. * * @param vertical true for vertical run, false for horizontal */ public void setVertical( boolean vertical ) { vertical_ = vertical; revalidate(); repaint(); } /** * Returns whether icons will be lined up horizontally or vertically. * * @return false for hormizontal run, true for vertical */ public boolean getVertical() { return vertical_; } /** * Sets the alignment of the icons in this component. * * @param trailing false for left/top, true for right/bottom alignment */ public void setTrailing( boolean trailing ) { trailing_ = trailing; repaint(); } /** * Returns the alignment of the icons in this component. * * @return false for left/top, true for right/bottom alignment */ public boolean getTrailing() { return trailing_; } /** * Sets the first-to-last ordering of the icons in this component. * * @param reversed false for increasing to right/bottom, * true for increasig to left/top */ public void setReversed( boolean reversed ) { reversed_ = reversed; repaint(); } /** * Returns the first-to-last ordering of the icons in this component. * * @return false for increasing to right/bottom, * true for increasig to left/top */ public boolean getReversed() { return reversed_; } public void setEnabled( boolean enabled ) { super.setEnabled( enabled ); setBackground( enabled ? enabledBg_ : disabledBg_ ); } /** * Refreshes the list-related state from scratch. */ private void refreshState() { entryList_.clear(); int count = model_.getSize(); for ( int i = 0; i < count; i++ ) { entryList_.add( createEntry( i ) ); } repaint(); } /** * Constructs an Entry object from an object contained in the ListModel, * using the currently installed renderer. * * @param index index of entry in list * @return new entry */ private Entry createEntry( int index ) { Object value = model_.getElementAt( index ); return new Entry( renderer_.getIcon( this, value, index ), renderer_.getToolTipText( this, value, index ) ); } /** * Sets the list model for use with this component. * Objects will be rendered as icons by using the currently intalled * renderer. * * @param model list model */ public void setModel( ListModel model ) { if ( model_ != null ) { model_.removeListDataListener( modelListener_ ); } model_ = model; if ( model_ != null ) { model_.addListDataListener( modelListener_ ); } refreshState(); } /** * Returns the list model used by this component. * * @return list model */ public ListModel getModel() { return model_; } /** * Sets the transverse dimension in pixels of this box. * * @param transSize pixel count across list run */ public void setTransverseSize( int transSize ) { transSize_ = transSize; if ( transSize_ != transSize ) { repaint(); } } /** * Returns the transverse dimension in pixels of this box. * * @return pixel count across run */ public int getTransverseSize() { return transSize_; } /** * Sets the object which is used to turn list model contents into the * icons and tooltips displayed by this component. * * @param renderer new renderer */ public void setRenderer( CellRenderer renderer ) { renderer_ = renderer; refreshState(); } /** * Returns the object which turns list model contents into display things. * The default value tries to cast to Icon and uses toString for tooltip. * * @return current renderer */ public CellRenderer getRenderer() { return renderer_; } public void setPreferredSize( Dimension prefSize ) { prefSize_ = prefSize; } public Dimension getPreferredSize() { if ( prefSize_ == null ) { int leng = 0; for ( Iterator it = entryList_.iterator(); it.hasNext(); ) { Entry entry = (Entry) it.next(); Icon icon = entry.icon_; leng += vertical_ ? icon.getIconHeight() : icon.getIconWidth(); if ( it.hasNext() ) { leng += gap_; } } Dimension size = vertical_ ? new Dimension( transSize_, leng ) : new Dimension( leng, transSize_ ); Insets insets = getInsets(); size.width += insets.left + insets.right; size.height += insets.top + insets.bottom; return size; } else { return prefSize_; } } public void setMinimumSize( Dimension minSize ) { minSize_ = minSize; } public Dimension getMinimumSize() { if ( minSize_ == null ) { Dimension size = vertical_ ? new Dimension( transSize_, 0 ) : new Dimension( 0, transSize_ ); Insets insets = getInsets(); size.width += insets.left + insets.right; size.height += insets.top + insets.bottom; return size; } else { return minSize_; } } public void setMaximumSize( Dimension maxSize ) { maxSize_ = maxSize; } public Dimension getMaximumSize() { if ( maxSize_ == null ) { Dimension size = new Dimension( transSize_, transSize_ ); Insets insets = getInsets(); size.width += insets.left + insets.right; size.height += insets.top + insets.bottom; if ( vertical_ ) { size.height = Integer.MAX_VALUE; } else { size.width = Integer.MAX_VALUE; } return size; } else { return maxSize_; } } /** * Returns the index of the list model element whose icon is displayed * at a given point in this component. * * @param point point to interrogate * @return list model index, or -1 if not found */ public int getIndexAt( Point point ) { Dimension size = getSize(); Insets insets = getInsets(); // The following should reject the request if it's outside this components // bounds. However, it seems that sometimes (always??) getSize() reports // zero size. I don't understand why, and I'm surprised the rest of the // functionality here works under these circumstances; but it does. // Leave it like this for now. // if ( point.x < insets.left || point.x > size.width - insets.right || // point.y < insets.top || point.y > size.height - insets.bottom ) { // return -1; // } List entryList = entryList_; if ( reversed_ ) { entryList = new ArrayList( entryList ); Collections.reverse( entryList ); } int pLeng = trailing_ ? ( vertical_ ? size.height - insets.bottom - point.y : size.width - insets.right - point.x ) : ( vertical_ ? point.y - insets.top : point.x - insets.left ); int index = 0; for ( Iterator it = entryList.iterator(); it.hasNext(); ) { Icon icon = ((Entry) it.next()).icon_; int leng = gap_ + ( vertical_ ? icon.getIconHeight() : icon.getIconWidth() ); if ( pLeng < leng ) { if ( index < entryList.size() ) { return index; } else { assert false; return -1; } } pLeng -= leng; index++; } return -1; } protected void paintComponent( Graphics g ) { super.paintComponent( g ); Dimension size = getSize(); if ( isOpaque() ) { Color color = g.getColor(); g.setColor( getBackground() ); g.fillRect( 0, 0, size.width, size.height ); g.setColor( color ); } Insets insets = getInsets(); List entryList = entryList_; if ( reversed_ ) { entryList = new ArrayList( entryList ); Collections.reverse( entryList ); } if ( entryList.isEmpty() ) { return; } int x = vertical_ ? insets.left : ( trailing_ ? size.width - insets.right - ((Entry) entryList.get( 0 )) .icon_.getIconWidth() : insets.left ); int y = vertical_ ? ( trailing_ ? size.height - insets.bottom - ((Entry) entryList.get( 0 )) .icon_.getIconHeight() : insets.top ) : insets.top; for ( Iterator it = entryList.iterator(); it.hasNext(); ) { Icon icon = ((Entry) it.next()).icon_; int width = icon.getIconWidth(); int height = icon.getIconHeight(); if ( g.hitClip( x, y, width, height ) ) { icon.paintIcon( this, g, x, y ); } if ( vertical_ ) { y += ( trailing_ ? -1 : +1 ) * ( height + gap_ ); } else { x += ( trailing_ ? -1 : +1 ) * ( width + gap_ ); } } } public String getToolTipText( MouseEvent evt ) { int index = getIndexAt( evt.getPoint() ); return index >= 0 ? ((Entry) entryList_.get( index )).tooltip_ : null; } /** * Defines how list model elements will be rendered as icons and tooltips. */ interface CellRenderer { /** * Returns the icon to be displayed for a given list model element. * * @param iconBox component using this renderer * @param value list model element * @param index index in the entry list being rendered * @return icon to paint */ Icon getIcon( IconBox iconBox, Object value, int index ); /** * Returns the tooltip text to be used for a given list model element. * Null is OK. * * @param iconBox component using this renderer * @param value list model element * @param index index in the entry list being rendered * @return tooltip for value */ String getToolTipText( IconBox iconBox, Object value, int index ); } /** * Convenience struct-type class which aggregates an icon and a tooltip. */ private static class Entry { final Icon icon_; final String tooltip_; /** * Constructor. * * @param icon icon * @param tooltip tooltip */ Entry( Icon icon, String tooltip ) { icon_ = icon; tooltip_ = tooltip; } } /** * Constructs an immutable list model with no content. * * @return dummy list model */ private static ListModel createEmptyListModel() { return new AbstractListModel() { public int getSize() { return 0; } public Object getElementAt( int index ) { return null; } }; } /** * Default renderer. */ private class DefaultRenderer implements CellRenderer, Icon { public Icon getIcon( IconBox iconBox, Object value, int index ) { return value instanceof Icon ? (Icon) value : (Icon) this; } public String getToolTipText( IconBox iconBox, Object value, int index ) { return value == null ? null : value.toString(); } public int getIconWidth() { return transSize_; } public int getIconHeight() { return transSize_; } public void paintIcon( Component c, Graphics g, int x, int y ) { g.drawOval( x, y, transSize_, transSize_ ); } } } jsamp/src/java/org/astrogrid/samp/gui/MessageTrackerHubConnector.java0000664000175000017500000010321612730747754025611 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.BorderLayout; import java.awt.Color; import java.awt.Dimension; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.WeakHashMap; import java.util.logging.Logger; import javax.swing.AbstractListModel; import javax.swing.BorderFactory; import javax.swing.Box; import javax.swing.BoxLayout; import javax.swing.JComponent; import javax.swing.JPanel; import javax.swing.JTabbedPane; import javax.swing.ListCellRenderer; import javax.swing.ListModel; import javax.swing.SwingUtilities; import javax.swing.UIManager; import javax.swing.event.ListDataEvent; import javax.swing.event.ListDataListener; import org.astrogrid.samp.Client; import org.astrogrid.samp.Message; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.Response; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.client.TrackedClientSet; /** * HubConnector implementation which provides facilities for keeping track * of incoming and outgoing messages as well as the other GUI features. * * @author Mark Taylor * @since 26 Nov 2008 */ public class MessageTrackerHubConnector extends GuiHubConnector implements ClientTransmissionHolder { private final TransmissionListModel txListModel_; private final TransmissionListModel rxListModel_; private final TransmissionTableModel txTableModel_; private final TransmissionTableModel rxTableModel_; private final Map callAllMap_; private final Map txModelMap_; private final Map rxModelMap_; private final ListDataListener transListListener_; private final int listRemoveDelay_; private static final Logger logger_ = Logger.getLogger( MessageTrackerHubConnector.class.getName() ); /** * Constructs a hub connector with default message tracker GUI expiry times. * * @param profile profile implementation */ public MessageTrackerHubConnector( ClientProfile profile ) { this( profile, 500, 20000, 100 ); } /** * Constructs a hub connector with specified message tracker GUI * expiry times. * The delay times are times in milliseconds after message resolution * before message representations expire and hence remove themselves * from gui components. * * @param profile profile implementation * @param listRemoveDelay expiry delay for summary icons in client * list display * @param tableRemoveDelay expiry delay for rows in message * table display * @param tableMaxRows maximum number of rows in message table * (beyond this limit resolved messages may be * removed early) */ public MessageTrackerHubConnector( ClientProfile profile, int listRemoveDelay, int tableRemoveDelay, int tableMaxRows ) { super( profile ); listRemoveDelay_ = listRemoveDelay; transListListener_ = new ClientTransmissionListListener(); txListModel_ = new TransmissionListModel( listRemoveDelay_ ); rxListModel_ = new TransmissionListModel( listRemoveDelay_ ); txListModel_.addListDataListener( transListListener_ ); rxListModel_.addListDataListener( transListListener_ ); txTableModel_ = new TransmissionTableModel( false, true, tableRemoveDelay, tableMaxRows ); rxTableModel_ = new TransmissionTableModel( true, false, tableRemoveDelay, tableMaxRows ); callAllMap_ = new HashMap(); // access only from EDT txModelMap_ = new WeakHashMap(); rxModelMap_ = new WeakHashMap(); } /** * Returns a ListModel representing the pending messages sent using * this connector. * Elements of the model are {@link Transmission} objects. * * @return transmission list model */ public ListModel getTxListModel() { return txListModel_; } /** * Returns a ListModel representing the pending messages received using * this connector. * Elements of the model are {@link Transmission} objects. * * @return transmission list model */ public ListModel getRxListModel() { return rxListModel_; } public ListModel getTxListModel( Client client ) { if ( ! txModelMap_.containsKey( client ) ) { TransmissionListModel listModel = new TransmissionListModel( listRemoveDelay_ ); listModel.addListDataListener( transListListener_ ); txModelMap_.put( client, listModel ); } return (ListModel) txModelMap_.get( client ); } public ListModel getRxListModel( Client client ) { if ( ! rxModelMap_.containsKey( client ) ) { TransmissionListModel listModel = new TransmissionListModel( listRemoveDelay_ ); listModel.addListDataListener( transListListener_ ); rxModelMap_.put( client, listModel ); } return (ListModel) rxModelMap_.get( client ); } /** * Returns a component which displays messages currently being * sent/received by this connector. * * @return iconSize height of icons in box */ public JComponent createMessageBox( int iconSize ) { JComponent box = createMessageBox( iconSize, rxListModel_, txListModel_ ); registerUpdater( box, ENABLE_COMPONENT ); return box; } /** * Returns a component which displays messages in receiver and/or sender * list models. * * @param iconSize height of icons * @param rxListModel list model containing received * {@link Transmission} objects * @param txListModel list model containing sent * {@link Transmission} objects */ public static JComponent createMessageBox( int iconSize, ListModel rxListModel, ListModel txListModel ) { final Color dtColor = UIManager.getColor( "Label.disabledText" ); JComponent box = new JPanel() { final Color enabledFg = getForeground(); final Color enabledBg = Color.WHITE; final Color disabledFg = null; final Color disabledBg = getBackground(); public void setEnabled( boolean enabled ) { super.setEnabled( enabled ); setForeground( enabled ? enabledFg : disabledFg ); setBackground( enabled ? enabledBg : disabledFg ); } }; box.setLayout( new BoxLayout( box, BoxLayout.X_AXIS ) ); if ( rxListModel != null ) { IconBox rxBox = new IconBox( iconSize ); rxBox.setOpaque( false ); rxBox.setTrailing( true ); rxBox.setModel( rxListModel ); rxBox.setRenderer( new TransmissionCellRenderer() { public String getToolTipText( IconBox iconBox, Object value, int index ) { if ( value instanceof Transmission ) { Transmission trans = (Transmission) value; return new StringBuffer() .append( trans.getMessage().getMType() ) .append( " <- " ) .append( trans.getSender().toString() ) .toString(); } else { return super.getToolTipText( iconBox, value, index ); } } } ); Dimension prefSize = rxBox.getPreferredSize(); prefSize.width = iconSize * 3; rxBox.setPreferredSize( prefSize ); box.add( rxBox ); } IconBox cBox = new IconBox( iconSize ); cBox.setOpaque( false ); cBox.setBorder( BorderFactory.createEmptyBorder( 0, 2, 0, 2 ) ); cBox.setModel( new AbstractListModel() { public int getSize() { return 1; } public Object getElementAt( int index ) { return "app"; } } ); cBox.setRenderer( new TransmissionCellRenderer() ); Dimension cSize = cBox.getPreferredSize(); cBox.setMaximumSize( cSize ); cBox.setMinimumSize( cSize ); box.add( cBox ); if ( txListModel != null ) { IconBox txBox = new IconBox( iconSize ); txBox.setOpaque( false ); txBox.setModel( txListModel ); txBox.setRenderer( new TransmissionCellRenderer() { public String getToolTipText( IconBox iconBox, Object value, int index ) { if ( value instanceof Transmission ) { Transmission trans = (Transmission) value; return new StringBuffer() .append( trans.getMessage().getMType() ) .append( " -> " ) .append( trans.getReceiver().toString() ) .toString(); } else { return super.getToolTipText( iconBox, value, index ); } } } ); Dimension prefSize = txBox.getPreferredSize(); prefSize.width = iconSize * 3; txBox.setPreferredSize( prefSize ); box.add( txBox ); } box.setBackground( Color.WHITE ); box.setBorder( createBoxBorder() ); return box; } public ListCellRenderer createClientListCellRenderer() { MessageTrackerListCellRenderer renderer = new MessageTrackerListCellRenderer( this ); renderer.setTransmissionCellRenderer( new TransmissionCellRenderer() { public String getToolTipText( IconBox iconBox, Object value, int index ) { return value instanceof Transmission ? ((Transmission) value).getMessage().getMType() : super.getToolTipText( iconBox, value, index ); } } ); return renderer; } public JComponent createMonitorPanel() { JTabbedPane tabber = new JTabbedPane(); // Add client view tab. HubView hubView = new HubView( false ); hubView.setClientListModel( getClientListModel() ); hubView.getClientList() .setCellRenderer( createClientListCellRenderer() ); tabber.add( "Clients", hubView ); // Add received message tab. tabber.add( "Received Messages", new TransmissionView( rxTableModel_ ) ); // Add sent message tab. tabber.add( "Sent Messages", new TransmissionView( txTableModel_ ) ); // Position and return. JComponent panel = new JPanel( new BorderLayout() ); panel.add( tabber, BorderLayout.CENTER ); return panel; } protected HubConnection createConnection() throws SampException { HubConnection connection = super.createConnection(); return connection == null ? null : new MessageTrackerHubConnection( connection ); } /** * Schedules a new transmission to add to the appropriate list models. * May be called from any thread. * * @param trans transmission * @param tx true for send, false for receive */ private void scheduleAddTransmission( final Transmission trans, final boolean tx ) { SwingUtilities.invokeLater( new Runnable() { public void run() { ( tx ? txTableModel_ : rxTableModel_ ).addTransmission( trans ); ((TransmissionListModel) getTxListModel( trans.getSender() )) .addTransmission( trans ); ((TransmissionListModel) getRxListModel( trans.getReceiver() )) .addTransmission( trans ); } } ); } /** * Schedules a response to be registered for a previously added * transmission. * May be called from any thread. * * @param trans transmission * @param response response to associated with trans */ private void scheduleSetResponse( final Transmission trans, final Response response ) { SwingUtilities.invokeLater( new Runnable() { public void run() { trans.setResponse( response ); } } ); } /** * Schedules an error to be registered for a previously added * transmission. * May be called from any thread. * * @param trans transmission * @param error exception */ private void scheduleSetFailure( final Transmission trans, final Throwable error ) { SwingUtilities.invokeLater( new Runnable() { public void run() { trans.setError( error ); } } ); } /** * HubConnection object which intercepts calls to keep track of * outgoing and incoming messages. */ private class MessageTrackerHubConnection extends WrapperHubConnection { private Client selfClient_; /** * Constructor. * * @param base connection on which this one is based */ MessageTrackerHubConnection( HubConnection base ) { super( base ); } /** * Returns a Client object for use in Transmission objects * which represents this connection's owner. * This has to be the same object as is used in the client set, * otherwise the various models don't get updated correctly. * For this reason, it has to be obtained lazily, after the client set * has been initialised. * * @return self client object */ Client getSelfClient() { if ( selfClient_ == null ) { selfClient_ = (Client) getClientMap().get( getRegInfo().getSelfId() ); assert selfClient_ != null; txModelMap_.put( selfClient_, txListModel_ ); rxModelMap_.put( selfClient_, rxListModel_ ); } return selfClient_; } public void notify( final String recipientId, final Map msg ) throws SampException { // Construct a transmission corresponding to this notify and // add it to the send list. Client recipient = (Client) getClientMap().get( recipientId ); Transmission trans = recipient == null ? null : new Transmission( getSelfClient(), recipient, Message.asMessage( msg ), null, null ); if ( trans != null ) { scheduleAddTransmission( trans, true ); } // Do the actual send. try { super.notify( recipientId, msg ); // Notify won't generate a response, so signal that now. if ( trans != null ) { scheduleSetResponse( trans, null ); } } // If the send failed, signal it. catch ( SampException e ) { if ( trans != null ) { scheduleSetFailure( trans, e ); } throw e; } } public List notifyAll( Map msg ) throws SampException { // Do the send. List recipientIdList = super.notifyAll( msg ); // Construct a list of transmissions corresponding to this notify // and add them to the send list. final List transList = new ArrayList(); Message message = Message.asMessage( msg ); Client sender = getSelfClient(); for ( Iterator it = recipientIdList.iterator(); it.hasNext(); ) { Client recipient = (Client) getClientMap().get( (String) it.next() ); if ( recipient != null ) { Transmission trans = new Transmission( sender, recipient, message, null, null ); scheduleAddTransmission( trans, true ); // Notify won't generate a response, so signal that now. scheduleSetResponse( trans, null ); } } return recipientIdList; } public String call( String recipientId, String msgTag, Map msg ) throws SampException { // Construct a transmission corresponding to this call // and add it to the send list. Client recipient = (Client) getClientMap().get( recipientId ); Transmission trans = recipient == null ? null : new Transmission( getSelfClient(), recipient, Message.asMessage( msg ), msgTag, null ); if ( trans != null ) { scheduleAddTransmission( trans, true ); } // Do the actual call. try { return super.call( recipientId, msgTag, msg ); } // If the send failed, signal that since no reply will be // forthcoming. catch ( final SampException e ) { scheduleSetFailure( trans, e ); throw e; } } public Map callAll( final String msgTag, Map msg ) throws SampException { // This is a bit more complicated than the other cases. // We can't construct the list of transmissions before the send, // since we don't know which are the recipient clients. // But if we wait until after the delegated callAll() method // we may miss some early responses to it. So we have to // put in place a mechanism for dealing with responses before // we know exactly what they are responses to. // Prepare and store a CallAllHandler for this. final CallAllHandler cah = new CallAllHandler( msgTag ); SwingUtilities.invokeLater( new Runnable() { public void run() { callAllMap_.put( msgTag, cah ); } } ); // Do the actual call. Map callMap = super.callAll( msgTag, msg ); // Prepare a post-facto list of the transmissions which were sent. List transList = new ArrayList(); Message message = Message.asMessage( msg ); for ( Iterator it = callMap.entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); String recipientId = (String) entry.getKey(); Client sender = getSelfClient(); Client recipient = (Client) getClientMap().get( recipientId ); if ( recipient != null ) { String msgId = (String) entry.getValue(); Transmission trans = new Transmission( sender, recipient, message, msgTag, msgId ); scheduleAddTransmission( trans, true ); transList.add( trans ); } } final Transmission[] transmissions = (Transmission[]) transList.toArray( new Transmission[ 0 ] ); // And inform the CallAllHandler what the transmissions were, so // it knows how to process (possibly already received) responses. SwingUtilities.invokeLater( new Runnable() { public void run() { cah.setTransmissions( transmissions ); } } ); return callMap; } public Response callAndWait( String recipientId, Map msg, int timeout ) throws SampException { // Construct a transmission obejct corresponding to this call // and add it to the send list. Client recipient = (Client) getClientMap().get( recipientId ); Transmission trans = recipient == null ? null : new Transmission( getSelfClient(), recipient, Message.asMessage( msg ), "", "" ); if ( trans != null ) { scheduleAddTransmission( trans, true ); } // Do the actual call. try { Response response = super.callAndWait( recipientId, msg, timeout ); // Inform the transmission of the response. if ( trans != null ) { scheduleSetResponse( trans, response ); } return response; } // In case of error, inform the transmission of failure. catch ( SampException e ) { if ( trans != null ) { scheduleSetFailure( trans, e ); } throw e; } } public void reply( final String msgId, final Map response ) throws SampException { // Do the actual reply. Throwable err; try { super.reply( msgId, response ); err = null; } catch ( Throwable e ) { err = e; } final Throwable error = err; // Inform the existing transmission on the receive list // that the reply has been made. SwingUtilities.invokeLater( new Runnable() { public void run() { int nt = rxListModel_.getSize(); for ( int i = 0; i < nt; i++ ) { Transmission trans = (Transmission) rxListModel_.getElementAt( i ); if ( msgId.equals( trans.getMessageId() ) ) { trans.setResponse( Response .asResponse( response ) ); if ( error != null ) { trans.setError( error ); } return; } } logger_.warning( "Orphan reply " + msgId + " - replier programming error?" ); } } ); } public void setCallable( CallableClient callable ) throws SampException { // Install a wrapper-like callable client which can intercept // the calls to keep track of send/received messages. CallableClient mtCallable = new MessageTrackerCallableClient( callable, this ); super.setCallable( mtCallable ); } } /** * CallableClient wrapper class which intercepts calls to keep track * of sent and received messages. */ private class MessageTrackerCallableClient implements CallableClient { private final CallableClient base_; private final MessageTrackerHubConnection connection_; /** * Constructor. * * @param base base callable * @param connection hub connection */ MessageTrackerCallableClient( CallableClient base, MessageTrackerHubConnection connection ) { base_ = base; connection_ = connection; } /** * Returns a Client object for use in Transmission objects * which represents this connection's owner. * * @return self client object */ private Client getSelfClient() { return connection_.getSelfClient(); } public void receiveCall( String senderId, String msgId, Message msg ) throws Exception { // Construct a transmission corresponding to the incoming call // and add it to the receive list. Client sender = (Client) getClientMap().get( senderId ); Transmission trans = sender == null ? null : new Transmission( sender, getSelfClient(), msg, null, msgId ); if ( trans != null ) { scheduleAddTransmission( trans, false ); } // Actually handle the call. try { base_.receiveCall( senderId, msgId, msg ); } // If the call handler fails, inform the transmission. catch ( Exception e ) { scheduleSetFailure( trans, e ); throw e; } } public void receiveNotification( String senderId, Message msg ) throws Exception { Client sender = (Client) getClientMap().get( senderId ); // Actually handle the notification. base_.receiveNotification( senderId, msg ); // Construct a transmission corresponding to the incoming // notification and add it to the receive list. // Give it a null response immediately, since being a notify // it won't get another one. if ( sender != null ) { final Transmission trans = new Transmission( sender, getSelfClient(), msg, null, null ); scheduleAddTransmission( trans, false ); scheduleSetResponse( trans, null ); } } public void receiveResponse( final String responderId, final String msgTag, final Response response ) throws Exception { // Actually handle the response. base_.receiveResponse( responderId, msgTag, response ); // Update state of the send list. // This isn't foolproof - if a sender has re-used the same msgTag // for a call and a callAll this handling might get confused - // but then so would the sender. SwingUtilities.invokeLater( new Runnable() { public void run() { // If the message was sent using callAll, handle using // the registered CallAllHandler. CallAllHandler cah = (CallAllHandler) callAllMap_.get( msgTag ); if ( cah != null ) { cah.addResponse( responderId, response ); } // Otherwise find the relevant Transmission in the // send list and inform it of the response. else { int nt = txListModel_.getSize(); for ( int i = 0; i < nt; i++ ) { Transmission trans = (Transmission) txListModel_.getElementAt( i ); if ( responderId.equals( trans.getReceiver() .getId() ) && msgTag.equals( trans.getMessageTag() ) ) { trans.setResponse( response ); return; } } logger_.warning( "Orphan reply " + msgTag + " - possible hub error?" ); } } } ); } } /** * Class used to keep track of outgoing callAll() messages. * It needs to be able to match Responses with Transmissions, * but the complication is that a Response may arrive either before * or after its corresponding Transmission is known. */ private class CallAllHandler { private final String msgTag_; private final Map responseMap_; private Collection transSet_; /** * Constructor. * * @param msgTag message tag labelling the callAll send */ CallAllHandler( String msgTag ) { msgTag_ = msgTag; responseMap_ = new HashMap(); } /** * Called once when the list of transmissions corresponding to the * callAll invocation is known. * * @param transmissions list of transmission objects, one for each * callAll recipient */ public void setTransmissions( Transmission[] transmissions ) { // Store transmissions for later. if ( transSet_ != null ) { throw new IllegalStateException(); } transSet_ = new HashSet( Arrays.asList( transmissions ) ); // Process any responses already in. for ( Iterator it = responseMap_.entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); String responderId = (String) entry.getKey(); Response response = (Response) entry.getValue(); processResponse( responderId, response ); } retireIfDone(); } /** * Supplies a response to the callAll invocation handled by this object. * * @param responderId client ID of responder * @param response response */ public void addResponse( String responderId, Response response ) { // If we know what transmissions have been sent, we can process // this response directly. if ( transSet_ != null ) { processResponse( responderId, response ); retireIfDone(); } // Otherwise store the response and defer processing until we do. else { responseMap_.put( responderId, response ); } } /** * Does the work of passing a received response to the relevant * member of the transmission list. * May only be called following {@link #setTransmissions}. * * @param responderId client ID of responder * @param response response */ private void processResponse( String responderId, Response response ) { assert transSet_ != null; for ( Iterator it = transSet_.iterator(); it.hasNext(); ) { Transmission trans = (Transmission) it.next(); if ( trans.getReceiver().getId().equals( responderId ) ) { trans.setResponse( response ); it.remove(); return; } } logger_.warning( "Orphan reply " + msgTag_ + " - possible hub error?" ); } /** * Checks whether this object has any further work to do * (any more responses are expected) and if not uninstalls itself, * at which point it becomes unreachable and can be garbage collected. * May only be called following {@link #setTransmissions}. */ private void retireIfDone() { assert transSet_ != null; if ( transSet_.isEmpty() ) { assert callAllMap_.containsKey( msgTag_ ); callAllMap_.remove( msgTag_ ); } } } /** * ListDataListener implementation which responds to transmission list * events and passes them on to the client list, since any changes to * the transmission list may change the way that a client is renderered * in the JList. */ private class ClientTransmissionListListener implements ListDataListener { public void contentsChanged( ListDataEvent evt ) { transmissionChanged( evt ); } public void intervalAdded( ListDataEvent evt ) { transmissionChanged( evt ); } public void intervalRemoved( ListDataEvent evt ) { transmissionChanged( evt ); } /** * Called when there is any change to a known transmission. */ private void transmissionChanged( ListDataEvent evt ) { Object src = evt.getSource(); assert src instanceof Transmission; if ( src instanceof Transmission ) { Transmission trans = (Transmission) src; TrackedClientSet clientSet = getClientSet(); clientSet.updateClient( trans.getReceiver(), false, false ); clientSet.updateClient( trans.getSender(), false, false ); } } } } jsamp/src/java/org/astrogrid/samp/gui/IndividualCallActionManager.java0000664000175000017500000001167212730747754025720 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.Component; import java.awt.event.ActionEvent; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.Map; import javax.swing.AbstractAction; import javax.swing.Action; import javax.swing.ListModel; import org.astrogrid.samp.Client; import org.astrogrid.samp.Message; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.HubConnector; import org.astrogrid.samp.client.ResultHandler; /** * SendActionManager which uses the Asynchronous Call/Response delivery * pattern, but allows a "broadcast" to send different message objects * to different recipients. * Multiple targetted sends rather than an actual SAMP broadcast may be * used to achieve this. * Concrete subclasses need only implement the * {@link #createMessage(org.astrogrid.samp.Client)} method. * They may also wish to to customise the returned Send and Broadcast Action * objects (for instance give them useful names and descriptions). * * @author Mark Taylor * @since 3 Dec 2008 */ public abstract class IndividualCallActionManager extends AbstractCallActionManager { private final Component parent_; /** * Constructor. * * @param parent parent component * @param connector hub connector * @param clientListModel list model containing only those clients * which are suitable recipients; * all elements must be {@link Client}s */ public IndividualCallActionManager( Component parent, GuiHubConnector connector, ListModel clientListModel ) { super( parent, connector, clientListModel ); parent_ = parent; } protected abstract Map createMessage( Client client ) throws Exception; public Action createBroadcastAction() { return new BroadcastAction(); } /** * Action which performs "broadcasts". They may actually be multiple * targetted sends. */ private class BroadcastAction extends AbstractAction { final HubConnector connector_ = getConnector(); final ListModel clientList_ = getClientListModel(); /** * Constructor. */ BroadcastAction() { putValue( SMALL_ICON, getBroadcastIcon() ); } public void actionPerformed( ActionEvent evt ) { // Identify groups of recipients which can receive the same // Message object as each other. int nc = clientList_.getSize(); Map msgMap = new HashMap(); try { for ( int ic = 0; ic < nc; ic++ ) { Client client = (Client) clientList_.getElementAt( ic ); Map message = createMessage( client ); if ( message != null ) { Message msg = Message.asMessage( message ); msg.check(); if ( ! msgMap.containsKey( msg ) ) { msgMap.put( msg, new HashSet() ); } Collection clientSet = (Collection) msgMap.get( msg ); clientSet.add( client ); } } } catch ( Exception e ) { ErrorDialog.showError( parent_, "Send Error", "Error constructing message " + e.getMessage(), e ); return; } // Send the message to each group at a time. try { HubConnection connection = connector_.getConnection(); if ( connection != null ) { for ( Iterator it = msgMap.entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); Message msg = (Message) entry.getKey(); Client[] recipients = (Client[]) ((Collection) entry.getValue()) .toArray( new Client[ 0 ] ); String tag = createTag(); ResultHandler handler = createResultHandler( connection, msg, recipients ); registerHandler( tag, recipients, handler ); for ( int ir = 0; ir < recipients.length; ir++ ) { connection.call( recipients[ ir ].getId(), tag, msg ); } } } } catch ( Exception e ) { ErrorDialog.showError( parent_, "Send Error", "Error sending message " + e.getMessage(), e ); } } } } jsamp/src/java/org/astrogrid/samp/gui/MessageTrackerListCellRenderer.java0000664000175000017500000002564212730747754026430 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.Component; import java.awt.Dimension; import java.awt.Graphics; import java.awt.Insets; import java.awt.Point; import java.awt.event.MouseEvent; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import javax.swing.BorderFactory; import javax.swing.Icon; import javax.swing.JComponent; import javax.swing.JList; import javax.swing.ListModel; import javax.swing.event.ListDataEvent; import javax.swing.event.ListDataListener; import org.astrogrid.samp.Client; /** * ListCellRenderer which draws a representation of incoming and outgoing * messages alongside the default representation. * * @author Mark Taylor * @since 26 Nov 2008 */ class MessageTrackerListCellRenderer extends ClientListCellRenderer { private final ClientTransmissionHolder transHolder_; private final int msgGap_; private final IconBox iconBox_; private final IconListModel iconListModel_; private final Object separator_; /** * Constructor. * * @param transHolder obtains list models containing sent/received * messages */ public MessageTrackerListCellRenderer( ClientTransmissionHolder transHolder ) { transHolder_ = transHolder; iconListModel_ = new IconListModel(); msgGap_ = 10; separator_ = new Object(); iconBox_ = new IconBox( 16 ); iconBox_.setOpaque( false ); iconBox_.setBorder( BorderFactory.createEmptyBorder( 1, 1, 1, 1 ) ); iconBox_.setModel( iconListModel_ ); iconBox_.setRenderer( new TransmissionCellRenderer() { public String getToolTipText( IconBox iconBox, Object value, int index ) { if ( value instanceof Transmission ) { Transmission trans = (Transmission) value; String mtype = trans.getMessage().getMType(); Client client = iconListModel_.client_; if ( client == trans.getSender() ) { return mtype + " -> " + trans.getReceiver(); } else if ( client == trans.getReceiver() ) { return mtype + " <- " + trans.getSender(); } else { assert false; return null; } } else { return null; } } } ); } /** * Sets the cell renderer for transmission objects. * * @param transRend renderer */ public void setTransmissionCellRenderer( IconBox.CellRenderer transRend ) { iconBox_.setRenderer( transRend ); } /** * Returns the cell renderer for transmission objects. * * @return renderer */ public IconBox.CellRenderer getTransmissionCellRenderer() { return iconBox_.getRenderer(); } protected void paintComponent( Graphics g ) { super.paintComponent( g ); if ( iconListModel_.client_ != null ) { Point p = getIconBoxPosition(); Dimension boxSize = iconBox_.getPreferredSize(); if ( g.hitClip( p.x, p.y, boxSize.width, boxSize.height ) ) { g.translate( p.x, p.y ); iconBox_.paintComponent( g ); g.translate( -p.x, -p.y ); } } } public Dimension getPreferredSize() { Dimension prefSize = super.getPreferredSize(); if ( iconListModel_.client_ != null ) { Dimension boxSize = iconBox_.getPreferredSize(); prefSize.width += iconBox_.getPreferredSize().width + msgGap_; } return prefSize; } public String getToolTipText( MouseEvent evt ) { Point boxPos = getIconBoxPosition(); evt.translatePoint( -boxPos.x, -boxPos.y ); return iconBox_.getToolTipText( evt ); } public Component getListCellRendererComponent( JList list, Object value, int index, boolean isSel, boolean hasFocus ) { Component c = super.getListCellRendererComponent( list, value, index, isSel, hasFocus ); if ( value instanceof Client ) { iconListModel_.setClient( (Client) value ); int size = c.getPreferredSize().height; if ( c instanceof JComponent ) { Insets cInsets = ((JComponent) c).getInsets(); size -= cInsets.top + cInsets.bottom; Insets bInsets = iconBox_.getInsets(); size -= bInsets.top + bInsets.bottom; } iconBox_.setTransverseSize( size ); } else { iconListModel_.setClient( null ); } return c; } /** * Returns the position at which the transmission list icon should * be drawn. * * @return icon base position */ private Point getIconBoxPosition() { Insets insets = getInsets(); return new Point( insets.left + super.getPreferredSize().width + msgGap_, insets.top ); } /** * ListModel which can be used in the icon box. * It contains entries for each received and sent message, as well as * one which notionally represents the application (visual sugar). * It is basically a combination of the rx and tx models. */ private class IconListModel implements ListModel { Client client_; ListModel rxModel_; ListModel txModel_; private final ListDataListener rxListener_; private final ListDataListener txListener_; private final List listenerList_; /** * Constructor. */ IconListModel() { listenerList_ = new ArrayList(); rxListener_ = new ListDataForwarder() { public int getOffset() { return 0; } }; txListener_ = new ListDataForwarder() { public int getOffset() { return ( rxModel_ == null ? 0 : rxModel_.getSize() ) + 1; } }; } /** * Sets the client whose transmissions this list will represent. * May be null. * * @param client client */ public void setClient( Client client ) { if ( rxModel_ != null ) { rxModel_.removeListDataListener( rxListener_ ); } if ( txModel_ != null ) { txModel_.removeListDataListener( txListener_ ); } client_ = client; rxModel_ = transHolder_.getRxListModel( client ); txModel_ = transHolder_.getTxListModel( client ); if ( rxModel_ != null ) { rxModel_.addListDataListener( rxListener_ ); } if ( txModel_ != null ) { txModel_.addListDataListener( txListener_ ); } fireEvent( new ListDataEvent( this, ListDataEvent.CONTENTS_CHANGED, -1, -1 ) ); } public int getSize() { return ( rxModel_ == null ? 0 : rxModel_.getSize() ) + 1 + ( txModel_ == null ? 0 : txModel_.getSize() ); } public Object getElementAt( int index ) { int rxSize = rxModel_ == null ? 0 : rxModel_.getSize(); if ( index < rxSize ) { return rxModel_.getElementAt( index ); } index -= rxSize; if ( index < 1 ) { return separator_; } index -= 1; int txSize = txModel_ == null ? 0 : txModel_.getSize(); if ( index < txSize ) { return txModel_.getElementAt( index ); } index -= txSize; throw new IllegalArgumentException(); } public void addListDataListener( ListDataListener listener ) { listenerList_.add( listener ); } public void removeListDataListener( ListDataListener listener ) { listenerList_.remove( listener ); } /** * Passes an event on to registered ListDataListeners. */ private void fireEvent( ListDataEvent evt ) { for ( Iterator it = listenerList_.iterator(); it.hasNext(); ) { ListDataListener listener = (ListDataListener) it.next(); switch ( evt.getType() ) { case ListDataEvent.INTERVAL_ADDED: listener.intervalAdded( evt ); break; case ListDataEvent.INTERVAL_REMOVED: listener.intervalRemoved( evt ); break; case ListDataEvent.CONTENTS_CHANGED: listener.contentsChanged( evt ); break; default: assert false; } } } /** * Listener implementation which can listen to constituent (rx and tx) * models and forward events from them to listeners to this model. */ private abstract class ListDataForwarder implements ListDataListener { /** * Returns the offset into the IconBoxModel at which the * model this listener is listening to starts. * * @return model element offset */ abstract int getOffset(); public void intervalAdded( ListDataEvent evt ) { forwardEvent( evt ); } public void intervalRemoved( ListDataEvent evt ) { forwardEvent( evt ); } public void contentsChanged( ListDataEvent evt ) { forwardEvent( evt ); } /** * Takes an event received by this listener, adjusts its * indexes appropriately, and forwards it to listeners to this * model. * * @param evt event to forward */ private void forwardEvent( ListDataEvent evt ) { Object src = evt.getSource(); int i0 = evt.getIndex0(); int i1 = evt.getIndex1(); if ( 0 <= i0 && i0 <= i1 ) { int offset = getOffset(); fireEvent( new ListDataEvent( evt.getSource(), evt.getType(), evt.getIndex0() + offset, evt.getIndex1() + offset ) ); } else { fireEvent( evt ); } } } } } jsamp/src/java/org/astrogrid/samp/gui/NotifyActionManager.java0000664000175000017500000001572512730747754024307 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.Component; import java.awt.event.ActionEvent; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.logging.Logger; import javax.swing.AbstractAction; import javax.swing.Action; import javax.swing.JMenu; import org.astrogrid.samp.Client; import org.astrogrid.samp.Message; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.HubConnector; /** * SendActionManager subclass which works with messages of a single MType, * using the Notify delivery pattern. * * @author Mark Taylor * @since 5 Sep 2008 */ public abstract class NotifyActionManager extends SendActionManager { private final Component parent_; private final GuiHubConnector connector_; private final String sendType_; private static final Logger logger_ = Logger.getLogger( NotifyActionManager.class.getName() ); /** * Constructor. * * @param parent parent component * @param connector hub connector * @param mtype MType for messages transmitted by this object's actions * @param sendType short string identifying the kind of thing being * sent (used for action descriptions etc) */ public NotifyActionManager( Component parent, GuiHubConnector connector, String mtype, String sendType ) { super( connector, new SubscribedClientListModel( connector, mtype ) ); parent_ = parent; connector_ = connector; sendType_ = sendType; updateState(); } /** * Generates the message which is sent to one or all clients * by this object's actions. * * @return {@link org.astrogrid.samp.Message}-like Map representing * message to transmit */ protected abstract Map createMessage() throws Exception; /** * Called when a message has been sent by this object. * The default action is to notify via the logging system. * Subclasses may override this method. * * @param connection connection object * @param msg the message which was sent * @param recipients the recipients to whom an attempt was made to send * the message */ protected void messageSent( HubConnection connection, Message msg, Client[] recipients ) { for ( int i = 0; i < recipients.length; i++ ) { logger_.info( "Message " + msg.getMType() + " sent to " + recipients[ i ] ); } } protected Action createBroadcastAction() { Action act = new AbstractAction() { public void actionPerformed( ActionEvent evt ) { List recipientIdList = null; Message msg = null; HubConnection connection = null; try { msg = Message.asMessage( createMessage() ); msg.check(); connection = connector_.getConnection(); if ( connection != null ) { recipientIdList = connection.notifyAll( msg ); } } catch ( Exception e ) { ErrorDialog.showError( parent_, "Send Error", "Send failure " + e.getMessage(), e ); } if ( recipientIdList != null ) { assert connection != null; assert msg != null; List recipientList = new ArrayList(); Map clientMap = connector_.getClientMap(); for ( Iterator it = recipientIdList.iterator(); it.hasNext(); ) { String id = (String) it.next(); Client recipient = (Client) clientMap.get( id ); if ( recipient != null ) { recipientList.add( recipient ); } } messageSent( connection, msg, (Client[]) recipientList.toArray( new Client[ 0 ] ) ); } } }; act.putValue( Action.NAME, "Broadcast " + sendType_ ); act.putValue( Action.SHORT_DESCRIPTION, "Transmit " + sendType_ + " to all applications" + " listening using the SAMP protocol" ); act.putValue( Action.SMALL_ICON, getBroadcastIcon() ); return act; } /** * Returns a new menu for targetted sends with a title suitable for * this object. * * @return new send menu */ public JMenu createSendMenu() { JMenu menu = super.createSendMenu( "Send " + sendType_ + " to..." ); menu.setIcon( getSendIcon() ); return menu; } protected Action getSendAction( Client client ) { return new SendAction( client ); } /** * Action which performs a send. */ private class SendAction extends AbstractAction { private final Client client_; private final String cName_; /** * Constructor. * * @param client target client */ SendAction( Client client ) { client_ = client; cName_ = client.toString(); putValue( NAME, cName_ ); putValue( SHORT_DESCRIPTION, "Transmit " + sendType_ + " to " + cName_ + " using SAMP protocol" ); } public void actionPerformed( ActionEvent evt ) { boolean sent = false; Message msg = null; HubConnection connection = null; try { msg = Message.asMessage( createMessage() ); msg.check(); connection = connector_.getConnection(); if ( connection != null ) { connection.notify( client_.getId(), msg ); sent = true; } } catch ( Exception e ) { ErrorDialog.showError( parent_, "Send Error", "Send failure " + e.getMessage(), e ); } if ( sent ) { assert connection != null; assert msg != null; messageSent( connection, msg, new Client[] { client_ } ); } } public boolean equals( Object o ) { if ( o instanceof SendAction ) { SendAction other = (SendAction) o; return this.client_.equals( other.client_ ) && this.cName_.equals( other.cName_ ); } else { return false; } } public int hashCode() { return client_.hashCode() * 23 + cName_.hashCode(); } } } jsamp/src/java/org/astrogrid/samp/gui/AbstractCallActionManager.java0000664000175000017500000003531712730747754025375 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.Component; import java.awt.event.ActionEvent; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.Map; import java.util.logging.Logger; import javax.swing.AbstractAction; import javax.swing.Action; import javax.swing.ListModel; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; import org.astrogrid.samp.Client; import org.astrogrid.samp.Message; import org.astrogrid.samp.Response; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.LogResultHandler; import org.astrogrid.samp.client.ResponseHandler; import org.astrogrid.samp.client.ResultHandler; /** * Partial SendActionManager implementation which * uses the Asynchronous Call/Response delivery pattern. * It supplies most of the machinery required for tracking what happened * to responses to messages sent at the same time, but does not * implement the actual {@link #createBroadcastAction} method. * Subclasses are provided which do this. * * @author Mark Taylor * @since 11 Nov 2008 */ public abstract class AbstractCallActionManager extends SendActionManager { private final Component parent_; private final GuiHubConnector connector_; private final CallResponseHandler responder_; private static final Logger logger_ = Logger.getLogger( AbstractCallActionManager.class.getName() ); /** * Constructor. * * @param parent parent component * @param connector hub connector * @param clientListModel list model containing only those clients * which are suitable recipients; * all elements must be {@link Client}s */ public AbstractCallActionManager( Component parent, GuiHubConnector connector, ListModel clientListModel ) { super( connector, clientListModel ); parent_ = parent; connector_ = connector; responder_ = new CallResponseHandler(); connector_.addResponseHandler( responder_ ); connector_.addConnectionListener( responder_ ); updateState(); } /** * Must be implemented by concrete subclasses. */ abstract protected Action createBroadcastAction(); /** * Returns an object which will be informed of the results of a single- * or multiple-recipient send as they arrive. * This method will be called from the event dispatch thread. * *

      The default implementation returns an instance of * {@link org.astrogrid.samp.client.LogResultHandler}. * * @param connection connection object * @param msg the message which was sent * @param recipients the recipients to whom the message was sent * @return result handler object */ protected ResultHandler createResultHandler( HubConnection connection, Message msg, Client[] recipients ) { return new LogResultHandler( msg ); } /** * Releases resources associated with this object. * Specifically, it removes listeners from the hub connector. * Following a call to this method, this object should not be used again. */ public void dispose() { connector_.removeResponseHandler( responder_ ); connector_.removeConnectionListener( responder_ ); } /** * Returns the Message object which is to be transmitted by this manager * to a given client. This is called by the action returned by * {@link #getSendAction}. * * @param client target * @return message */ protected abstract Map createMessage( Client client ) throws Exception; protected Action getSendAction( Client client ) { return new SendAction( client ); } /** * Creates and returns a new tag which will be attached to * an outgoing message, and updates internal structures so that * it will be recognised in the future. * A subsequent call to {@link #registerHandler} should be made for the * returned tag. * * @return new tag */ public String createTag() { return responder_.createTag(); } /** * Registers a result handler to handle results corresponding to a * message tag. * * @param tag tag returned by an earlier invocation of * {@link #createTag} * @param recipients clients from which responses are expected * @param handler result handler for responses; may be null * if no handling is required */ public void registerHandler( String tag, Client[] recipients, ResultHandler handler ) { responder_.registerHandler( tag, recipients, handler ); } /** * Action which performs a send to a particular client. */ private class SendAction extends AbstractAction { private final Client client_; private final String cName_; /** * Constructor. * * @param client target client */ SendAction( Client client ) { client_ = client; cName_ = client.toString(); putValue( NAME, cName_ ); putValue( SHORT_DESCRIPTION, "Transmit to " + cName_ + " using SAMP protocol" ); } public void actionPerformed( ActionEvent evt ) { boolean sent = false; Message msg = null; HubConnection connection = null; String tag = null; // Attempt to send the messsage. try { msg = Message.asMessage( createMessage( client_ ) ); msg.check(); connection = connector_.getConnection(); if ( connection != null ) { tag = responder_.createTag(); connection.call( client_.getId(), tag, msg ); sent = true; } } catch ( Exception e ) { ErrorDialog.showError( parent_, "Send Error", "Send failure " + e.getMessage(), e ); } // If it was sent, arrange for the result to be processed by // a suitable result handler. if ( sent ) { assert connection != null; assert msg != null; assert tag != null; Client[] recipients = new Client[] { client_ }; ResultHandler handler = createResultHandler( connection, msg, recipients ); responder_.registerHandler( tag, recipients, handler ); } } public boolean equals( Object o ) { if ( o instanceof SendAction ) { SendAction other = (SendAction) o; return this.client_.equals( other.client_ ) && this.cName_.equals( other.cName_ ); } else { return false; } } public int hashCode() { return client_.hashCode() * 23 + cName_.hashCode(); } } /** * ResponseHandler implementation for use by this class. * It handles all SAMP responses for calls which have been made by * this object and passes them on to the appropriate ResultHandlers. */ private class CallResponseHandler implements ResponseHandler, ChangeListener { private int iCall_; private final Map tagMap_; /** * Constructor. */ CallResponseHandler() { tagMap_ = Collections.synchronizedMap( new HashMap() ); } /** * Creates and returns a new tag which will be attached to * an outgoing message, and updates internal structures so that * it will be recognised in the future. * A subsequent call to {@link #registerHandler} should be made for the * returned tag. * * @return new tag */ public synchronized String createTag() { String tag = connector_.createTag( this ); tagMap_.put( tag, null ); return tag; } /** * Registers a result handler to handle results corresponding to a * message tag. * * @param tag tag returned by an earlier invocation of * {@link #createTag} * @param recipients clients from which responses are expected * @param handler result handler for responses; may be null * if no handling is required */ public void registerHandler( String tag, Client[] recipients, ResultHandler handler ) { synchronized ( tagMap_ ) { if ( handler != null ) { tagMap_.put( tag, new TagInfo( recipients, handler ) ); } else { tagMap_.remove( tag ); } tagMap_.notifyAll(); } } public boolean ownsTag( String tag ) { return tagMap_.containsKey( tag ); } public void receiveResponse( HubConnection connection, final String responderId, final String tag, final Response response ) { synchronized ( tagMap_ ) { if ( tagMap_.containsKey( tag ) ) { // If the result handler is already registered, pass the // result on to it. TagInfo info = (TagInfo) tagMap_.get( tag ); if ( info != null ) { processResponse( tag, info, responderId, response ); } // If the response was received very quickly, it's possible // that the handler has not been registered yet. // In this case, wait until it is. // Do this in a separate thread so that the // receiveResponse can return quickly (not essential, but // good behaviour). else { new Thread( "TagWaiter-" + tag ) { public void run() { TagInfo tinfo; try { synchronized ( tagMap_ ) { do { tinfo = (TagInfo) tagMap_.get( tag ); if ( tinfo == null ) { tagMap_.wait(); } } while ( tinfo == null ); } processResponse( tag, tinfo, responderId, response ); } catch ( InterruptedException e ) { logger_.warning( "Interrupted??" ); } } }.start(); } } // Shouldn't happen - HubConnector should not have invoked // in this case. else { logger_.warning( "Receive response for unknown tag " + tag + "??" ); return; } } } /** * Does the work of passing on a received response to a registered * result handler. * * @param tag message tag * @param info tag handling information object * @param responderId client ID of responder * @param response response object */ private void processResponse( String tag, TagInfo info, String responderId, Response response ) { ResultHandler handler = info.handler_; Map recipientMap = info.recipientMap_; synchronized ( info ) { Client responder = (Client) recipientMap.remove( responderId ); // Pass response on to handler. if ( responder != null ) { handler.result( responder, response ); } // If there are no more to come, notify the handler of this. if ( recipientMap.isEmpty() ) { handler.done(); } } // Unregister the handler if no more responses are expected for it. synchronized ( tagMap_ ) { if ( recipientMap.isEmpty() ) { tagMap_.remove( tag ); } } } public void stateChanged( ChangeEvent evt ) { if ( ! connector_.isConnected() ) { hubDisconnected(); } } /** * Called when the connection to the hub disappears. */ private void hubDisconnected() { synchronized ( tagMap_ ) { // Notify all result handlers that they will receive no more // responses, then unregister them all. for ( Iterator it = tagMap_.entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); String tag = (String) entry.getKey(); TagInfo info = (TagInfo) entry.getValue(); if ( info != null ) { info.handler_.done(); } it.remove(); } } } } /** * Aggregates information required for handling responses which * correspond to a particular message tag. */ private static class TagInfo { final Map recipientMap_; final ResultHandler handler_; /** * Constructor. * * @param recipients recipients of message * @param handler handler for responses */ public TagInfo( Client[] recipients, ResultHandler handler ) { recipientMap_ = Collections.synchronizedMap( new HashMap() ); for ( int i = 0; i < recipients.length; i++ ) { Client recipient = recipients[ i ]; recipientMap_.put( recipient.getId(), recipient ); } handler_ = handler; } } } jsamp/src/java/org/astrogrid/samp/gui/HubClientPopupListener.java0000664000175000017500000001517512730747754025014 0ustar sladensladenpackage org.astrogrid.samp.gui; import java.awt.Component; import java.awt.event.ActionEvent; import java.awt.event.MouseEvent; import java.awt.event.MouseListener; import javax.swing.AbstractAction; import javax.swing.JList; import javax.swing.JPopupMenu; import javax.swing.SwingUtilities; import org.astrogrid.samp.Message; import org.astrogrid.samp.RegInfo; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.hub.HubClient; import org.astrogrid.samp.hub.BasicHubService; /** * MouseListener which provides a popup menu with per-client options * for use with a JList containing HubClient objects. * * @author Mark Taylor * @since 8 Jul 2009 */ class HubClientPopupListener implements MouseListener { private final BasicHubService hub_; /** Message which does a ping. */ private static final Message PING_MSG = new Message( "samp.app.ping" ); /** * Constructor. * * @param hub hub service which knows about the HubClients contained * in the JList this will be listening to */ public HubClientPopupListener( BasicHubService hub ) { hub_ = hub; } public void mouseClicked( MouseEvent evt ) { } public void mouseEntered( MouseEvent evt ) { } public void mouseExited( MouseEvent evt ) { } public void mousePressed( MouseEvent evt ) { maybeShowPopup( evt ); } public void mouseReleased( MouseEvent evt ) { maybeShowPopup( evt ); } /** * Invoked for a MouseEvent which may be a popup menu trigger. * * @param evt popup trigger event candidate */ private void maybeShowPopup( MouseEvent evt ) { if ( evt.isPopupTrigger() && evt.getSource() instanceof JList ) { final JList jlist = (JList) evt.getSource(); final int index = jlist.locationToIndex( evt.getPoint() ); if ( index >= 0 ) { Object item = jlist.getModel().getElementAt( index ); if ( item instanceof HubClient ) { HubClient client = (HubClient) item; // Set the selection to the client for which the menu // will be posted. This is not essential, but it can be // visually confusing for the user if it doesn't happen. SwingUtilities.invokeLater( new Runnable() { public void run() { jlist.setSelectedIndex( index ); } } ); Component comp = evt.getComponent(); JPopupMenu popper = createPopup( comp, client ); popper.show( comp, evt.getX(), evt.getY() ); } } } } /** * Returns a new popup menu for a given client. * The actions on this menu are not dynamic (e.g. do not enable/disable * themselves according to changes in the hub status) because the * menu is likely to be short-lived. * * @param parent parent component * @param client hub client which the menu will affect * @return new popup menu */ private JPopupMenu createPopup( Component parent, HubClient client ) { JPopupMenu popper = new JPopupMenu(); popper.add( new CallAction( parent, client, "Ping", PING_MSG, true ) ); popper.add( new DisconnectAction( parent, client ) ); return popper; } /** * Action which will forcibly disconnect a given client. */ private class DisconnectAction extends AbstractAction { private final Component parent_; private final HubClient client_; /** * Constructor. * * @param parent parent component * @param client client to disconnect */ public DisconnectAction( Component parent, HubClient client ) { super( "Disconnect" ); parent_ = parent; client_ = client; putValue( SHORT_DESCRIPTION, "Forcibly disconnect client " + client_ + " from hub" ); setEnabled( ! client.getId() .equals( hub_.getServiceConnection() .getRegInfo().getSelfId() ) ); } public void actionPerformed( ActionEvent evt ) { new SampThread( parent_, "Disconnect Error", "Error disconnecting client " + client_ ) { protected void sampRun() throws SampException { hub_.disconnect( client_.getId(), "GUI hub user requested ejection" ); } }.start(); } } /** * Action which will send a message to a client. */ private class CallAction extends AbstractAction { private final Component parent_; private final HubClient client_; private final String name_; private final Message msg_; private final boolean isCall_; /** * Constructor. * * @param parent parent component * @param client client to receive message * @param name informal name of message (for menu) * @param msg message to send * @param isCall true for call, false for notify */ public CallAction( Component parent, HubClient client, String name, Message msg, boolean isCall ) { super( name ); parent_ = parent; client_ = client; name_ = name; msg_ = msg; isCall_ = isCall; String mtype = msg.getMType(); putValue( SHORT_DESCRIPTION, "Send " + mtype + ( isCall ? " call" : " notification" ) + " to client " + client ); setEnabled( client_.isSubscribed( mtype ) ); } public void actionPerformed( ActionEvent evt ) { final HubConnection connection = hub_.getServiceConnection(); final String recipientId = client_.getId(); new SampThread( parent_, name_ + " Error", "Error attempting to send message " + msg_.getMType() + " to client " + client_ ) { protected void sampRun() throws SampException { if ( isCall_ ) { connection.call( recipientId, name_ + "-tag", msg_ ); } else { connection.notify( recipientId, msg_ ); } } }.start(); } } } jsamp/src/java/org/astrogrid/samp/gui/package.html0000664000175000017500000000012512730747754022010 0ustar sladensladen Classes required only for graphical components based on SAMP classes. jsamp/src/java/org/astrogrid/samp/Platform.java0000664000175000017500000002557612730747754021413 0ustar sladensladenpackage org.astrogrid.samp; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Arrays; import java.util.logging.Logger; /** * Platform-dependent features required by the SAMP implementation. * * @author Mark Taylor * @since 14 Jul 2008 */ public abstract class Platform { private static Platform instance_; private final String name_; private static final Logger logger_ = Logger.getLogger( Platform.class.getName() ); /** * Constructor. * * @param name platform name */ protected Platform( String name ) { name_ = name; } /** * Returns SAMP's definition of the "home" directory. * * @return directory containing SAMP lockfile */ public abstract File getHomeDirectory(); /** * Returns the value of an environment variable. * If it can't be done, null is returned. * * @param varname name of environment variable * @return value of environment variable */ public String getEnv( String varname ) { try { return System.getenv( varname ); } // System.getenv is unimplemented at 1.4, and throws an Error. catch ( Throwable e ) { String[] argv = getGetenvArgs( varname ); if ( argv == null ) { return null; } else { try { String cmdout = exec( argv ); return cmdout.trim(); } catch ( Throwable e2 ) { return null; } } } } /** * Sets file permissions on a given file so that it cannot be read by * anyone other than its owner. * * @param file file whose permissions are to be altered * @throws IOException if permissions cannot be changed */ public void setPrivateRead( File file ) throws IOException { if ( setPrivateReadReflect( file ) ) { return; } else { String[] privateReadArgs = getPrivateReadArgs( file ); if ( privateReadArgs != null ) { exec( privateReadArgs ); } else { logger_.info( "No known way to set user-only read permissions" + "; possible security implications" + " on multi-user systems" ); } } } /** * Returns an array of words to pass to * {@link java.lang.Runtime#exec(java.lang.String[])} in order * to read an environment variable name. * If null is returned, no way is known to do this with a system command. * * @param varname environment variable name to read * @return exec args */ protected abstract String[] getGetenvArgs( String varname ); /** * Returns an array of words to pass to * {@link java.lang.Runtime#exec(java.lang.String[])} in order * to set permissions on a given file so that it cannot be read by * anyone other than its owner. * If null is returned, no way is known to do this with a system command. * * @param file file to alter * @return exec args */ protected abstract String[] getPrivateReadArgs( File file ) throws IOException; /** * Attempt to use the File.setReadable() method to set * permissions on a file so that it cannot be read by anyone other * than its owner. * * @param file file to alter * @return true if the attempt succeeded, false if it failed because * we are running the wrong version of java * @throws IOException if there was some I/O failure */ private static boolean setPrivateReadReflect( File file ) throws IOException { try { Method setReadableMethod = File.class.getMethod( "setReadable", new Class[] { boolean.class, boolean.class, } ); boolean success = ( setReadableMethod.invoke( file, new Object[] { Boolean.FALSE, Boolean.FALSE } ) .equals( Boolean.TRUE ) ) && ( setReadableMethod.invoke( file, new Object[] { Boolean.TRUE, Boolean.TRUE } ) .equals( Boolean.TRUE ) ); return success; } catch ( InvocationTargetException e1 ) { Throwable e2 = e1.getCause(); if ( e2 instanceof IOException ) { throw (IOException) e2; } else if ( e2 instanceof RuntimeException ) { throw (RuntimeException) e2; } else { throw (IOException) new IOException( e2.getMessage() ) .initCause( e2 ); } } catch ( NoSuchMethodException e ) { // method only available at java 1.6+ return false; } catch ( IllegalAccessException e ) { // not likely. return false; } } /** * Attempts a {@java.lang.Runtime#exec(java.lang.String[])} with a given * list of arguments. The output from stdout is returned as a string; * in the case of error an IOException is thrown with a message giving * the output from stderr. * *

      Note: do not use this for cases in which the * output from stdout or stderr might be more than a few characters - * blocking or deadlock is possible (see {@link java.lang.Process}). * * @param args array of words to pass to exec * @return output from standard output * @throws IOException with text from standard error if there is an error */ private static String exec( String[] args ) throws IOException { String argv = Arrays.asList( args ).toString(); logger_.info( "System exec: " + argv ); final Process process; final StreamReader outReader; final StreamReader errReader; try { process = Runtime.getRuntime().exec( args ); outReader = new StreamReader( process.getInputStream() ); errReader = new StreamReader( process.getErrorStream() ); outReader.start(); errReader.start(); process.waitFor(); } catch ( InterruptedException e ) { throw new IOException( "Exec failed: " + argv ); } catch ( IOException e ) { throw (IOException) new IOException( "Exec failed: " + argv ).initCause( e ); } return process.exitValue() == 0 ? outReader.getContent() : errReader.getContent(); } /** * Returns a Platform instance for the current system. * * @return platform instance */ public static Platform getPlatform() { if ( instance_ == null ) { instance_ = createPlatform(); } return instance_; } /** * Constructs a Platform for the current system. * * @return new platform */ private static Platform createPlatform() { // Is this reliable? String osname = System.getProperty( "os.name" ); if ( osname.toLowerCase().startsWith( "windows" ) || osname.toLowerCase().indexOf( "microsoft" ) >= 0 ) { return new WindowsPlatform(); } else { return new UnixPlatform(); } } /** * Thread which reads the contents of a stream into a string buffer. */ private static class StreamReader extends Thread { private final InputStream in_; private final StringBuffer sbuf_; /** * Constructor. * * @param in input stream */ StreamReader( InputStream in ) { super( "StreamReader" ); in_ = in; sbuf_ = new StringBuffer(); setDaemon( true ); } public void run() { try { for ( int c; ( c = in_.read() ) >= 0; ) { sbuf_.append( (char) c ); } in_.close(); } catch ( IOException e ) { } } /** * Returns the content of the stream. * * @return content */ public String getContent() { return sbuf_.toString(); } } /** * Platform implementation for Un*x-like systems. */ private static class UnixPlatform extends Platform { /** * Constructor. */ UnixPlatform() { super( "Un*x" ); } public File getHomeDirectory() { return new File( System.getProperty( "user.home" ) ); } protected String[] getGetenvArgs( String varname ) { return new String[] { "printenv", varname, }; } protected String[] getPrivateReadArgs( File file ) { return new String[] { "chmod", "600", file.toString(), }; } } /** * Platform implementation for Microsoft Windows-like systems. */ private static class WindowsPlatform extends Platform { /** * Constructor. */ WindowsPlatform() { super( "MS Windows" ); } protected String[] getPrivateReadArgs( File file ) throws IOException { // No good way known. For a while I was using "attrib -R file", // but this wasn't doing what was wanted. Bruno Rino has // suggested "CALCS file /G %USERNAME%:F". Sounds kind of // sensible, but requires user input (doable, but fiddly), // and from my experiments on NTFS doesn't seem to have any // discernable effect. As I understand it, it's unlikely to do // anything on FAT (no ACLs). Given my general ignorance of // MS OSes and file systems, I'm inclined to leave this for // fear of inadvertently doing something bad. return null; } public File getHomeDirectory() { String userprofile = getEnv( "USERPROFILE" ); if ( userprofile != null && userprofile.trim().length() > 0 ) { return new File( userprofile ); } else { return new File( System.getProperty( "user.home" ) ); } } public String[] getGetenvArgs( String varname ) { return new String[] { "cmd", "/c", "echo", "%" + varname + "%", }; } } } jsamp/src/java/org/astrogrid/samp/Subscriptions.java0000664000175000017500000001331212730747754022457 0ustar sladensladenpackage org.astrogrid.samp; import java.util.HashMap; import java.util.Iterator; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * Represents the set of subscribed messages for a SAMP client. * This has the form of a Map in which each key is an MType (perhaps * wildcarded) and the corresponding values are maps with keys which are * so far undefined (thus typically empty). * * @author Mark Taylor * @since 14 Jul 2008 */ public class Subscriptions extends SampMap { private static final String ATOM_REGEX = "[0-9a-zA-Z\\-_]+"; private static String MTYPE_REGEX = "(" + ATOM_REGEX + "\\.)*" + ATOM_REGEX; private static String MSUB_REGEX = "(" + MTYPE_REGEX + "|" + MTYPE_REGEX + "\\.\\*" + "|" + "\\*" + ")"; private static final Pattern MSUB_PATTERN = Pattern.compile( MSUB_REGEX ); /** * Constructs an empty subscriptions object. */ public Subscriptions() { super( new String[ 0 ] ); } /** * Constructs a subscriptions object based on an existing map. * * @param map map containing initial data for this object */ public Subscriptions( Map map ) { this(); putAll( map ); } /** * Adds a subscription to a given MType. mtype may include * a wildcard according to the SAMP rules. * * @param mtype subscribed MType, possibly wildcarded */ public void addMType( String mtype ) { put( mtype, new HashMap() ); } /** * Determines whether a given (non-wildcarded) MType is subscribed to * by this object. * * @param mtype MType to test */ public boolean isSubscribed( String mtype ) { if ( containsKey( mtype ) ) { return true; } for ( Iterator it = keySet().iterator(); it.hasNext(); ) { if ( matchLevel( (String) it.next(), mtype ) >= 0 ) { return true; } } return false; } /** * Returns the map which forms the value for a given MType key. * If a wildcarded subscription is recorded which matches * mtype, the corresponding value is returned. * If mtype is not subscribed to, null * is returned. * * @param mtype MType to query * @return map value corresponding to mtype, or null */ public Map getSubscription( String mtype ) { if ( containsKey( mtype ) ) { Object value = get( mtype ); return value instanceof Map ? (Map) value : (Map) new HashMap(); } else { int bestLevel = -1; Map bestValue = null; for ( Iterator it = entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); int level = matchLevel( (String) entry.getKey(), mtype ); if ( level > bestLevel ) { bestLevel = level; Object value = entry.getValue(); bestValue = value instanceof Map ? (Map) value : (Map) new HashMap(); } } return bestValue; } } public void check() { super.check(); for ( Iterator it = entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); String key = (String) entry.getKey(); Object value = entry.getValue(); if ( ! MSUB_PATTERN.matcher( key ).matches() ) { throw new DataException( "Illegal subscription key " + "\"" + key + "\"" ); } if ( ! ( value instanceof Map ) ) { throw new DataException( "Subscription values " + "are not all maps" ); } } } /** * Returns a given map in the form of a Subscriptions object. * * @param map map * @return subscriptions */ public static Subscriptions asSubscriptions( Map map ) { return ( map instanceof Subscriptions || map == null ) ? (Subscriptions) map : new Subscriptions( map ); } /** * Performs wildcard matching of MTypes. The result is the number of * dot-separated "atoms" which match between the two. * * @param pattern MType pattern; may contain a wildcard * @param mtype unwildcarded MType for comparison with * pattern * @return the number of atoms of pattern which match * mtype; if pattern="*" the result is * 0, and if there is no match the result is -1 */ public static int matchLevel( String pattern, String mtype ) { if ( mtype.equals( pattern ) ) { return countAtoms( pattern ); } else if ( "*".equals( pattern ) ) { return 0; } else if ( pattern.endsWith( ".*" ) ) { String prefix = pattern.substring( 0, pattern.length() - 2 ); return mtype.startsWith( prefix ) ? countAtoms( prefix ) : -1; } else { return -1; } } /** * Counts the number of dot-separated "atoms" in a string. * * @param text string to test */ private static int countAtoms( String text ) { int leng = text.length(); int natom = 1; for ( int i = 0; i < leng; i++ ) { if ( text.charAt( i ) == '.' ) { natom++; } } return natom; } } jsamp/src/java/org/astrogrid/samp/JsonWriter.java0000664000175000017500000001311712730747754021721 0ustar sladensladenpackage org.astrogrid.samp; import java.util.Iterator; import java.util.List; import java.util.Map; /** * Outputs a SAMP object as JSON. * Can do it formatted and reasonably compact. * * @author Mark Taylor * @since 25 Jul 2011 */ class JsonWriter { private final int indent_; private final String spc_; /** * Constructor with default properties. */ public JsonWriter() { this( 2, true ); } /** * Custom constructor. * * @param indent number of characters indent per level * @param spacer whether to put spaces inside brackets */ public JsonWriter( int indent, boolean spacer ) { indent_ = indent; spc_ = spacer ? " " : ""; } /** * Converts a SAMP data item to JSON. * * @param item SAMP-friendly object * @return JSON representation */ public String toJson( Object item ) { StringBuffer sbuf = new StringBuffer(); toJson( sbuf, item, 0, false ); if ( indent_ >= 0 ) { assert sbuf.charAt( 0 ) == '\n'; return sbuf.substring( 1, sbuf.length() ); } else { return sbuf.toString(); } } /** * Recursive method which does the work for conversion. * If possible, call this method with isPositioned=false. * * @param sbuf string buffer to append result to * @param item object to convert * @param level current indentation level * @param isPositioned true if output should be direct to sbuf, * false if it needs a newline plus indentation first */ private void toJson( StringBuffer sbuf, Object item, int level, boolean isPositioned ) { if ( item instanceof String ) { if ( ! isPositioned ) { sbuf.append( getIndent( level ) ); } sbuf.append( '"' ) .append( (String) item ) .append( '"' ); } else if ( item instanceof List ) { List list = (List) item; if ( list.isEmpty() ) { if ( ! isPositioned ) { sbuf.append( getIndent( level ) ); } sbuf.append( "[]" ); } else { sbuf.append( getIntroIndent( level, '[', isPositioned ) ); boolean isPos = ! isPositioned; for ( Iterator it = list.iterator(); it.hasNext(); ) { toJson( sbuf, it.next(), level + 1, isPos ); if ( it.hasNext() ) { sbuf.append( "," ); } isPos = false; } sbuf.append( spc_ + "]" ); } } else if ( item instanceof Map ) { Map map = (Map) item; if ( map.isEmpty() ) { if ( ! isPositioned ) { sbuf.append( getIndent( level ) ); } sbuf.append( "{}" ); } else { sbuf.append( getIntroIndent( level, '{', isPositioned ) ); boolean isPos = ! isPositioned; for ( Iterator it = map.entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); Object key = entry.getKey(); if ( ! ( key instanceof String ) ) { throw new DataException( "Non-string key in map:" + key ); } toJson( sbuf, key, level + 1, isPos ); sbuf.append( ":" + spc_ ); toJson( sbuf, entry.getValue(), level + 1, true ); if ( it.hasNext() ) { sbuf.append( "," ); } isPos = false; } sbuf.append( spc_ + "}" ); } } else { throw new DataException( "Illegal data type " + item ); } } /** * Returns prepended whitespace containing an opener character. * * @param level indentation level * @param chr opener character * @param isPositioned true if output should be direct to sbuf, * false if it needs a newline plus indentation first * @return string to prepend */ private String getIntroIndent( int level, char chr, boolean isPositioned ) { if ( isPositioned ) { return new StringBuffer().append( chr ).toString(); } else { StringBuffer sbuf = new StringBuffer(); sbuf.append( getIndent( level ) ); sbuf.append( chr ); for ( int ic = 0; ic < indent_ - 1; ic++ ) { sbuf.append( ' ' ); } return sbuf.toString(); } } /** * Returns prepended whitespace. * * @param level indentation level * @return string to prepend */ private String getIndent( int level ) { if ( indent_ >= 0 ) { int nc = level * indent_; StringBuffer sbuf = new StringBuffer( nc + 1 ); sbuf.append( '\n' ); for ( int ic = 0; ic < nc; ic++ ) { sbuf.append( ' ' ); } return sbuf.toString(); } else { return ""; } } public static void main( String[] args ) { String txt = args[ 0 ]; Object item = new JsonReader().read( txt ); System.out.println( new JsonWriter().toJson( item ) ); } } jsamp/src/java/org/astrogrid/samp/hub/0000775000175000017500000000000012730747754017523 5ustar sladensladenjsamp/src/java/org/astrogrid/samp/hub/FacadeHubService.java0000664000175000017500000001450112730747754023512 0ustar sladensladenpackage org.astrogrid.samp.hub; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.logging.Logger; import org.astrogrid.samp.Message; import org.astrogrid.samp.RegInfo; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; /** * HubService that provides hub functionality by accessing an existing * hub service. The existing hub service is defined by a supplied * ClientProfile object. * * @author Mark Taylor * @since 1 Feb 2011 */ public class FacadeHubService implements HubService { private final ClientProfile profile_; private final Map connectionMap_; // FacadeHubConnection -> ProfileToken private static final Logger logger_ = Logger.getLogger( FacadeHubService.class.getName() ); /** * Constructor. * * @param profile defines the hub connection factory on which this * service is based */ public FacadeHubService( ClientProfile profile ) { profile_ = profile; connectionMap_ = Collections.synchronizedMap( new HashMap() ); } public boolean isHubRunning() { return profile_.isHubRunning(); } public HubConnection register( ProfileToken profileToken ) throws SampException { // Mostly delegate registration to the underlying client profile, // but put in place machinery to keep track of which clients // are registered via this service. This will be required so that // they can be messaged if the underlying hub shuts down. HubConnection baseConnection = profile_.register(); if ( baseConnection != null ) { HubConnection conn = new FacadeHubConnection( baseConnection ) { final HubConnection hubConn = this; public void ping() throws SampException { if ( FacadeHubService.this.isHubRunning() ) { super.ping(); } else { throw new SampException( "Hub underlying facade " + "is not running" ); } } public void unregister() throws SampException { connectionMap_.keySet().remove( hubConn ); super.unregister(); } }; connectionMap_.put( conn, profileToken ); return conn; } // Or return null if there is no underlying hub. else { return null; } } public void disconnectAll( ProfileToken profileToken ) { Map.Entry[] entries = (Map.Entry[]) connectionMap_.entrySet() .toArray( new Map.Entry[ 0 ] ); List ejectList = new ArrayList(); for ( int ie = 0; ie < entries.length; ie++ ) { if ( profileToken.equals( entries[ ie ].getValue() ) ) { ejectList.add( entries[ ie ].getKey() ); } } FacadeHubConnection[] ejectConns = (FacadeHubConnection[]) ejectList.toArray( new FacadeHubConnection[ 0 ] ); int nc = ejectConns.length; Message discoMsg = new Message( "samp.hub.event.shutdown" ); String[] ejectIds = new String[ nc ]; for ( int ic = 0; ic < nc; ic++ ) { FacadeHubConnection conn = ejectConns[ ic ]; ejectIds[ ic ] = conn.getRegInfo().getSelfId(); conn.hubEvent( discoMsg ); connectionMap_.remove( conn ); } for ( int ic = 0; ic < nc; ic++ ) { hubEvent( new Message( "samp.hub.event.unregister" ) .addParam( "id", ejectIds[ ic ] ) ); } } /** * No-op. */ public void start() { } public void shutdown() { hubEvent( new Message( "samp.hub.event.shutdown" ) ); connectionMap_.clear(); } /** * Sends a given message by notification, as if from the hub, * to all the clients which have registered through this service. * * @param msg message to send */ private void hubEvent( Message msg ) { String mtype = msg.getMType(); FacadeHubConnection[] connections = (FacadeHubConnection[]) connectionMap_.keySet().toArray( new FacadeHubConnection[ 0 ] ); for ( int ic = 0; ic < connections.length; ic++ ) { connections[ ic ].hubEvent( msg ); } } /** * Utility HubConnection class which allows hub event notifications * to be sent to clients. */ private static class FacadeHubConnection extends WrapperHubConnection { private CallableClient callable_; private Subscriptions subs_; /** * Constructor. * * @param base base connection */ FacadeHubConnection( HubConnection base ) { super( base ); } public void setCallable( CallableClient callable ) throws SampException { super.setCallable( callable ); callable_ = callable; } public void declareSubscriptions( Map subs ) throws SampException { super.declareSubscriptions( subs ); subs_ = subs == null ? null : Subscriptions.asSubscriptions( subs ); } /** * Sends a given message as a notification, as if from the hub, * to this connection if it is able to receive it. * * @param msg message to send */ void hubEvent( Message msg ) { String mtype = msg.getMType(); CallableClient callable = callable_; if ( callable != null && subs_.isSubscribed( mtype ) ) { RegInfo regInfo = getRegInfo(); try { callable.receiveNotification( regInfo.getHubId(), msg ); } catch ( Throwable e ) { logger_.info( "Failed " + mtype + " notification to " + regInfo.getSelfId() ); } } } } } jsamp/src/java/org/astrogrid/samp/hub/MessageRestriction.java0000664000175000017500000000156212730747754024204 0ustar sladensladenpackage org.astrogrid.samp.hub; import java.util.Map; /** * Specifies restrictions on the message types that may be sent in * a particular context. * In general if null is used in place of a MessageRestriction object, * the understanding is that no restrictions apply. * * @author Mark Taylor * @since 23 Nov 2011 */ public interface MessageRestriction { /** * Indicates whether a message covered by a given MType subscription * may be sent. * * @param mtype the MType string to be sent * @param subsInfo the annotation map corresponding to the MType * subscription (the value from the Subscriptions map * corresponding to the mtype key) * @return true if the message may be sent, false if it is blocked */ boolean permitSend( String mtype, Map subsInfo ); } jsamp/src/java/org/astrogrid/samp/hub/PingMessageHandler.java0000664000175000017500000000114012730747754024062 0ustar sladensladenpackage org.astrogrid.samp.hub; import java.util.HashMap; import java.util.Map; import org.astrogrid.samp.Message; import org.astrogrid.samp.client.AbstractMessageHandler; import org.astrogrid.samp.client.HubConnection; /** * Implements samp.app.ping MType. * * @author Mark Taylor * @since 21 Nov 2011 */ class PingMessageHandler extends AbstractMessageHandler { /** * Constructor. */ public PingMessageHandler() { super( "samp.app.ping" ); } public Map processCall( HubConnection conn, String senderId, Message msg ) { return new HashMap(); } }; jsamp/src/java/org/astrogrid/samp/hub/WrapperHubConnection.java0000664000175000017500000000530212730747754024465 0ustar sladensladenpackage org.astrogrid.samp.hub; import java.util.List; import java.util.Map; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.RegInfo; import org.astrogrid.samp.Response; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; /** * HubConnection implementation that delegates all calls to a base instance. * * @author Mark Taylor * @since 3 Feb 2011 */ class WrapperHubConnection implements HubConnection { private final HubConnection base_; /** * Constructor. * * @param base hub connection to which all calls are delegated */ public WrapperHubConnection( HubConnection base ) { base_ = base; } public RegInfo getRegInfo() { return base_.getRegInfo(); } public void setCallable( CallableClient client ) throws SampException { base_.setCallable( client ); } public void ping() throws SampException { base_.ping(); } public void unregister() throws SampException { base_.unregister(); } public void declareMetadata( Map meta ) throws SampException { base_.declareMetadata( meta ); } public Metadata getMetadata( String clientId ) throws SampException { return base_.getMetadata( clientId ); } public void declareSubscriptions( Map subs ) throws SampException { base_.declareSubscriptions( subs ); } public Subscriptions getSubscriptions( String clientId ) throws SampException { return base_.getSubscriptions( clientId ); } public String[] getRegisteredClients() throws SampException { return base_.getRegisteredClients(); } public Map getSubscribedClients( String mtype ) throws SampException { return base_.getSubscribedClients( mtype ); } public void notify( String recipientId, Map msg ) throws SampException { base_.notify( recipientId, msg ); } public List notifyAll( Map msg ) throws SampException { return base_.notifyAll( msg ); } public String call( String recipientId, String msgTag, Map msg ) throws SampException { return base_.call( recipientId, msgTag, msg ); } public Map callAll( String msgTag, Map msg ) throws SampException { return base_.callAll( msgTag, msg ); } public Response callAndWait( String recipientId, Map msg, int timeout ) throws SampException { return base_.callAndWait( recipientId, msg, timeout ); } public void reply( String msgId, Map response ) throws SampException { base_.reply( msgId, response ); } } jsamp/src/java/org/astrogrid/samp/hub/BasicClientSet.java0000664000175000017500000000240212730747754023220 0ustar sladensladenpackage org.astrogrid.samp.hub; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.Map; import java.util.TreeMap; /** * Basic ClientSet implementation. * * @author Mark Taylor * @since 20 Nov 2008 */ public class BasicClientSet implements ClientSet { private final Map publicIdMap_; /** * Constructor. * * @param clientIdComparator comparator for client IDs */ public BasicClientSet( Comparator clientIdComparator ) { publicIdMap_ = Collections .synchronizedMap( new TreeMap( clientIdComparator ) ); } public synchronized void add( HubClient client ) { publicIdMap_.put( client.getId(), client ); } public synchronized void remove( HubClient client ) { publicIdMap_.remove( client.getId() ); } public synchronized HubClient getFromPublicId( String publicId ) { return (HubClient) publicIdMap_.get( publicId ); } public synchronized HubClient[] getClients() { return (HubClient[]) publicIdMap_.values().toArray( new HubClient[ 0 ] ); } public synchronized boolean containsClient( HubClient client ) { return publicIdMap_.containsValue( client ); } } jsamp/src/java/org/astrogrid/samp/hub/HubServiceMode.java0000664000175000017500000006762012730747754023245 0ustar sladensladenpackage org.astrogrid.samp.hub; import java.awt.AWTException; import java.awt.CheckboxMenuItem; import java.awt.Image; import java.awt.Menu; import java.awt.MenuItem; import java.awt.PopupMenu; import java.awt.Toolkit; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.ItemEvent; import java.awt.event.ItemListener; import java.awt.event.KeyEvent; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.io.IOException; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Random; import java.util.logging.Logger; import javax.swing.AbstractAction; import javax.swing.Action; import javax.swing.JCheckBoxMenuItem; import javax.swing.JMenu; import javax.swing.JMenuBar; import javax.swing.JMenuItem; import javax.swing.JFrame; import javax.swing.JToggleButton; import javax.swing.SwingUtilities; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; import org.astrogrid.samp.Client; import org.astrogrid.samp.client.DefaultClientProfile; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.gui.ErrorDialog; import org.astrogrid.samp.gui.GuiHubService; import org.astrogrid.samp.gui.MessageTrackerHubService; import org.astrogrid.samp.gui.SysTray; /** * Specifies a particular hub implementation for use with {@link Hub}. * * @author Mark Taylor * @since 20 Nov 2008 */ public abstract class HubServiceMode { // This class looks like an enumeration-type class to external users. // It is actually a HubService factory. private final String name_; private final boolean isDaemon_; private static final Logger logger_ = Logger.getLogger( HubServiceMode.class.getName() ); /** Hub mode with no GUI representation of hub operations. */ public static final HubServiceMode NO_GUI; /** Hub mode with a GUI representation of connected clients. */ public static final HubServiceMode CLIENT_GUI; /** Hub mode with a GUI representation of clients and messages. */ public static HubServiceMode MESSAGE_GUI; /** Hub Mode which piggy-backs on an existing hub using * the default client profile. */ public static HubServiceMode FACADE; /** Array of available hub modes. */ private static final HubServiceMode[] KNOWN_MODES = new HubServiceMode[] { NO_GUI = createBasicHubMode( "no-gui" ), CLIENT_GUI = createGuiHubMode( "client-gui" ), MESSAGE_GUI = createMessageTrackerHubMode( "msg-gui" ), FACADE = createFacadeHubMode( "facade" ), }; /** * Constructor. * * @param name mode name * @param isDaemon true if the hub will start only daemon threads */ HubServiceMode( String name, boolean isDaemon ) { name_ = name; isDaemon_ = isDaemon; } /** * Creates and returns a new hub service object. * * @param random random number generator * @param profiles hub profiles * @param runners 1-element array of Hubs - this should be * populated with the runner once it has been constructed * @return object containing the hub service and possibly a window */ abstract ServiceGui createHubService( Random random, HubProfile[] profiles, Hub[] runners ); /** * Indicates whether the hub service will start only daemon threads. * If it returns true, the caller may need to make sure that the * JVM doesn't stop too early. * * @return true iff no non-daemon threads will be started by the service */ boolean isDaemon() { return isDaemon_; } /** * Returns this mode's name. * * @return mode name */ String getName() { return name_; } public String toString() { return name_; } /** * Returns one of the known modes which has a name as given. * * @param name mode name (case-insensitive) * @return mode with given name, or null if none known */ public static HubServiceMode getModeFromName( String name ) { HubServiceMode[] modes = KNOWN_MODES; for ( int im = 0; im < modes.length; im++ ) { HubServiceMode mode = modes[ im ]; if ( mode.name_.equalsIgnoreCase( name ) ) { return mode; } } return null; } /** * Returns an array of the hub modes which can actually be used. * * @return available mode list */ public static HubServiceMode[] getAvailableModes() { List modeList = new ArrayList(); for ( int i = 0; i < KNOWN_MODES.length; i++ ) { HubServiceMode mode = KNOWN_MODES[ i ]; if ( ! ( mode instanceof BrokenHubMode ) ) { modeList.add( mode ); } } return (HubServiceMode[]) modeList.toArray( new HubServiceMode[ 0 ] ); } /** * Used to perform common configuration of hub display windows * for GUI-type hub modes. * * @param frame hub window * @param profiles profiles to run for hub * @param runners 1-element array which will contain an associated * hub runner object if one exists * @param hubService object providing hub services * @return object which should be shutdown when the hub stops running */ private static Tidier configureHubWindow( JFrame frame, HubProfile[] profiles, Hub[] runners, GuiHubService hubService ) { SysTray sysTray = SysTray.getInstance(); if ( sysTray.isSupported() ) { try { SysTrayWindowConfig winConfig = new SysTrayWindowConfig( frame, profiles, runners, hubService, sysTray ); winConfig.configureWindow(); winConfig.configureSysTray(); logger_.info( "Hub started in system tray" ); return winConfig; } catch ( AWTException e ) { logger_.warning( "Failed to install in system tray: " + e ); BasicWindowConfig winConfig = new BasicWindowConfig( frame, profiles, runners, hubService ); winConfig.configureWindow(); return winConfig; } } else { logger_.info( "System tray not supported: displaying hub window" ); BasicWindowConfig winConfig = new BasicWindowConfig( frame, profiles, runners, hubService ); winConfig.configureWindow(); return winConfig; } } /** * Constructs a mode for BasicHubService. * * @param name mode name * @return non-gui mode */ private static HubServiceMode createBasicHubMode( String name ) { try { return new HubServiceMode( name, true ) { ServiceGui createHubService( Random random, HubProfile[] profiles, Hub[] runners ) { ServiceGui serviceGui = new ServiceGui(); serviceGui.service_ = new BasicHubService( random ); return serviceGui; } }; } catch ( Throwable e ) { return new BrokenHubMode( name, e ); } } /** * Constructs a mode for GuiHubService. * * @return mode without message tracking */ private static HubServiceMode createGuiHubMode( String name ) { try { /* Check GuiHubService class is present; if GUI classes are * missing in a stripped-down installation find out now * (mode creation time) rather than service creation time. */ GuiHubService.class.getName(); /* Create and return the service. */ return new HubServiceMode( name, false ) { ServiceGui createHubService( Random random, final HubProfile[] profiles, final Hub[] runners ) { final ServiceGui serviceGui = new ServiceGui(); serviceGui.service_ = new GuiHubService( random ) { volatile Runnable tidierCallback; public void start() { final GuiHubService service = this; super.start(); SwingUtilities.invokeLater( new Runnable() { public void run() { JFrame window = createHubWindow(); final Tidier tidier = configureHubWindow( window, profiles, runners, service ); tidierCallback = new Runnable() { public void run() { tidier.tidyGui(); } }; serviceGui.window_ = window; } } ); } public void shutdown() { super.shutdown(); /* It is (apparently) necessary under some * circumstances to call an existing Runnable here * rather than creating a new one because of * weird (buggy?) shutdown behaviour * in the JNLP class loader (fails with * IllegalStateException: zip file closed). * Report and fix provided by Laurent Bourges. */ if ( tidierCallback != null ) { SwingUtilities.invokeLater( tidierCallback ); }; } }; return serviceGui; } }; } catch ( Throwable e ) { return new BrokenHubMode( name, e ); } } /** * Constructs a mode for MessageTrackerHubService. * * @return mode with message tracking */ private static HubServiceMode createMessageTrackerHubMode( String name ) { try { MessageTrackerHubService.class.getName(); return new HubServiceMode( name, false ) { ServiceGui createHubService( Random random, final HubProfile[] profiles, final Hub[] runners ) { final ServiceGui serviceGui = new ServiceGui(); serviceGui.service_ = new MessageTrackerHubService( random ) { Tidier tidier; public void start() { super.start(); final MessageTrackerHubService service = this; SwingUtilities.invokeLater( new Runnable() { public void run() { JFrame window = createHubWindow(); tidier = configureHubWindow( window, profiles, runners, service ); serviceGui.window_ = window; } } ); } public void shutdown() { super.shutdown(); SwingUtilities.invokeLater( new Runnable() { public void run() { if ( tidier != null ) { tidier.tidyGui(); } } } ); } }; return serviceGui; } }; } catch ( Throwable e ) { return new BrokenHubMode( name, e ); } } /** * Constructs a mode for FacadeHubService. * * @return mode based on the default client profile */ private static HubServiceMode createFacadeHubMode( String name ) { return new HubServiceMode( name, true ) { ServiceGui createHubService( Random random, HubProfile[] profiles, final Hub[] runners ) { ServiceGui serviceGui = new ServiceGui(); serviceGui.service_ = new FacadeHubService( DefaultClientProfile.getProfile() ); return serviceGui; } }; } /** * HubServiceMode implementation for modes which cannot be used because they * rely on classes unavailable at runtime. */ private static class BrokenHubMode extends HubServiceMode { private final Throwable error_; /** * Constructor. * * @param name mode name * @param error error explaining why mode is unavailable for use */ BrokenHubMode( String name, Throwable error ) { super( name, false ); error_ = error; } ServiceGui createHubService( Random random, HubProfile[] profiles, Hub[] runners ) { throw new RuntimeException( "Hub mode " + getName() + " unavailable", error_ ); } } /** * Utility abstract class to define an object which can be tidied up * on hub shutdown. */ private interface Tidier { /** * Performs any required tidying operations. * May be assumed to be called on the AWT Event Dispatch Thread. */ void tidyGui(); } /** * Aggregates a HubService and an associated monitor/control window. */ static class ServiceGui { private volatile HubService service_; private JFrame window_; /** * Returns the hub service. * * @return hub service object */ public HubService getHubService() { return service_; } /** * Returns a monitor/control window for this service, if available. * * @return window, or null */ public JFrame getWindow() { return window_; } } /** * Class to configure a window for use as a hub control. */ private static class BasicWindowConfig implements Tidier { final JFrame frame_; final Hub[] runners_; final GuiHubService hubService_; final ProfileToggler[] profileTogglers_; final ConfigHubProfile[] configProfiles_; final Action exitAct_; /** * Constructor. * * @param frame hub window * @param profiles hub profiles to run * @param runners 1-element array which will contain an associated * hub runner object if one exists * @param hubService object providing hub services */ BasicWindowConfig( JFrame frame, HubProfile[] profiles, final Hub[] runners, GuiHubService hubService ) { frame_ = frame; runners_ = runners; hubService_ = hubService; profileTogglers_ = new ProfileToggler[ profiles.length ]; List configProfileList = new ArrayList(); for ( int ip = 0; ip < profiles.length; ip++ ) { HubProfile profile = profiles[ ip ]; profileTogglers_[ ip ] = new ProfileToggler( profile, runners ); if ( profile instanceof ConfigHubProfile ) { configProfileList.add( (ConfigHubProfile) profile ); } } configProfiles_ = (ConfigHubProfile[]) configProfileList.toArray( new ConfigHubProfile[ 0 ] ); exitAct_ = new AbstractAction( "Stop Hub" ) { public void actionPerformed( ActionEvent evt ) { if ( runners[ 0 ] != null ) { runners[ 0 ].shutdown(); } tidyGui(); } }; exitAct_.putValue( Action.SHORT_DESCRIPTION, "Shut down SAMP hub" ); exitAct_.putValue( Action.MNEMONIC_KEY, new Integer( KeyEvent.VK_T ) ); } /** * Perform configuration of window. */ public void configureWindow() { configureMenus(); frame_.setDefaultCloseOperation( JFrame.DISPOSE_ON_CLOSE ); frame_.setVisible( true ); frame_.addWindowListener( new WindowAdapter() { public void windowClosed( WindowEvent evt ) { Hub runner = runners_[ 0 ]; if ( runner != null ) { runner.shutdown(); } } } ); } /** * Configures menus on the window. Invoked by configureWindow. */ protected void configureMenus() { JMenuBar mbar = new JMenuBar(); JMenu fileMenu = new JMenu( "File" ); fileMenu.setMnemonic( KeyEvent.VK_F ); fileMenu.add( new JMenuItem( exitAct_ ) ); mbar.add( fileMenu ); JMenu[] serviceMenus = hubService_.createMenus(); for ( int im = 0; im < serviceMenus.length; im++ ) { mbar.add( serviceMenus[ im ] ); } JMenu profileMenu = new JMenu( "Profiles" ); profileMenu.setMnemonic( KeyEvent.VK_P ); for ( int ip = 0; ip < profileTogglers_.length; ip++ ) { profileMenu.add( profileTogglers_[ ip ].createJMenuItem() ); } // Add configuration menus - somewhat hacky, only really intended // for Web Profile at present. for ( int ic = 0; ic < configProfiles_.length; ic++ ) { ConfigHubProfile configProfile = configProfiles_[ ic ]; JToggleButton.ToggleButtonModel[] configModels = configProfile.getConfigModels(); JMenu configMenu = new JMenu( configProfile.getProfileName() + " Profile Configuration" ); for ( int im = 0; im < configModels.length; im++ ) { JToggleButton.ToggleButtonModel model = configModels[ im ]; JCheckBoxMenuItem menuItem = new JCheckBoxMenuItem( model.toString() ); menuItem.setModel( model ); configMenu.add( menuItem ); } profileMenu.add( configMenu ); } mbar.add( profileMenu ); frame_.setJMenuBar( mbar ); } public void tidyGui() { if ( frame_.isDisplayable() ) { frame_.dispose(); } } } /** * Takes care of hub display window configuration with system tray * functionality. */ private static class SysTrayWindowConfig extends BasicWindowConfig { private final SysTray sysTray_; private final Action showAct_; private final Action hideAct_; private final MenuItem showItem_; private final MenuItem hideItem_; private final MenuItem exitItem_; private final ActionListener iconListener_; private Object trayIcon_; /** * Constructor. * * @param frame hub window * @param profiles hub profiles to run * @param runners 1-element array which will contain an associated * hub runner object if one exists * @param hubService object providing hub services * @param sysTray system tray facade object */ SysTrayWindowConfig( JFrame frame, HubProfile[] profiles, Hub[] runners, GuiHubService hubService, SysTray sysTray ) { super( frame, profiles, runners, hubService ); sysTray_ = sysTray; showAct_ = new AbstractAction( "Show Hub Window" ) { public void actionPerformed( ActionEvent evt ) { setWindowVisible( true ); } }; hideAct_ = new AbstractAction( "Hide Hub Window" ) { public void actionPerformed( ActionEvent evt ) { setWindowVisible( false ); } }; showItem_ = toMenuItem( showAct_ ); hideItem_ = toMenuItem( hideAct_ ); exitItem_ = toMenuItem( exitAct_ ); iconListener_ = showAct_; } protected void configureMenus() { super.configureMenus(); frame_.getJMenuBar().getMenu( 0 ).add( new JMenuItem( hideAct_ ) ); } public void configureWindow() { configureMenus(); frame_.setDefaultCloseOperation( JFrame.HIDE_ON_CLOSE ); // Arrange that a manual window close will set the action states // correctly. frame_.addWindowListener( new WindowAdapter() { public void windowClosing( WindowEvent evt ) { showAct_.setEnabled( true ); hideAct_.setEnabled( false ); } } ); hideAct_.actionPerformed( null ); } /** * Performs configuration. */ public void configureSysTray() throws AWTException { Image im = Toolkit.getDefaultToolkit() .createImage( Client.class .getResource( "images/hub.png" ) ); String tooltip = "SAMP Hub"; PopupMenu popup = new PopupMenu(); Menu profileMenu = new Menu( "Profiles" ); for ( int ip = 0; ip < profileTogglers_.length; ip++ ) { profileMenu.add( profileTogglers_[ ip ].createMenuItem() ); } popup.add( profileMenu ); popup.add( showItem_ ); popup.add( hideItem_ ); popup.add( exitItem_ ); trayIcon_ = sysTray_.addIcon( im, tooltip, popup, iconListener_ ); } public void tidyGui() { super.tidyGui(); try { sysTray_.removeIcon( trayIcon_ ); } catch ( AWTException e ) { logger_.warning( "Can't remove hub system tray icon: " + e ); } } /** * Sets visibility for the hub control window, adjusting actions * as appropriate. * * @param isVis true for visible, false for invisible */ private void setWindowVisible( boolean isVis ) { frame_.setVisible( isVis ); showAct_.setEnabled( ! isVis ); hideAct_.setEnabled( isVis ); showItem_.setEnabled( ! isVis ); hideItem_.setEnabled( isVis ); } /** * Turns an action into an AWT menu item. * * @param act action * @return MenuItem facade */ private MenuItem toMenuItem( Action act ) { MenuItem item = new MenuItem( (String) act.getValue( Action.NAME ) ); item.addActionListener( act ); return item; } } /** * Manages a toggle button for starting/stopping profiles. * This object can supply both Swing JMenuItems and AWT MenuItems * with effectively the same model (which is quite hard work). */ private static class ProfileToggler { final HubProfile profile_; final Hub[] runners_; final String title_; final JToggleButton.ToggleButtonModel toggleModel_; final List menuItemList_; /** * Constructor. * * @param profile profile to operate on * @param runners one-element array containing hub */ ProfileToggler( HubProfile profile, Hub[] runners ) { profile_ = profile; runners_ = runners; title_ = profile.getProfileName() + " Profile"; menuItemList_ = new ArrayList(); toggleModel_ = new JToggleButton.ToggleButtonModel() { public boolean isSelected() { return profile_.isRunning(); } public void setSelected( boolean on ) { Hub hub = runners_[ 0 ]; if ( hub != null ) { if ( on && ! profile_.isRunning() ) { try { hub.startProfile( profile_ ); super.setSelected( on ); } catch ( IOException e ) { ErrorDialog .showError( null, title_ + " Start Error", "Error starting " + title_, e ); return; } } else if ( ! on && profile_.isRunning() ) { hub.stopProfile( profile_ ); } } super.setSelected( on ); } }; toggleModel_.addChangeListener( new ChangeListener() { public void stateChanged( ChangeEvent evt ) { updateMenuItems(); } } ); } /** * Returns a new Swing JMenuItem for start/stop toggle. * * @return menu item */ public JMenuItem createJMenuItem() { JCheckBoxMenuItem item = new JCheckBoxMenuItem( title_ ); item.setToolTipText( "Start or stop the " + title_ ); char chr = Character .toUpperCase( profile_.getProfileName().charAt( 0 ) ); if ( chr >= 'A' && chr <= 'Z' ) { item.setMnemonic( (int) chr ); } item.setModel( toggleModel_ ); return item; } /** * Returns a new AWT MenuItem for start/stop toggle. * * @return menu item */ public MenuItem createMenuItem() { final CheckboxMenuItem item = new CheckboxMenuItem( title_ ); item.addItemListener( new ItemListener() { public void itemStateChanged( ItemEvent evt ) { boolean on = item.getState(); toggleModel_.setSelected( on ); if ( toggleModel_.isSelected() != on ) { item.setState( toggleModel_.isSelected() ); } } } ); item.setState( toggleModel_.isSelected() ); menuItemList_.add( item ); return item; } /** * Updates all dispatched menu items to the current state. */ private void updateMenuItems() { for ( Iterator it = menuItemList_.iterator(); it.hasNext(); ) { CheckboxMenuItem item = (CheckboxMenuItem) it.next(); boolean on = toggleModel_.isSelected(); if ( item.getState() != on ) { item.setState( on ); } } } } } jsamp/src/java/org/astrogrid/samp/hub/HubProfile.java0000664000175000017500000000322412730747754022426 0ustar sladensladenpackage org.astrogrid.samp.hub; import java.io.IOException; import java.util.List; import org.astrogrid.samp.client.ClientProfile; /** * Defines a hub profile. * This profile allows registration and deregistration of clients to * a given provider of hub connections, using some profile-specific * transport and authentication arrangements. * Multiple profiles may be attached to a single connection supplier * at any time, and may be started and stopped independently of each other. * The connection supplier is typically a hub service running in the same * JVM, but may also be a client-side connection to a hub. * A profile should be able to undergo multiple start/stop cycles. * * @author Mark Taylor * @since 31 Jan 2011 */ public interface HubProfile extends ProfileToken { /** * Starts this profile's activity allowing access to a given supplier of * hub connections. * * @param profile object which can provide hub connections */ void start( ClientProfile profile ) throws IOException; /** * Indicates whether this profile is currently running. * * @return true iff profile is running */ boolean isRunning(); /** * Ends this profile's activity on behalf of the hub. * Any resources associated with the profile should be released. * This does not include messaging registered clients about profile * termination; that should be taken care of by the user of this profile. */ void stop() throws IOException; /** * Returns the name of this profile. * * @return profile name, usually one word */ public String getProfileName(); } jsamp/src/java/org/astrogrid/samp/hub/HubClient.java0000664000175000017500000001071112730747754022243 0ustar sladensladenpackage org.astrogrid.samp.hub; import java.util.Map; import org.astrogrid.samp.Client; import org.astrogrid.samp.Message; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.Response; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.SampException; /** * Represents a client registered with a hub. * * @author Mark Taylor * @since 15 Jul 2008 */ public class HubClient implements Client { private final String publicId_; private final ProfileToken profileToken_; private volatile Subscriptions subscriptions_; private volatile Metadata metadata_; private volatile CallableClient callable_; /** * Constructor. * * @param publicId client public ID * @param profileToken identifier for the source of the hub connection */ public HubClient( String publicId, ProfileToken profileToken ) { publicId_ = publicId; profileToken_ = profileToken; subscriptions_ = new Subscriptions(); metadata_ = new Metadata(); callable_ = new NoCallableClient(); } public String getId() { return publicId_; } public Metadata getMetadata() { return metadata_; } public Subscriptions getSubscriptions() { return subscriptions_; } /** * Returns a token identifying the source of this client's connection * to the hub. * * @return profile token */ public ProfileToken getProfileToken() { return profileToken_; } /** * Sets this client's metadata map. * * @param meta metadata map */ public void setMetadata( Map meta ) { metadata_ = new Metadata( meta ); } /** * Sets this client's subscriptions list. * * @param subs subscriptions map */ public void setSubscriptions( Map subs ) { subscriptions_ = Subscriptions.asSubscriptions( subs ); } /** * Indicates whether this client is subscribed to a given MType. * * @param mtype MType * @return true iff subscribed to MType */ public boolean isSubscribed( String mtype ) { return isCallable() && subscriptions_.isSubscribed( mtype ); } /** * Returns the subscription information for a given MType for this client. * * @param mtype MType * @return subscriptions map value for key mtype, * or null if not subscribed */ public Map getSubscription( String mtype ) { return isCallable() ? subscriptions_.getSubscription( mtype ) : null; } /** * Sets the callable object which allows this client to receive * callbacks. If null is used, a no-op callable object is installed. * * @param callable new callable interface, or null */ public void setCallable( CallableClient callable ) { callable_ = callable == null ? new NoCallableClient() : callable; } /** * Returns the callable object which allows this client to receive * callbacks. It is never null. * * @return callable object */ public CallableClient getCallable() { return callable_; } /** * Indicates whether this client is callable. * * @return true iff this client has a non-useless callback handler * installed */ public boolean isCallable() { return ! ( callable_ instanceof NoCallableClient ); } public String toString() { return SampUtils.toString( this ); } /** * No-op callback handler implementation. * Any attempt to call its methods results in an exception. */ private class NoCallableClient implements CallableClient { public void receiveNotification( String senderId, Message message ) throws SampException { refuse(); } public void receiveCall( String senderId, String msgId, Message message ) throws SampException { refuse(); } public void receiveResponse( String responderId, String msgId, Response response ) throws SampException { refuse(); } private void refuse() throws SampException { throw new SampException( "Client " + getId() + " is not callable" ); } } } jsamp/src/java/org/astrogrid/samp/hub/HubService.java0000664000175000017500000000423312730747754022427 0ustar sladensladenpackage org.astrogrid.samp.hub; import java.util.List; import java.util.Map; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; /** * Interface defining the work that the hub has to do. * This is independent of profile or transport, and just concerns * keeping track of clients and routing messages between them. * * @author Mark Taylor * @since 15 Jul 2008 */ public interface HubService { /** * Begin operation. The {@link #register} method should not be * called until the hub has been started. */ void start(); /** * Creates a new connection to this hub service, thereby initiating * a new registered client. * *

      It is the responsibility of the returned connection, not the * user of that connection, to broadcast the various * samp.hub.event.* notifications at the appropriate times. * *

      Most of the HubConnection methods are declared to * throw SampException, however, implementations may * throw unchecked exceptions if that is more convenient; * users of the connection should be prepared to catch these if * they occur. * * @param profileToken identifier for the profile acting as gatekeeper * for this connection * @return new hub connection representing registration of a new client */ HubConnection register( ProfileToken profileToken ) throws SampException; /** * Forcibly terminates any connections created by a previous call of * {@link #register} * with a particular profileToken. * Any necessary hub events will be sent. * * @param profileToken previous argument to register */ void disconnectAll( ProfileToken profileToken ); /** * Indicates whether this hub service is currently open for operations. * * @return true iff called between {@link #start} and {@link #shutdown} */ boolean isHubRunning(); /** * Tidies up any resources owned by this object. * Should be called when no longer required. */ void shutdown(); } jsamp/src/java/org/astrogrid/samp/hub/MetaQueryMessageHandler.java0000664000175000017500000000305612730747754025111 0ustar sladensladenpackage org.astrogrid.samp.hub; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import org.astrogrid.samp.Message; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.client.AbstractMessageHandler; import org.astrogrid.samp.client.HubConnection; /** * Implements MType for querying registered clients by metadata item. * * @author Mark Taylor * @since 21 Nov 2011 */ class MetaQueryMessageHandler extends AbstractMessageHandler { private final ClientSet clientSet_; private static final String BASE_MTYPE = "query.by-meta"; /** * Constructor. * * @param clientSet hub client set object */ public MetaQueryMessageHandler( ClientSet clientSet ) { super( new String[] { "samp." + BASE_MTYPE, "x-samp." + BASE_MTYPE } ); clientSet_ = clientSet; } public Map processCall( HubConnection conn, String senderId, Message msg ) { String key = (String) msg.getRequiredParam( "key" ); String value = (String) msg.getRequiredParam( "value" ); HubClient[] clients = clientSet_.getClients(); List foundList = new ArrayList(); for ( int ic = 0; ic < clients.length; ic++ ) { HubClient client = clients[ ic ]; Metadata meta = client.getMetadata(); if ( meta != null && value.equals( meta.get( key ) ) ) { foundList.add( client.getId() ); } } Map result = new HashMap(); result.put( "ids", foundList ); return result; } } jsamp/src/java/org/astrogrid/samp/hub/HubCallableClient.java0000664000175000017500000000574112730747754023672 0ustar sladensladenpackage org.astrogrid.samp.hub; import org.astrogrid.samp.Message; import org.astrogrid.samp.Response; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.client.AbstractMessageHandler; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; /** * CallableClient implementation used by the hub client. * This isn't exactly essential, but it enables the hub client * (the client which represents the hub itself) to subscribe to some MTypes. * Possibly useful for testing purposes etc. * * @author Mark Taylor * @since 28 Jan 2011 */ class HubCallableClient implements CallableClient { private final HubConnection connection_; private final AbstractMessageHandler[] handlers_; /** * Constructs a HubCallableClient with a given set of handlers. * * @param connection connection to hub service * @param handlers array of message handlers */ public HubCallableClient( HubConnection connection, AbstractMessageHandler[] handlers ) { connection_ = connection; handlers_ = handlers; } public void receiveCall( String senderId, String msgId, Message msg ) throws SampException { msg.check(); getHandler( msg.getMType() ) .receiveCall( connection_, senderId, msgId, msg ); } public void receiveNotification( String senderId, Message msg ) throws SampException { msg.check(); getHandler( msg.getMType() ) .receiveNotification( connection_, senderId, msg ); } public void receiveResponse( String responderId, String msgTag, Response response ) throws SampException { } /** * Returns the subscriptions corresponding to the messages that this * receiver can deal with. * * @return subscriptions list */ public Subscriptions getSubscriptions() { Subscriptions subs = new Subscriptions(); for ( int i = 0; i < handlers_.length; i++ ) { subs.putAll( handlers_[ i ].getSubscriptions() ); } return subs; } /** * Returns a handler owned by this callable client which can handle * a given MType. If more than one applies, the first one encountered * is returned. * * @param mtype MType to handle * @return handler for mtype * @throws SampException if no suitable handler exists */ private AbstractMessageHandler getHandler( String mtype ) throws SampException { for ( int i = 0; i < handlers_.length; i++ ) { AbstractMessageHandler handler = handlers_[ i ]; if ( Subscriptions.asSubscriptions( handler.getSubscriptions() ) .isSubscribed( mtype ) ) { return handler; } } throw new SampException( "Not subscribed to " + mtype ); } } jsamp/src/java/org/astrogrid/samp/hub/HubProfileFactory.java0000664000175000017500000000356212730747754023763 0ustar sladensladenpackage org.astrogrid.samp.hub; import java.io.IOException; import java.util.List; /** * Factory to produce hub profiles of a particular type. * Used with the command-line invocation of the hub. * * @author Mark Taylor * @since 31 Jan 2011 */ public interface HubProfileFactory { /** * Returns the name used to identify this profile. * * @return short name */ String getName(); /** * Returns an array of strings, each describing one command-line flag * which will be consumed by the createProfile method. * * @return array of plain-text strings suitable for use as part of * a usage message */ String[] getFlagsUsage(); /** * Creates a HubProfile perhaps configured using a supplied list * of flags. Any flags which match those described by the * {@link #getFlagsUsage} command are used for configuration of the * returned hub, and must be removed from the flagList list. * Unrecognised flags should be ignored and left in the list. * Flags which are recognised but badly formed should raise a * RuntimeException with a helpful message. * * @param flagList mutable list of Strings giving command-ilne flags, * some of which may be intended for configuring a profile * @return new profile */ HubProfile createHubProfile( List flagList ) throws IOException; /** * Returns a HubProfile subclass with a no-arg constructor which, * when invoked, will produce a basic instance of the HubProfile * represented by this factory. The instance thus produced will * typically be similar to that produced by invoking * {@link #createHubProfile} with an empty flag list. * * @return HubProfile subclass with a public no-arg constructor */ Class getHubProfileClass(); } jsamp/src/java/org/astrogrid/samp/hub/KeyGenerator.java0000664000175000017500000000350512730747754022770 0ustar sladensladenpackage org.astrogrid.samp.hub; import java.security.SecureRandom; import java.util.Random; /** * Object which can generate a sequence of private keys. * The values returned by the next() method should in general not be * easy to guess. * * @author Mark Taylor * @since 26 Oct 2010 */ public class KeyGenerator { private final String prefix_; private final int nchar_; private final Random random_; private int iseq_; private static final char SEQ_DELIM = '_'; /** * Constructor. * * @param prefix prefix prepended to all generated keys * @param nchar number of characters in generated keys * @param random random number generator */ public KeyGenerator( String prefix, int nchar, Random random ) { prefix_ = prefix; nchar_ = nchar; random_ = random; } /** * Returns the next key in the sequence. * Guaranteed different from any previous return value from this method. * * @return key string */ public synchronized String next() { StringBuffer sbuf = new StringBuffer(); sbuf.append( prefix_ ); sbuf.append( Integer.toString( ++iseq_ ) ); sbuf.append( SEQ_DELIM ); for ( int i = 0; i < nchar_; i++ ) { char c = (char) ( 'a' + (char) random_.nextInt( 'z' - 'a' ) ); assert c != SEQ_DELIM; sbuf.append( c ); } return sbuf.toString(); } /** * Returns a new, randomly seeded, Random object. * * @return random */ public static Random createRandom() { byte[] seedBytes = new SecureRandom().generateSeed( 8 ); long seed = 0L; for ( int i = 0; i < 8; i++ ) { seed = ( seed << 8 ) | ( seedBytes[ i ] & 0xff ); } return new Random( seed ); } } jsamp/src/java/org/astrogrid/samp/hub/ProfileToken.java0000664000175000017500000000136612730747754022775 0ustar sladensladenpackage org.astrogrid.samp.hub; /** * Marker interface that identifies a hub profile. * Objects implementing this interface can be identified as the provider of * a connection to the hub. * * @author Mark Taylor * @since 20 Jul 2011 */ public interface ProfileToken { /** * Returns the name by which this token is to be identified. * * @return profile identifier, usually one word */ String getProfileName(); /** * Returns a MessageRestriction object which controls what messages * may be sent by clients registering under ths profile. * If null is returned, any messages may be sent. * * @return message restriction, or null */ MessageRestriction getMessageRestriction(); } jsamp/src/java/org/astrogrid/samp/hub/Hub.java0000664000175000017500000011142212730747754021105 0ustar sladensladenpackage org.astrogrid.samp.hub; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Arrays; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.WeakHashMap; import java.util.logging.Level; import java.util.logging.Logger; import javax.swing.JFrame; import org.astrogrid.samp.ShutdownManager; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.httpd.UtilServer; import org.astrogrid.samp.web.WebHubProfile; import org.astrogrid.samp.web.WebHubProfileFactory; import org.astrogrid.samp.xmlrpc.StandardHubProfile; import org.astrogrid.samp.xmlrpc.StandardHubProfileFactory; import org.astrogrid.samp.xmlrpc.XmlRpcKit; /** * Class which manages a hub and its associated profiles. * Static methods are provided for starting a hub in the current or an * external JVM, and a main() method is provided for * use from the command line. * *

      Some of the static methods allow you to indicate which hub profiles * should be used, others use a default. The default list can be set * programmatically by using the {@link #setDefaultProfileClasses} method * or externally by using the * {@value #HUBPROFILES_PROP} and {@value #EXTRAHUBPROFILES_PROP} * system properties. * So, for instance, running an application with * -Djsamp.hub.profiles=web,std will cause it to run hubs * using both the Standard and Web profiles if it does not explicitly choose * profiles. * * @author Mark Taylor * @author Sylvain Lafrasse * @since 31 Jan 2011 */ public class Hub { private final HubService service_; private final List profileList_; private static Class[] defaultDefaultProfileClasses_ = { StandardHubProfile.class, WebHubProfile.class, }; private static Class[] defaultDefaultExtraProfileClasses_ = { }; private static Class[] defaultProfileClasses_ = createDefaultProfileClasses( false ); private static Class[] defaultExtraProfileClasses_ = createDefaultProfileClasses( true ); private static final Map hubList_ = new WeakHashMap(); private static final Logger logger_ = Logger.getLogger( Hub.class.getName() ); /** * System property name for supplying default profiles ({@value}) * available at hub startup. * The value of this property, if any, will be fed to * {@link #parseProfileList}. */ public static final String HUBPROFILES_PROP = "jsamp.hub.profiles"; /** * System property name for supplying default profiles ({@value}) * additional to those in {@link #HUBPROFILES_PROP} which will be * supported by the hub but switched off at hub startup time. * The value of this property, if any, will be fed to * {@link #parseProfileList}. */ public static final String EXTRAHUBPROFILES_PROP = "jsamp.hub.profiles.extra"; /** * Constructor. * Note that this object does not start the service, it must be * started explicitly, either before or after this constructor is called. * * @param service hub service */ public Hub( HubService service ) { service_ = service; profileList_ = new ArrayList(); synchronized ( hubList_ ) { hubList_.put( this, null ); } } /** * Stops this hub and its profiles running. */ public synchronized void shutdown() { logger_.info( "Shutting down hub service" ); try { service_.shutdown(); } catch ( Throwable e ) { logger_.log( Level.WARNING, "Service shutdown error: " + e, e ); } for ( Iterator it = profileList_.iterator(); it.hasNext(); ) { HubProfile profile = (HubProfile) it.next(); logger_.info( "Shutting down hub profile " + profile.getProfileName() ); try { profile.stop(); } catch ( IOException e ) { logger_.log( Level.WARNING, "Failed to stop profile " + profile.getProfileName(), e ); } it.remove(); } synchronized ( hubList_ ) { hubList_.remove( this ); } ShutdownManager.getInstance().unregisterHook( this ); } /** * Starts a profile running on behalf of this hub. * * @param profile to start */ public synchronized void startProfile( final HubProfile profile ) throws IOException { if ( profileList_.contains( profile ) ) { logger_.info( "Profile " + profile.getProfileName() + " already started in this hub" ); } else { profile.start( new ClientProfile() { public HubConnection register() throws SampException { return service_.register( profile ); } public boolean isHubRunning() { return service_.isHubRunning(); } } ); profileList_.add( profile ); } } /** * Stops a profile running on behalf of this hub, and disconnects * all clients registered with it. * * @param profile profile to stop */ public synchronized void stopProfile( HubProfile profile ) { logger_.info( "Shutting down hub profile " + profile.getProfileName() + " and disconnecting clients" ); try { profile.stop(); } catch ( IOException e ) { logger_.log( Level.WARNING, "Failed to stop profile " + profile.getProfileName(), e ); } profileList_.remove( profile ); service_.disconnectAll( profile ); } /** * Returns the hub service associated with this hub. * * @return hub service */ public HubService getHubService() { return service_; } /** * Returns the hub profiles currently running on behalf of this hub. * * @return profiles that have been started and not yet stopped by this hub */ public HubProfile[] getRunningProfiles() { return (HubProfile[]) profileList_.toArray( new HubProfile[ 0 ] ); } /** * Returns a window for user monitoring and control of this hub, * if available. * The default implementation returns null, but this may be overridden * depending on how this hub was instantiated. * * @return hub monitor/control window, or null */ public JFrame getWindow() { return null; } /** * Returns a standard list of known HubProfileFactories. * This is used when parsing hub profile lists * ({@link #parseProfileList} to supply the well-known named profiles. * * @return array of known hub profile factories */ public static HubProfileFactory[] getKnownHubProfileFactories() { return new HubProfileFactory[] { new StandardHubProfileFactory(), new WebHubProfileFactory(), }; } /** * Returns a copy of the default set of HubProfile classes used * when a hub is run and the list of profiles is not set explicitly. * Each element should be an implementation of {@link HubProfile} * with a no-arg constructor. * * @param extra false for starting classes, true for additional ones * @return array of hub profile classes */ public static Class[] getDefaultProfileClasses( boolean extra ) { return (Class[]) ( extra ? defaultExtraProfileClasses_ : defaultProfileClasses_ ).clone(); } /** * Sets the default set of HubProfile classes. * * @param clazzes array to be returned by getDefaultProfileClasses * @param extra false for starting classes, true for additional ones */ public static void setDefaultProfileClasses( Class[] clazzes, boolean extra ) { for ( int ip = 0; ip < clazzes.length; ip++ ) { Class clazz = clazzes[ ip ]; if ( ! HubProfile.class.isAssignableFrom( clazz ) ) { throw new IllegalArgumentException( "Class " + clazz.getName() + " not a HubProfile" ); } } clazzes = (Class[]) clazzes.clone(); if ( extra ) { defaultExtraProfileClasses_ = clazzes; } else { defaultProfileClasses_ = clazzes; } } /** * Invoked at class load time to come up with the list of hub * profiles to use when no profiles are specified explicitly. * By default this is just the standard profile, but if the * {@link #HUBPROFILES_PROP} system property is defined its value * is used instead. * * @param extra false for starting classes, true for additional ones * @return default array of hub profile classes */ private static Class[] createDefaultProfileClasses( boolean extra ) { String listTxt = System.getProperty( extra ? EXTRAHUBPROFILES_PROP : HUBPROFILES_PROP ); if ( listTxt != null ) { HubProfileFactory[] facts = parseProfileList( listTxt ); Class[] clazzes = new Class[ facts.length ]; for ( int i = 0; i < facts.length; i++ ) { clazzes[ i ] = facts[ i ].getHubProfileClass(); } return clazzes; } else { return extra ? defaultDefaultExtraProfileClasses_ : defaultDefaultProfileClasses_; } } /** * Parses a string representing a list of hub profiles. * The result is an array of HubProfileFactories. * The list is comma-separated, and each element may be * either the {@link HubProfileFactory#getName name} * of a HubProfileFactory * or the classname of a {@link HubProfile} implementation * with a suitable no-arg constructor. * * @param listTxt comma-separated list * @return array of hub profile factories * @throws IllegalArgumentException if unknown */ public static HubProfileFactory[] parseProfileList( String listTxt ) { String[] txtItems = listTxt == null || listTxt.trim().length() == 0 ? new String[ 0 ] : listTxt.split( "," ); List factoryList = new ArrayList(); for ( int i = 0; i < txtItems.length; i++ ) { factoryList.add( parseProfileClass( txtItems[ i ] ) ); } return (HubProfileFactory[]) factoryList.toArray( new HubProfileFactory[ 0 ] ); } /** * Parses a string representing a hub profile. Each element may be * either the {@link HubProfileFactory#getName name} * of a HubProfileFactory * or the classname of a {@link HubProfile} implementation * with a suitable no-arg constructor. * * @param txt string * @return hub profile factory * @throws IllegalArgumentException if unknown */ private static HubProfileFactory parseProfileClass( String txt ) { HubProfileFactory[] profFacts = getKnownHubProfileFactories(); for ( int i = 0; i < profFacts.length; i++ ) { if ( txt.equals( profFacts[ i ].getName() ) ) { return profFacts[ i ]; } } final Class clazz; try { clazz = Class.forName( txt ); } catch ( ClassNotFoundException e ) { throw (IllegalArgumentException) new IllegalArgumentException( "No known hub/class " + txt ) .initCause( e ); } if ( HubProfile.class.isAssignableFrom( clazz ) ) { return new HubProfileFactory() { public Class getHubProfileClass() { return clazz; } public String[] getFlagsUsage() { return new String[ 0 ]; } public String getName() { return clazz.getName(); } public HubProfile createHubProfile( List flagList ) throws IOException { try { return (HubProfile) clazz.newInstance(); } catch ( IllegalAccessException e ) { throw (IOException) new IOException( "Can't create " + clazz.getName() + " instance" ) .initCause( e ); } catch ( InstantiationException e ) { throw (IOException) new IOException( "Can't create " + clazz.getName() + " instance" ) .initCause( e ); } catch ( ExceptionInInitializerError e ) { Throwable cause = e.getCause(); if ( cause instanceof IOException ) { throw (IOException) cause; } else { throw (IOException) new IOException( "Can't create " + clazz.getName() + " instance" ) .initCause( e ); } } } }; } else { throw new IllegalArgumentException( clazz + " is not a " + HubProfile.class.getName() ); } } /** * Returns an array of default Hub Profiles. * This is the result of calling the no-arg constructor * for each element of the result of {@link #getDefaultProfileClasses}. * * @param extra false for starting profiles, true for additional ones * @return array of hub profiles to use by default */ public static HubProfile[] createDefaultProfiles( boolean extra ) { Class[] clazzes = getDefaultProfileClasses( extra ); List hubProfileList = new ArrayList(); for ( int ip = 0; ip < clazzes.length; ip++ ) { Class clazz = clazzes[ ip ]; try { hubProfileList.add( (HubProfile) clazz.newInstance() ); } catch ( ClassCastException e ) { logger_.warning( "No hub profile " + clazz.getName() + " - not a " + HubProfile.class.getName() ); } catch ( InstantiationException e ) { logger_.warning( "No hub profile " + clazz.getName() + " - failed to instantiate (" + e + ")" ); } catch ( IllegalAccessException e ) { logger_.warning( "No hub profile " + clazz.getName() + " - inaccessible constructor (" + e + ")" ); } catch ( ExceptionInInitializerError e ) { logger_.warning( "No hub profile " + clazz.getName() + " - construction error" + " (" + e.getCause() + ")" ); } } return (HubProfile[]) hubProfileList.toArray( new HubProfile[ 0 ] ); } /** * Starts a SAMP hub with given sets of profiles. * The returned hub is running. * *

      The profiles argument gives the profiles which will * be started initially, and the extraProfiles argument * lists more that can be started under user control later. * If either or both list is given as null, suitable defaults will be used. * *

      If the hub mode corresponds to one of the GUI options, * one of two things will happen. An attempt will be made to install * an icon in the "system tray"; if this is successful, the attached * popup menu will provide options for displaying the hub window and * for shutting it down. If no system tray is available, the hub window * will be posted directly, and the hub will shut down when this window * is closed. System tray functionality is only available when running * under Java 1.6 or later, and when using a suitable display manager. * * @param hubMode hub mode * @param profiles SAMP profiles to support on hub startup; * if null a default set will be used * @param extraProfiles SAMP profiles to offer for later startup under * user control; if null a default set will be used * @return running hub */ public static Hub runHub( HubServiceMode hubMode, HubProfile[] profiles, HubProfile[] extraProfiles ) throws IOException { // Get values for the list of starting profiles, and the list of // additional profiles that may be started later. // If these have not been specified explicitly, use defaults. if ( profiles == null ) { profiles = createDefaultProfiles( false ); } if ( extraProfiles == null ) { extraProfiles = createDefaultProfiles( true ); } List profList = new ArrayList(); profList.addAll( Arrays.asList( profiles ) ); for ( int ip = 0; ip < extraProfiles.length; ip++ ) { HubProfile ep = extraProfiles[ ip ]; boolean gotit = false; for ( int jp = 0; jp < profiles.length; jp++ ) { gotit = gotit || profiles[ jp ].getClass().equals( ep.getClass() ); } if ( ! gotit ) { profList.add( ep ); } } HubProfile[] allProfiles = (HubProfile[]) profList.toArray( new HubProfile[ 0 ] ); // Construct a hub service ready to use the full profile list. final Hub[] runners = new Hub[ 1 ]; final HubServiceMode.ServiceGui serviceGui = hubMode.createHubService( KeyGenerator.createRandom(), allProfiles, runners ); HubService hubService = serviceGui.getHubService(); final Hub hub = new Hub( hubService ) { public JFrame getWindow() { return serviceGui.getWindow(); } }; runners[ 0 ] = hub; // Start the initial profiles. int nStarted = 0; IOException error1 = null; for ( int ip = 0; ip < profiles.length; ip++ ) { HubProfile prof = profiles[ ip ]; String pname = prof.getProfileName(); try { logger_.info( "Starting hub profile " + pname ); hub.startProfile( prof ); nStarted++; } catch ( IOException e ) { if ( error1 == null ) { error1 = e; } logger_.log( Level.WARNING, "Failed to start SAMP hub profile " + pname, e ); } } logger_.info( "Started " + nStarted + "/" + profiles.length + " SAMP profiles" ); if ( nStarted == 0 && profiles.length > 0 ) { assert error1 != null; throw (IOException) new IOException( "No SAMP profiles started: " + error1 ) .initCause( error1 ); } // Start the hub service itself. logger_.info( "Starting hub service" ); hubService.start(); ShutdownManager.getInstance() .registerHook( hub, ShutdownManager.HUB_SEQUENCE, new Runnable() { public void run() { hub.shutdown(); } } ); // Return the running hub. return hub; } /** * Starts a SAMP hub with a default set of profiles. * This convenience method invokes runHub(hubMode,null,null). * * @param hubMode hub mode * @return running hub * @see #runHub(HubServiceMode,HubProfile[],HubProfile[]) */ public static Hub runHub( HubServiceMode hubMode ) throws IOException { return runHub( hubMode, null, null ); } /** * Attempts to start a hub in a new JVM with a given set * of profiles. The resulting hub can therefore outlast the * lifetime of the current application. * Because of the OS interaction required, it's hard to make this * bulletproof, and it may fail without an exception, but we do our best. * *

      The classes specified by the profileClasses and * extraProfileClasses arguments must implement * {@link HubProfile} and must have a no-arg constructor. * If null is given in either case suitable defaults, taken from the * current JVM, are used. * * @param hubMode hub mode * @param profileClasses hub profile classes to start on hub startup * @param extraProfileClasses hub profile classes which may be started * later under user control * @see #checkExternalHubAvailability */ public static void runExternalHub( HubServiceMode hubMode, Class[] profileClasses, Class[] extraProfileClasses ) throws IOException { String classpath = System.getProperty( "java.class.path" ); if ( classpath == null || classpath.trim().length() == 0 ) { throw new IOException( "No classpath available - JNLP context?" ); } File javaHome = new File( System.getProperty( "java.home" ) ); File javaExec = new File( new File( javaHome, "bin" ), "java" ); String javacmd = ( javaExec.exists() && ! javaExec.isDirectory() ) ? javaExec.toString() : "java"; String[] propagateProps = new String[] { XmlRpcKit.IMPL_PROP, UtilServer.PORT_PROP, SampUtils.LOCALHOST_PROP, HUBPROFILES_PROP, EXTRAHUBPROFILES_PROP, "java.awt.Window.locationByPlatform", }; List argList = new ArrayList(); argList.add( javacmd ); for ( int ip = 0; ip < propagateProps.length; ip++ ) { String propName = propagateProps[ ip ]; String propVal = System.getProperty( propName ); if ( propVal != null ) { argList.add( "-D" + propName + "=" + propVal ); } } argList.add( "-classpath" ); argList.add( classpath ); argList.add( Hub.class.getName() ); argList.add( "-mode" ); argList.add( hubMode.toString() ); if ( profileClasses != null ) { argList.add( "-profiles" ); StringBuffer profArg = new StringBuffer(); for ( int ip = 0; ip < profileClasses.length; ip++ ) { if ( ip > 0 ) { profArg.append( ',' ); } profArg.append( profileClasses[ ip ].getName() ); } argList.add( profArg.toString() ); } if ( extraProfileClasses != null ) { argList.add( "-extraprofiles" ); StringBuffer eprofArg = new StringBuffer(); for ( int ip = 0; ip < profileClasses.length; ip++ ) { if ( ip > 0 ) { eprofArg.append( ',' ); } eprofArg.append( extraProfileClasses[ ip ].getName() ); } argList.add( eprofArg.toString() ); } String[] args = (String[]) argList.toArray( new String[ 0 ] ); StringBuffer cmdbuf = new StringBuffer(); for ( int iarg = 0; iarg < args.length; iarg++ ) { if ( iarg > 0 ) { cmdbuf.append( ' ' ); } cmdbuf.append( args[ iarg ] ); } logger_.info( "Starting external hub" ); logger_.info( cmdbuf.toString() ); execBackground( args ); } /** * Attempts to run a hub in a new JVM with a default set of profiles. * The default set is taken from that in this JVM. * This convenience method invokes * runExternalHub(hubMode,null,null). * * @param hubMode hub mode * @see #runExternalHub(HubServiceMode,java.lang.Class[],java.lang.Class[]) */ public static void runExternalHub( HubServiceMode hubMode ) throws IOException { runExternalHub( hubMode, null, null ); } /** * Returns an array of all the instances of this class which are * currently running. * * @return running hubs */ public static Hub[] getRunningHubs() { List list; synchronized ( hubList_ ) { list = new ArrayList( hubList_.keySet() ); } for ( Iterator it = list.iterator(); it.hasNext(); ) { Hub hub = (Hub) it.next(); if ( ! hub.getHubService().isHubRunning() ) { it.remove(); } } return (Hub[]) list.toArray( new Hub[ 0 ] ); } /** * Attempts to determine whether an external hub can be started using * {@link #runExternalHub runExternalHub}. * If it can be determined that such an * attempt would fail, this method will throw an exception with * an informative message. This method succeeding is not a guarantee * that an external hub can be started successfullly. * The behaviour of this method is not expected to change over the * lifetime of a given JVM. */ public static void checkExternalHubAvailability() throws IOException { String classpath = System.getProperty( "java.class.path" ); if ( classpath == null || classpath.trim().length() == 0 ) { throw new IOException( "No classpath available - JNLP context?" ); } if ( System.getProperty( "jnlpx.jvm" ) != null ) { throw new IOException( "Running under WebStart" + " - external hub not likely to work" ); } } /** * Main method, which allows configuration of which profiles will run * and configuration of those individual profiles. * Use the -h flag for usage. */ public static void main( String[] args ) { try { int status = runMain( args ); if ( status != 0 ) { System.exit( status ); } } // Explicit exit on error may be necessary to kill Swing. catch ( Throwable e ) { e.printStackTrace(); System.exit( 2 ); } } /** * Invoked by main. * In case of a usage error, it returns a non-zero value, but does not * call System.exit. * * @param args command-line argument array * @return non-zero for error completion */ public static int runMain( String[] args ) throws IOException { HubProfileFactory[] knownProfileFactories = getKnownHubProfileFactories(); // Assemble usage message. StringBuffer pbuf = new StringBuffer(); for ( int ip = 0; ip < knownProfileFactories.length; ip++ ) { pbuf.append( knownProfileFactories[ ip ].getName() ) .append( '|' ); } pbuf.append( "" ) .append( "[,...]" ); String profUsage = pbuf.toString(); StringBuffer ubuf = new StringBuffer(); ubuf.append( "\n Usage:" ) .append( "\n " ) .append( Hub.class.getName() ) .append( "\n " ) .append( " [-help]" ) .append( " [-/+verbose]" ) .append( "\n " ) .append( " [-mode " ); HubServiceMode[] modes = HubServiceMode.getAvailableModes(); for ( int im = 0; im < modes.length; im++ ) { if ( im > 0 ) { ubuf.append( '|' ); } ubuf.append( modes[ im ].getName() ); } ubuf.append( ']' ) .append( "\n " ) .append( " [" ) .append( "-profiles " ) .append( profUsage ) .append( "]" ) .append( "\n " ) .append( " [" ) .append( "-extraprofiles " ) .append( profUsage ) .append( "]" ); for ( int ip = 0; ip < knownProfileFactories.length; ip++ ) { List pusageList = new ArrayList( Arrays.asList( knownProfileFactories[ ip ] .getFlagsUsage() ) ); while ( ! pusageList.isEmpty() ) { StringBuffer sbuf = new StringBuffer() .append( "\n " ); for ( Iterator it = pusageList.iterator(); it.hasNext(); ) { String pusage = (String) it.next(); if ( sbuf.length() + pusage.length() < 78 ) { sbuf.append( ' ' ) .append( pusage ); it.remove(); } else { break; } } ubuf.append( sbuf ); } } ubuf.append( '\n' ); String usage = ubuf.toString(); // Get default hub mode. HubServiceMode hubMode = HubServiceMode.MESSAGE_GUI; if ( ! Arrays.asList( HubServiceMode.getAvailableModes() ) .contains( hubMode ) ) { hubMode = HubServiceMode.NO_GUI; } // Parse general command-line arguments. List argList = new ArrayList( Arrays.asList( args ) ); int verbAdjust = 0; String stdSecret = null; boolean stdHttplock = false; String webAuth = "swing"; String webLog = "none"; boolean webRemote = false; String profilesTxt = null; String extraProfilesTxt = null; for ( Iterator it = argList.iterator(); it.hasNext(); ) { String arg = (String) it.next(); if ( arg.equals( "-mode" ) && it.hasNext() ) { it.remove(); String mode = (String) it.next(); it.remove(); hubMode = HubServiceMode.getModeFromName( mode ); if ( hubMode == null ) { System.err.println( "Unkown mode " + mode ); System.err.println( usage ); return 1; } } else if ( arg.equals( "-profiles" ) ) { it.remove(); if ( it.hasNext() ) { profilesTxt = (String) it.next(); it.remove(); } else { System.err.println( usage ); return 1; } } else if ( arg.equals( "-extraprofiles" ) ) { it.remove(); if ( it.hasNext() ) { extraProfilesTxt = (String) it.next(); it.remove(); } else { System.err.println( usage ); return 1; } } else if ( arg.equals( "-v" ) || arg.equals( "-verbose" ) ) { it.remove(); verbAdjust--; } else if ( arg.equals( "+v" ) || arg.equals( "+verbose" ) ) { it.remove(); verbAdjust++; } else if ( arg.equals( "-h" ) || arg.equals( "-help" ) ) { it.remove(); System.out.println( usage ); return 0; } } // Adjust logging in accordance with verboseness flags. int logLevel = Level.WARNING.intValue() + 100 * verbAdjust; Logger.getLogger( "org.astrogrid.samp" ) .setLevel( Level.parse( Integer.toString( logLevel ) ) ); // Assemble lists of profiles to use. HubProfile[] profiles = getProfiles( profilesTxt, argList, false, "-profiles " + profUsage ); if ( profiles == null ) { return 1; } HubProfile[] extraProfiles = getProfiles( extraProfilesTxt, argList, true, "-extraprofiles " + profUsage ); if ( profiles == null ) { return 1; } // Check all command line args have been used. if ( ! argList.isEmpty() ) { System.err.println( "Some args not used " + argList ); System.err.println( usage ); return 1; } // Start hub service and install profile-specific interfaces. runHub( hubMode, profiles, extraProfiles ); // For non-GUI case block indefinitely otherwise the hub (which uses // a daemon thread) will just exit immediately. if ( hubMode.isDaemon() ) { Object lock = new String( "Indefinite" ); synchronized ( lock ) { try { lock.wait(); } catch ( InterruptedException e ) { } } } // Success return. return 0; } /** * Parses profile list command-line argument and associated * command-line arguments to construct a list of required profiles. * If there is an argument processing error, an error message will * be written to standard error and a null value will be returned. * * @param profTxt string value of profiles parameter (may be null) * @param argList complete list of other so far unused command line * args * @param isExtra true for extraProfiles, false for main ones * @param usage profile flag usage string, used for error messages * @return profiles array, or null */ private static HubProfile[] getProfiles( String profTxt, List argList, boolean isExtra, String usage ) throws IOException { if ( profTxt == null ) { Class[] dflts = isExtra ? defaultDefaultExtraProfileClasses_ : defaultDefaultProfileClasses_; if ( Arrays.equals( createDefaultProfileClasses( isExtra ), dflts ) ) { profTxt = isExtra ? "" : "std,web"; } else { logger_.warning( "Non-default profiles set external to flags; " + "web: and std: flags will be ignored" ); return createDefaultProfiles( isExtra ); } } HubProfileFactory[] pfacts; try { pfacts = parseProfileList( profTxt ); } catch ( IllegalArgumentException e ) { System.err.println( e.getMessage() ); System.err.println( usage ); return null; } HubProfile[] profiles = new HubProfile[ pfacts.length ]; for ( int i = 0; i < pfacts.length; i++ ) { HubProfileFactory pfact = pfacts[ i ]; try { profiles[ i ] = pfact.createHubProfile( argList ); } catch ( RuntimeException e ) { System.err.println( "Error configuring profile " + pfact.getName() + ":\n" + e.getMessage() ); return null; } } return profiles; } /** * Executes a command in a separate process, and discards any stdout * or stderr output generated by it. * Simply calling Runtime.exec can block the process * until its output is consumed. * * @param cmdarray array containing the command to call and its args */ private static void execBackground( String[] cmdarray ) throws IOException { Process process = Runtime.getRuntime().exec( cmdarray ); discardBytes( process.getInputStream() ); discardBytes( process.getErrorStream() ); } /** * Ensures that any bytes from a given input stream are discarded. * * @param in input stream */ private static void discardBytes( final InputStream in ) { Thread eater = new Thread( "StreamEater" ) { public void run() { try { while ( in.read() >= 0 ) {} in.close(); } catch ( IOException e ) { } } }; eater.setDaemon( true ); eater.start(); } } jsamp/src/java/org/astrogrid/samp/hub/ClientSet.java0000664000175000017500000000213212730747754022256 0ustar sladensladenpackage org.astrogrid.samp.hub; /** * Data structure for keeping track of clients currently registered with a hub. * * @author Mark Taylor * @since 15 Jul 2008 */ public interface ClientSet { /** * Adds a new client to the set. * * @param client client to add */ void add( HubClient client ); /** * Removes a client from the set. * * @param client client to remove */ void remove( HubClient client ); /** * Returns the client in the set corresponding to a given public ID. * * @param publicId client public ID * @return client with id publicId if registered, or null */ HubClient getFromPublicId( String publicId ); /** * Returns an array of all the currently contained clients. * * @return client list */ HubClient[] getClients(); /** * Indicates whether a given client is currently a member of this set. * * @return true iff client is currently a member of this set */ boolean containsClient( HubClient client ); } jsamp/src/java/org/astrogrid/samp/hub/BasicHubService.java0000664000175000017500000013346012730747754023376 0ustar sladensladenpackage org.astrogrid.samp.hub; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Random; import java.util.TreeMap; import java.util.logging.Level; import java.util.logging.Logger; import org.astrogrid.samp.Message; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.RegInfo; import org.astrogrid.samp.Response; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.client.AbstractMessageHandler; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.httpd.UtilServer; /** * HubService implementation. * * @author Mark Taylor * @since 15 Jul 2008 */ public class BasicHubService implements HubService { private final KeyGenerator keyGen_; private final ClientIdGenerator idGen_; private final Map waiterMap_; private ClientSet clientSet_; private HubClient serviceClient_; private HubConnection serviceClientConnection_; private volatile boolean started_; private volatile boolean shutdown_; private static final char ID_DELIMITER = '_'; private final Logger logger_ = Logger.getLogger( BasicHubService.class.getName() ); private final static ProfileToken INTERNAL_PROFILE = new ProfileToken() { public String getProfileName() { return "Internal"; } public MessageRestriction getMessageRestriction() { return null; } }; /** The maximum timeout for a synchronous call permitted in seconds. * Default is 43200 = 12 hours. */ public static int MAX_TIMEOUT = 12 * 60 * 60; /** The maximum number of concurrently pending synchronous calls. * Default is 100. */ public static int MAX_WAITERS = 100; /** * Constructor. * * @param random random number generator used for message tags etc */ public BasicHubService( Random random ) { // Prepare ID generators. keyGen_ = new KeyGenerator( "m:", 16, random ); idGen_ = new ClientIdGenerator( "c" ); // Prepare the data structure which keeps track of pending synchronous // calls. waiterMap_ = Collections.synchronizedMap( new HashMap() ); } public void start() { // Prepare the data structure which keeps track of registered clients. clientSet_ = createClientSet(); // Prepare and store the client object which represents the hub itself // (the one that apparently sends samp.hub.event.shutdown messages etc). serviceClient_ = createClient( "hub", INTERNAL_PROFILE ); serviceClientConnection_ = createConnection( serviceClient_ ); Metadata meta = new Metadata(); meta.setName( "Hub" ); try { meta.setIconUrl( UtilServer.getInstance() .exportResource( "/org/astrogrid/samp/images/" + "hub.png" ) .toString() ); } catch ( Throwable e ) { logger_.warning( "Can't set icon" ); } meta.put( "author.name", "Mark Taylor" ); meta.put( "author.mail", "m.b.taylor@bristol.ac.uk" ); meta.setDescriptionText( getClass().getName() ); serviceClient_.setMetadata( meta ); HubCallableClient hubCallable = new HubCallableClient( serviceClientConnection_, createHubMessageHandlers() ); serviceClient_.setCallable( hubCallable ); serviceClient_.setSubscriptions( hubCallable.getSubscriptions() ); clientSet_.add( serviceClient_ ); started_ = true; } /** * Factory method used to create the client set used by this hub service. * * @return client set */ protected ClientSet createClientSet() { return new BasicClientSet( getIdComparator() ) { public void add( HubClient client ) { assert client.getId().indexOf( ID_DELIMITER ) < 0; super.add( client ); } }; } /** * Factory method used to create all the client objects which will * be used by this hub service. * * @param publicId client public ID * @param ptoken connection source * @return hub client */ protected HubClient createClient( String publicId, ProfileToken ptoken ) { return new HubClient( publicId, ptoken ); } /** * Constructs a list of MessageHandlers to use for the client * provided by the Hub. * * @return hub message handler list */ protected AbstractMessageHandler[] createHubMessageHandlers() { return new AbstractMessageHandler[] { new PingMessageHandler(), new MetaQueryMessageHandler( getClientSet() ), }; } /** * Returns a comparator which will order client IDs. * The ordering is in creation sequence. * * @return public ID comparator */ public Comparator getIdComparator() { return idGen_.getComparator(); } /** * Returns the structure which keeps track of registered clients. * * @return client set */ public ClientSet getClientSet() { return clientSet_; } public HubConnection register( ProfileToken ptoken ) throws SampException { if ( ! started_ ) { throw new SampException( "Not started" ); } HubClient client = createClient( idGen_.next(), ptoken ); assert client.getId().indexOf( ID_DELIMITER ) < 0; clientSet_.add( client ); hubEvent( new Message( "samp.hub.event.register" ) .addParam( "id", client.getId() ) ); return createConnection( client ); } public void disconnectAll( ProfileToken profileToken ) { HubClient[] clients = clientSet_.getClients(); List profClientIdList = new ArrayList(); for ( int ic = 0; ic < clients.length; ic++ ) { HubClient client = clients[ ic ]; if ( profileToken.equals( client.getProfileToken() ) ) { profClientIdList.add( client.getId() ); } } disconnect( (String[]) profClientIdList.toArray( new String[ 0 ] ), new Message( "samp.hub.event.shutdown" ) ); } /** * Returns a new HubConnection for use by a given hub client. * The instance methods of the returned object delegate to similarly * named protected methods of this BasicHubService object. * These BasicHubService methods may therefore be overridden to * modify the behaviour of such returned connections. * * @param caller client requiring a connection * @return connection whose methods may be called by or on behalf of * caller */ protected HubConnection createConnection( final HubClient caller ) { final BasicHubService service = this; final RegInfo regInfo = new RegInfo(); regInfo.put( RegInfo.HUBID_KEY, serviceClient_.getId() ); regInfo.put( RegInfo.SELFID_KEY, caller.getId() ); return new HubConnection() { public RegInfo getRegInfo() { return regInfo; } public void ping() throws SampException { if ( ! service.isHubRunning() ) { throw new SampException( "Service is stopped" ); } } public void unregister() throws SampException { checkCaller(); service.unregister( caller ); } public void setCallable( CallableClient callable ) throws SampException { checkCaller(); service.setCallable( caller, callable ); } public void declareMetadata( Map meta ) throws SampException { checkCaller(); service.declareMetadata( caller, meta ); } public Metadata getMetadata( String clientId ) throws SampException { checkCaller(); return service.getMetadata( caller, clientId ); } public void declareSubscriptions( Map subs ) throws SampException { checkCaller(); service.declareSubscriptions( caller, subs ); } public Subscriptions getSubscriptions( String clientId ) throws SampException { checkCaller(); return service.getSubscriptions( caller, clientId ); } public String[] getRegisteredClients() throws SampException { checkCaller(); return service.getRegisteredClients( caller ); } public Map getSubscribedClients( String mtype ) throws SampException { checkCaller(); return service.getSubscribedClients( caller, mtype ); } public void notify( String recipientId, Map message ) throws SampException { checkCaller(); service.notify( caller, recipientId, message ); } public String call( String recipientId, String msgTag, Map message ) throws SampException { checkCaller(); return service.call( caller, recipientId, msgTag, message ); } public List notifyAll( Map message ) throws SampException { checkCaller(); return service.notifyAll( caller, message ); } public Map callAll( String msgTag, Map message ) throws SampException { checkCaller(); return service.callAll( caller, msgTag, message ); } public void reply( String msgId, Map response ) throws SampException { checkCaller(); service.reply( caller, msgId, response ); } public Response callAndWait( String recipientId, Map message, int timeout ) throws SampException { checkCaller(); return service.callAndWait( caller, recipientId, message, timeout ); } /** * Checks that this connection's client is able to make calls * on this connection. If it is not, for instance if it has been * unregistered, an exception will be thrown. */ private void checkCaller() throws SampException { if ( ! clientSet_.containsClient( caller ) ) { throw new SampException( "Client not registered" ); } } }; } /** * Does the work for the unregister method of conections * registered with this service. * * @param caller client to unregister * @see org.astrogrid.samp.client.HubConnection#unregister */ protected void unregister( HubClient caller ) throws SampException { clientSet_.remove( caller ); hubEvent( new Message( "samp.hub.event.unregister" ) .addParam( "id", caller.getId() ) ); } /** * Does the work for the setCallable method of connections * registered with this service. * * @param caller client * @param callable callable object * @see org.astrogrid.samp.client.HubConnection#setCallable */ protected void setCallable( HubClient caller, CallableClient callable ) throws SampException { caller.setCallable( callable ); } /** * Does the work for the declareMetadata method of connections * registered with this service. * * @param caller client * @param meta new metadata for client * @see org.astrogrid.samp.client.HubConnection#declareMetadata */ protected void declareMetadata( HubClient caller, Map meta ) throws SampException { Metadata.asMetadata( meta ).check(); caller.setMetadata( meta ); hubEvent( new Message( "samp.hub.event.metadata" ) .addParam( "id", caller.getId() ) .addParam( "metadata", meta ) ); } /** * Does the work for the getMetadata method of connections * registered with this service. * * @param caller calling client * @param clientId id of client being queried * @return metadata for client * @see org.astrogrid.samp.client.HubConnection#getMetadata */ protected Metadata getMetadata( HubClient caller, String clientId ) throws SampException { return getClient( clientId ).getMetadata(); } /** * Does the work for the declareSubscriptions method of * connections registered with this service. * * @param caller client * @param subscriptions new subscriptions for client * @see org.astrogrid.samp.client.HubConnection#declareSubscriptions */ protected void declareSubscriptions( HubClient caller, Map subscriptions ) throws SampException { if ( ! caller.isCallable() ) { throw new SampException( "Client is not callable" ); } Subscriptions subs = Subscriptions.asSubscriptions( subscriptions ); subs.check(); caller.setSubscriptions( subs ); String callerId = caller.getId(); String serviceId = serviceClient_.getId(); String mtype = "samp.hub.event.subscriptions"; HubClient[] recipients = clientSet_.getClients(); for ( int ic = 0; ic < recipients.length; ic++ ) { HubClient recipient = recipients[ ic ]; if ( recipient != serviceClient_ && canSend( serviceClient_, recipient, mtype ) && clientSet_.containsClient( recipient ) ) { Message msg = new Message( mtype ); msg.addParam( "id", callerId ); msg.addParam( "subscriptions", getSubscriptionsFor( recipient, subs ) ); try { recipient.getCallable() .receiveNotification( serviceId, msg ); } catch ( Exception e ) { logger_.log( Level.WARNING, "Notification " + caller + " -> " + recipient + " failed: " + e, e ); } } } } /** * Does the work for the getSubscriptions method of connections * registered with this service. * * @param caller calling client * @param clientId id of client being queried * @return subscriptions for client * @see org.astrogrid.samp.client.HubConnection#getSubscriptions */ protected Subscriptions getSubscriptions( HubClient caller, String clientId ) throws SampException { return getSubscriptionsFor( caller, getClient( clientId ).getSubscriptions() ); } /** * Does the work for the getRegisteredClients method of * connections registered with this service. * * @param caller calling client * @return array of registered client IDs excluding caller's * @see org.astrogrid.samp.client.HubConnection#getRegisteredClients */ protected String[] getRegisteredClients( HubClient caller ) throws SampException { HubClient[] clients = clientSet_.getClients(); List idList = new ArrayList( clients.length ); for ( int ic = 0; ic < clients.length; ic++ ) { if ( ! clients[ ic ].equals( caller ) ) { idList.add( clients[ ic ].getId() ); } } return (String[]) idList.toArray( new String[ 0 ] ); } /** * Does the work for the getSubscribedClients method of * connections registered with this service. * * @param caller calling client * @param mtype message type * @return map in which the keys are the public IDs of clients * subscribed to mtype * @see org.astrogrid.samp.client.HubConnection#getSubscribedClients */ protected Map getSubscribedClients( HubClient caller, String mtype ) throws SampException { HubClient[] clients = clientSet_.getClients(); Map subMap = new TreeMap(); for ( int ic = 0; ic < clients.length; ic++ ) { HubClient client = clients[ ic ]; if ( ! client.equals( caller ) ) { Map sub = client.getSubscriptions().getSubscription( mtype ); if ( sub != null && canSend( caller, client, mtype ) ) { subMap.put( client.getId(), sub ); } } } return subMap; } /** * Does the work for the notify method of connections * registered with this service. * * @param caller calling client * @param recipientId public ID of client to receive message * @param message message * @see org.astrogrid.samp.client.HubConnection#notify */ protected void notify( HubClient caller, String recipientId, Map message ) throws SampException { Message msg = Message.asMessage( message ); msg.check(); String mtype = msg.getMType(); HubClient recipient = getClient( recipientId ); checkSend( caller, recipient, mtype ); try { recipient.getCallable().receiveNotification( caller.getId(), msg ); } catch ( SampException e ) { throw e; } catch ( Exception e ) { throw new SampException( e.getMessage(), e ); } } /** * Does the work for the call method of connections * registered with this service. * * @param caller calling client * @param recipientId client ID of recipient * @param msgTag message tag * @param message message * @return message ID * @see org.astrogrid.samp.client.HubConnection#call */ protected String call( HubClient caller, String recipientId, String msgTag, Map message ) throws SampException { Message msg = Message.asMessage( message ); msg.check(); String mtype = msg.getMType(); HubClient recipient = getClient( recipientId ); String msgId = MessageId.encode( caller, msgTag, false ); checkSend( caller, recipient, mtype ); try { recipient.getCallable().receiveCall( caller.getId(), msgId, msg ); } catch ( SampException e ) { throw e; } catch ( Exception e ) { throw new SampException( e.getMessage(), e ); } return msgId; } /** * Does the work for the notifyAll method of connections * registered with this service. * * @param caller calling client * @param message message * @return list of public IDs for clients to which the notify will be sent * @see org.astrogrid.samp.client.HubConnection#notifyAll */ protected List notifyAll( HubClient caller, Map message ) throws SampException { Message msg = Message.asMessage( message ); msg.check(); String mtype = msg.getMType(); HubClient[] recipients = clientSet_.getClients(); List sentList = new ArrayList(); for ( int ic = 0; ic < recipients.length; ic++ ) { HubClient recipient = recipients[ ic ]; if ( recipient != caller && canSend( caller, recipient, mtype ) && clientSet_.containsClient( recipient ) ) { try { recipient.getCallable() .receiveNotification( caller.getId(), msg ); sentList.add( recipient.getId() ); } catch ( Exception e ) { logger_.log( Level.WARNING, "Notification " + caller + " -> " + recipient + " failed: " + e, e ); } } } return sentList; } /** * Does the work for the call method of connections * registered with this service. * * @param caller calling client * @param msgTag message tag * @param message message * @return publicId->msgId map for clients to which an attempt to * send the call will be made * @see org.astrogrid.samp.client.HubConnection#callAll */ protected Map callAll( HubClient caller, String msgTag, Map message ) throws SampException { Message msg = Message.asMessage( message ); msg.check(); String mtype = msg.getMType(); String msgId = MessageId.encode( caller, msgTag, false ); HubClient[] recipients = clientSet_.getClients(); Map sentMap = new HashMap(); for ( int ic = 0; ic < recipients.length; ic++ ) { HubClient recipient = recipients[ ic ]; if ( recipient != caller && canSend( caller, recipient, mtype ) && clientSet_.containsClient( recipient ) ) { try { recipient.getCallable() .receiveCall( caller.getId(), msgId, msg ); } catch ( SampException e ) { throw e; } catch ( Exception e ) { throw new SampException( e.getMessage(), e ); } sentMap.put( recipient.getId(), msgId ); } } return sentMap; } /** * Does the work for the reply method of connections * registered with this service. * * @param caller calling client * @param msgIdStr message ID * @param resp response to forward * @see org.astrogrid.samp.client.HubConnection#reply */ protected void reply( HubClient caller, String msgIdStr, Map resp ) throws SampException { Response response = Response.asResponse( resp ); response.check(); MessageId msgId = MessageId.decode( msgIdStr ); HubClient sender = getClient( msgId.getSenderId() ); String senderTag = msgId.getSenderTag(); // If we can see from the message ID that it was originally sent // synchronously, take steps to place the response in the map of // waiting messages where it will get picked up and returned to // the sender as a callAndWait return value. if ( msgId.isSynch() ) { synchronized ( waiterMap_ ) { if ( waiterMap_.containsKey( msgId ) ) { if ( waiterMap_.get( msgId ) == null ) { waiterMap_.put( msgId, response ); waiterMap_.notifyAll(); } else { throw new SampException( "Response ignored - you've already sent one" ); } } else { throw new SampException( "Response ignored - synchronous call timed out" ); } } } // Otherwise, just pass it to the sender using a callback. else { try { sender.getCallable() .receiveResponse( caller.getId(), senderTag, response ); } catch ( SampException e ) { throw e; } catch ( Exception e ) { throw new SampException( e.getMessage(), e ); } } } /** * Does the work for the callAndWait method of connections * registered with this service. * * @param caller calling client * @param recipientId client ID of recipient * @param message message * @param timeout timeout in seconds * @return response response * @see org.astrogrid.samp.client.HubConnection#callAndWait */ protected Response callAndWait( HubClient caller, String recipientId, Map message, int timeout ) throws SampException { Message msg = Message.asMessage( message ); msg.check(); String mtype = msg.getMType(); HubClient recipient = getClient( recipientId ); MessageId hubMsgId = new MessageId( caller.getId(), keyGen_.next(), true ); long start = System.currentTimeMillis(); checkSend( caller, recipient, mtype ); synchronized ( waiterMap_ ) { // If the number of pending synchronous calls exceeds the // permitted maximum, remove the oldest calls until there is // space for the new one. if ( MAX_WAITERS > 0 && waiterMap_.size() >= MAX_WAITERS ) { int excess = waiterMap_.size() - MAX_WAITERS + 1; List keyList = new ArrayList( waiterMap_.keySet() ); Collections.sort( keyList, MessageId.AGE_COMPARATOR ); logger_.warning( "Pending synchronous calls exceeds limit " + MAX_WAITERS + " - giving up on " + excess + " oldest" ); for ( int ie = 0; ie < excess; ie++ ) { Object removed = waiterMap_.remove( keyList.get( ie ) ); assert removed != null; } waiterMap_.notifyAll(); } // Place an entry for this synchronous call in the waiterMap. waiterMap_.put( hubMsgId, null ); } // Make the call asynchronously to the receiver. try { recipient.getCallable() .receiveCall( caller.getId(), hubMsgId.toString(), msg ); } catch ( SampException e ) { throw e; } catch ( Exception e ) { throw new SampException( e.getMessage(), e ); } // Wait until either the timeout expires, or the response to the // message turns up in the waiter map (placed there on another // thread by this the reply() method). timeout = Math.min( Math.max( 0, timeout ), Math.max( 0, MAX_TIMEOUT ) ); long finish = timeout > 0 ? System.currentTimeMillis() + timeout * 1000 : Long.MAX_VALUE; // 3e8 years synchronized ( waiterMap_ ) { while ( waiterMap_.containsKey( hubMsgId ) && waiterMap_.get( hubMsgId ) == null && System.currentTimeMillis() < finish ) { long millis = finish - System.currentTimeMillis(); if ( millis > 0 ) { try { waiterMap_.wait( millis ); } catch ( InterruptedException e ) { throw new SampException( "Wait interrupted", e ); } } } // If the response is there, return it to the caller of this // method (the sender of the message). if ( waiterMap_.containsKey( hubMsgId ) ) { Response response = (Response) waiterMap_.remove( hubMsgId ); if ( response != null ) { return response; } // Otherwise, it must have timed out. Exit with an error. else { assert System.currentTimeMillis() >= finish; String millis = Long.toString( System.currentTimeMillis() - start ); String emsg = new StringBuffer() .append( "Synchronous call timeout after " ) .append( millis.substring( 0, millis.length() - 3 ) ) .append( '.' ) .append( millis.substring( millis.length() - 3 ) ) .append( '/' ) .append( timeout ) .append( " sec" ) .toString(); throw new SampException( emsg ); } } else { throw new SampException( "Synchronous call aborted" + " - server load exceeded maximum of " + MAX_WAITERS + "?" ); } } } /** * Returns the HubConnection object used by the hub itself to send * and receive messages. * This is the one which apparently sends samp.hub.event.shutdown messages * etc. * * @return hub service's own hub connection */ public HubConnection getServiceConnection() { return serviceClientConnection_; } /** * Forcibly disconnects a given client. * This call does three things: *

        *
      1. sends a samp.hub.disconnect message to the * client which is about to be ejected, if the client is * subscribed to that MType
      2. *
      3. removes that client from this hub's client set so that any * further communication attempts to or from it will fail
      4. *
      5. broadcasts a samp.hub.unregister message to all * remaining clients indicating that the client has disappeared
      6. *
      * * @param clientId public-id of client to eject * @param reason short text string indicating reason for ejection */ public void disconnect( String clientId, String reason ) { Message discoMsg = new Message( "samp.hub.disconnect" ); if ( reason != null && reason.length() > 0 ) { discoMsg.addParam( "reason", reason ); } disconnect( new String[] { clientId }, discoMsg ); } /** * Forcibly disconnects a number of clients for the same reason. * * @param clientIds public-ids of clients to disconnect * @param discoMsg message to send to clients which will effect * disconnection (samp.hub.disconnect or samp.hub.event.shutdown) */ private void disconnect( String[] clientIds, Message discoMsg ) { // Send the message and remove clients from client set. for ( int ic = 0; ic < clientIds.length; ic++ ) { String clientId = clientIds[ ic ]; HubClient client = clientSet_.getFromPublicId( clientId ); if ( client != null ) { if ( client.isSubscribed( discoMsg.getMType() ) ) { try { notify( serviceClient_, clientId, discoMsg ); } catch ( SampException e ) { logger_.log( Level.INFO, discoMsg.getMType() + " to " + client + " failed", e ); } } clientSet_.remove( client ); } } // Notify the remaining clients that the others have been removed. for ( int ic = 0; ic < clientIds.length; ic++ ) { hubEvent( new Message( "samp.hub.event.unregister" ) .addParam( "id", clientIds[ ic ] ) ); } } public boolean isHubRunning() { return started_ && ! shutdown_; } public synchronized void shutdown() { if ( ! shutdown_ ) { shutdown_ = true; if ( started_ ) { hubEvent( new Message( "samp.hub.event.shutdown" ) ); } serviceClientConnection_ = null; } } /** * Broadcast an event message to all subscribed clients. * The sender of this message is the hub application itself. * * @param msg message to broadcast */ private void hubEvent( Message msg ) { try { notifyAll( serviceClient_, msg ); } catch ( SampException e ) { assert false; } } /** * Returns the client object corresponding to a public client ID. * If no such client is registered, throw an exception. * * @param id client public id * @return HubClient object */ private HubClient getClient( String id ) throws SampException { HubClient client = clientSet_.getFromPublicId( id ); if ( client != null ) { return client; } else if ( idGen_.hasUsed( id ) ) { throw new SampException( "Client " + id + " is no longer registered" ); } else { throw new SampException( "No registered client with ID \"" + id + "\"" ); } } /** * Checks if a given send is permitted. Throws an exception if not. * * @param sender sending client * @param recipient receiving client * @param mtype MType * @throws SampException if the send is not permitted */ private void checkSend( HubClient sender, HubClient recipient, String mtype ) throws SampException { String errmsg = getSendError( sender, recipient, mtype ); if ( errmsg != null ) { throw new SampException( errmsg ); } } /** * Indicates whether a given send is permitted. * * @param sender sending client * @param recipient receiving client * @param mtype MType * @return true iff send OK */ private boolean canSend( HubClient sender, HubClient recipient, String mtype ) { return getSendError( sender, recipient, mtype ) == null; } /** * Does the work to determine whether a given sending client is * permitted to send a message with a given MType to a given recipient. * Returns null if allowed, a useful message if not. * Not intended for direct use, see {@link #canSend} and {@link #checkSend}. * * @param sender sending client * @param recipient receiving client * @param mtype MType * @return null if send OK, otherwise explanation message */ private String getSendError( HubClient sender, HubClient recipient, String mtype ) { if ( ! recipient.isCallable() ) { return "Client " + recipient + " is not callable"; } Subscriptions subs = recipient.getSubscriptions(); if ( ! subs.isSubscribed( mtype ) ) { return "Client " + recipient + " is not subscribed to " + mtype; } ProfileToken ptoken = sender.getProfileToken(); MessageRestriction mrestrict = ptoken.getMessageRestriction(); if ( mrestrict != null ) { Map subsInfo = subs.getSubscription( mtype ); if ( ! mrestrict.permitSend( mtype, subsInfo ) ) { return "MType " + mtype + " blocked from " + ptoken + " profile"; } } return null; } /** * Returns the view of a given subscriptions map to be presented to * a sending client. The result may be affected by any message * restrictions in force for the client. * * @param client client to view subscriptions * @param subs basic subscription map * @return view of subscription map for client */ private Subscriptions getSubscriptionsFor( HubClient client, Subscriptions subs ) { MessageRestriction mrestrict = client.getProfileToken().getMessageRestriction(); if ( mrestrict == null ) { return subs; } Subscriptions csubs = new Subscriptions(); for ( Iterator it = subs.entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); String mtype = (String) entry.getKey(); Map note = (Map) entry.getValue(); if ( mrestrict.permitSend( mtype, note ) ) { csubs.put( mtype, note ); } } return csubs; } /** * Encapsulates information about a MessageId. * A message ID can be represented as a string, but encodes information * which can be retrieved later. */ private static class MessageId { private final String senderId_; private final String senderTag_; private final boolean isSynch_; private final long birthday_; private static final String T_SYNCH_FLAG = "S"; private static final String F_SYNCH_FLAG = "A"; private static final int CHECK_SEED = (int) System.currentTimeMillis(); private static final int CHECK_LENG = 4; private static final Comparator AGE_COMPARATOR = new Comparator() { public int compare( Object o1, Object o2 ) { return (int) (((MessageId) o1).birthday_ - ((MessageId) o2).birthday_); } }; /** * Constructor. * * @param senderId client id of the message sender * @param senderTag msgTag provided by the sender * @param isSynch whether the message was sent synchronously or not */ public MessageId( String senderId, String senderTag, boolean isSynch ) { senderId_ = senderId; senderTag_ = senderTag; isSynch_ = isSynch; birthday_ = System.currentTimeMillis(); } /** * Returns the sender's public client id. * * @return sender's id */ public String getSenderId() { return senderId_; } /** * Returns the msgTag attached to the message by the sender. * * @return msgTag */ public String getSenderTag() { return senderTag_; } /** * Returns whether the message was sent synchronously. * * @return true iff message was sent using callAndWait */ public boolean isSynch() { return isSynch_; } public int hashCode() { return checksum( senderId_, senderTag_, isSynch_ ).hashCode(); } public boolean equals( Object o ) { if ( o instanceof MessageId ) { MessageId other = (MessageId) o; return this.senderId_.equals( other.senderId_ ) && this.senderTag_.equals( other.senderTag_ ) && this.isSynch_ == other.isSynch_; } else { return false; } } /** * Returns the string representation of this MessageId. * * @return message ID string */ public String toString() { Object checksum = checksum( senderId_, senderTag_, isSynch_ ); return new StringBuffer() .append( senderId_ ) .append( ID_DELIMITER ) .append( isSynch_ ? T_SYNCH_FLAG : F_SYNCH_FLAG ) .append( ID_DELIMITER ) .append( checksum ) .append( ID_DELIMITER ) .append( senderTag_ ) .toString(); } /** * Decodes a msgId string to return the corresponding MessageId object. * This is the opposite of the {@link #toString} method. * * @param msgId string representation of message ID * @return new MessageId object */ public static MessageId decode( String msgId ) throws SampException { int delim1 = msgId.indexOf( ID_DELIMITER ); int delim2 = msgId.indexOf( ID_DELIMITER, delim1 + 1 ); int delim3 = msgId.indexOf( ID_DELIMITER, delim2 + 1 ); if ( delim1 < 0 || delim2 < 0 || delim3 < 0 ) { throw new SampException( "Badly formed message ID " + msgId ); } String senderId = msgId.substring( 0, delim1 ); String synchFlag = msgId.substring( delim1 + 1, delim2 ); String checksum = msgId.substring( delim2 + 1, delim3 ); String senderTag = msgId.substring( delim3 + 1 ); boolean isSynch; if ( T_SYNCH_FLAG.equals( synchFlag ) ) { isSynch = true; } else if ( F_SYNCH_FLAG.equals( synchFlag ) ) { isSynch = false; } else { throw new SampException( "Badly formed message ID " + msgId + " (synch flag)" ); } if ( ! checksum( senderId, senderTag, isSynch ) .equals( checksum ) ) { throw new SampException( "Bad message ID checksum" ); } MessageId idObj = new MessageId( senderId, senderTag, isSynch ); assert idObj.toString().equals( msgId ); return idObj; } /** * Returns a message ID string corresponding to the arguments. * * @param sender sender client * @param senderTag msgTag attached by sender * @param isSynch whether message was sent synchronously * @return string representation of message ID */ public static String encode( HubClient sender, String senderTag, boolean isSynch ) { return new MessageId( sender.getId(), senderTag, isSynch ) .toString(); } /** * Returns a checksum string which is a hash of the given arguments. * * @param senderId public client id of sender * @param senderTag msgTag attached by sender * @param isSynch whether message was sent synchronously * @return checksum string */ private static String checksum( String senderId, String senderTag, boolean isSynch ) { int sum = CHECK_SEED; sum = 23 * sum + senderId.hashCode(); sum = 23 * sum + senderTag.hashCode(); sum = 23 * sum + ( isSynch ? 3 : 5 ); String check = Integer.toHexString( sum ); check = check.substring( Math.max( 0, check.length() - CHECK_LENG ) ); while ( check.length() < CHECK_LENG ) { check = "0" + check; } assert check.length() == CHECK_LENG; return check; } } /** * Generates client public IDs. * These must be unique, but don't need to be hard to guess. */ private static class ClientIdGenerator { private int iseq_; private final String prefix_; private final Comparator comparator_; /** * Constructor. * * @param prefix prefix for all generated ids */ public ClientIdGenerator( String prefix ) { prefix_ = prefix; // Prepare a comparator which will order the keys generated here // in sequence of generation. comparator_ = new Comparator() { public int compare( Object o1, Object o2 ) { String s1 = o1.toString(); String s2 = o2.toString(); Integer i1 = getIndex( s1 ); Integer i2 = getIndex( s2 ); if ( i1 == null && i2 == null ) { return s1.compareTo( s2 ); } else if ( i1 == null ) { return +1; } else if ( i2 == null ) { return -1; } else { return i1.intValue() - i2.intValue(); } } }; } /** * Returns the next unused id. * * @return next id */ public synchronized String next() { return prefix_ + Integer.toString( ++iseq_ ); } /** * Indicates whether a given client ID has previously been dispensed * by this object. * * @param id id to test * @return true iff id has been returned by a previous call of * next */ public boolean hasUsed( String id ) { Integer ix = getIndex( id ); return ix != null && ix.intValue() <= iseq_; } /** * Returns an Integer giving the sequence index of the given id string. * If id does not look like a string generated by this * object, null is returned. * * @param id identifier to test * @return object containing sequence index of id, * or null */ private Integer getIndex( String id ) { if ( id.startsWith( prefix_ ) ) { try { int iseq = Integer.parseInt( id.substring( prefix_.length() ) ); return new Integer( iseq ); } catch ( NumberFormatException e ) { return null; } } else { return null; } } /** * Returns a comparator which will order the IDs generated by this * object in generation sequence. * * @return id comparator */ public Comparator getComparator() { return comparator_; } } } jsamp/src/java/org/astrogrid/samp/hub/ConfigHubProfile.java0000664000175000017500000000124512730747754023555 0ustar sladensladenpackage org.astrogrid.samp.hub; import javax.swing.JToggleButton; /** * Marks a HubProfile that can also provide GUI-based configuration. * This is a bit of a hack, in that it's not very general; it is just * intended at present for the WebHubProfile and is rather specific to * its needs. This interface may change or disappear at some point * in the future. * * @author Mark Taylor * @since 22 Jul 2011 */ public interface ConfigHubProfile extends HubProfile { /** * Returns some toggle button models for hub profile configuration. * * @return toggle button model array */ JToggleButton.ToggleButtonModel[] getConfigModels(); } jsamp/src/java/org/astrogrid/samp/hub/LockWriter.java0000664000175000017500000000771712730747754022467 0ustar sladensladenpackage org.astrogrid.samp.hub; import java.io.File; import java.io.IOException; import java.io.OutputStream; import java.util.Iterator; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.astrogrid.samp.Platform; /** * Writes records to a SAMP Standard Profile hub lockfile. * * @author Mark Taylor * @since 15 Jul 2008 */ public class LockWriter { private final OutputStream out_; private static final byte[] linesep_ = getLineSeparator(); private static final String TOKEN_REGEX = "[a-zA-Z0-9\\-_\\.]+"; private static final Pattern TOKEN_PATTERN = Pattern.compile( TOKEN_REGEX ); /** * Constructs a writer for writing to a given output stream. * * @param out output stream */ public LockWriter( OutputStream out ) { out_ = out; } /** * Writes all the assignments in a given map to the lockfile. * * @param map assignment set to output */ public void writeAssignments( Map map ) throws IOException { for ( Iterator it = map.entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); writeAssignment( (String) entry.getKey(), (String) entry.getValue() ); } } /** * Writes a single assignment to the lockfile. * * @param name assignment key * @param value assignment value */ public void writeAssignment( String name, String value ) throws IOException { if ( TOKEN_PATTERN.matcher( name ).matches() ) { writeLine( name + "=" + value ); } else { throw new IllegalArgumentException( "Bad name sequence: " + name + " !~" + TOKEN_REGEX ); } } /** * Writes a comment line to the lockfile. * * @param comment comment text */ public void writeComment( String comment ) throws IOException { writeLine( "# " + comment ); } /** * Writes a blank line to the lockfile. */ public void writeLine() throws IOException { out_.write( linesep_ ); } /** * Writes a line of text to the lockfile, terminated with a line-end. * * @param line line to write */ protected void writeLine( String line ) throws IOException { byte[] bbuf = new byte[ line.length() ]; for ( int i = 0; i < line.length(); i++ ) { int c = line.charAt( i ); if ( c < 0x20 || c > 0x7f ) { throw new IllegalArgumentException( "Illegal character 0x" + Integer.toHexString( c ) ); } bbuf[ i ] = (byte) c; } out_.write( bbuf ); writeLine(); } /** * Closes the output stream. * May be required to ensure that all data is written. */ public void close() throws IOException { out_.close(); } /** * Sets the permissions on a given file suitably for a SAMP Standard * Profile lockfile. This means that nobody apart from the file's * owner can read it. * * @param file file to set access permissions on */ public static void setLockPermissions( File file ) throws IOException { Platform.getPlatform().setPrivateRead( file ); } /** * Returns the platform-specific line separator sequence as an array of * bytes. * * @return line separator sequence */ private static final byte[] getLineSeparator() { String linesep = System.getProperty( "line.separator" ); if ( linesep.matches( "[\\r\\n]+" ) ) { byte[] lsbuf = new byte[ linesep.length() ]; for ( int i = 0; i < linesep.length(); i++ ) { lsbuf[ i ] = (byte) linesep.charAt( i ); } return lsbuf; } else { return new byte[] { (byte) '\n' }; } } } jsamp/src/java/org/astrogrid/samp/hub/package.html0000664000175000017500000000017112730747754022003 0ustar sladensladen Classes required only for running a SAMP hub. For standard usage, see {@link org.astrogrid.samp.hub.Hub}. jsamp/src/java/org/astrogrid/samp/web/0000775000175000017500000000000012730747754017522 5ustar sladensladenjsamp/src/java/org/astrogrid/samp/web/WebHubProfileFactory.java0000664000175000017500000001451612730747754024421 0ustar sladensladenpackage org.astrogrid.samp.web; import java.io.IOException; import java.util.Iterator; import java.util.List; import java.util.logging.Level; import org.astrogrid.samp.hub.HubProfile; import org.astrogrid.samp.hub.HubProfileFactory; import org.astrogrid.samp.hub.KeyGenerator; import org.astrogrid.samp.hub.MessageRestriction; import org.astrogrid.samp.xmlrpc.internal.InternalServer; /** * HubProfileFactory implementation for Web Profile. * * @author Mark Taylor * @since 2 Feb 2011 */ public class WebHubProfileFactory implements HubProfileFactory { private static final String logUsage_ = "[-web:log none|http|xml|rpc]"; private static final String authUsage_ = "[-web:auth swing|true|false|extreme]"; private static final String corsUsage_ = "[-web:[no]cors]"; private static final String flashUsage_ = "[-web:[no]flash]"; private static final String silverlightUsage_ = "[-web:[no]silverlight]"; private static final String urlcontrolUsage_ = "[-web:[no]urlcontrol]"; private static final String restrictMtypeUsage_ = "[-web:[no]restrictmtypes]"; /** * Returns "web". */ public String getName() { return "web"; } public String[] getFlagsUsage() { return new String[] { logUsage_, authUsage_, corsUsage_, flashUsage_, silverlightUsage_, urlcontrolUsage_, restrictMtypeUsage_, }; } public HubProfile createHubProfile( List flagList ) throws IOException { // Process flags. String logType = "none"; String authType = "swing"; boolean useCors = true; boolean useFlash = true; boolean useSilverlight = false; boolean urlControl = true; boolean restrictMtypes = true; for ( Iterator it = flagList.iterator(); it.hasNext(); ) { String arg = (String) it.next(); if ( arg.equals( "-web:log" ) ) { it.remove(); if ( it.hasNext() ) { logType = (String) it.next(); it.remove(); } else { throw new IllegalArgumentException( "Usage: " + logUsage_ ); } } else if ( arg.equals( "-web:auth" ) ) { it.remove(); if ( it.hasNext() ) { authType = (String) it.next(); it.remove(); } else { throw new IllegalArgumentException( "Usage: " + authUsage_ ); } } else if ( arg.equals( "-web:cors" ) ) { it.remove(); useCors = true; } else if ( arg.equals( "-web:nocors" ) ) { it.remove(); useCors = false; } else if ( arg.equals( "-web:flash" ) ) { it.remove(); useFlash = true; } else if ( arg.equals( "-web:noflash" ) ) { it.remove(); useFlash = false; } else if ( arg.equals( "-web:silverlight" ) ) { it.remove(); useSilverlight = true; } else if ( arg.equals( "-web:nosilverlight" ) ) { it.remove(); useSilverlight = false; } else if ( arg.equals( "-web:urlcontrol" ) ) { it.remove(); urlControl = true; } else if ( arg.equals( "-web:nourlcontrol" ) ) { it.remove(); urlControl = false; } else if ( arg.equals( "-web:restrictmtypes" ) ) { it.remove(); restrictMtypes = true; } else if ( arg.equals( "-web:norestrictmtypes" ) ) { it.remove(); restrictMtypes = false; } } // Prepare HTTP server. WebHubProfile.ServerFactory sfact = new WebHubProfile.ServerFactory(); try { sfact.setLogType( logType ); } catch ( IllegalArgumentException e ) { throw (IllegalArgumentException) new IllegalArgumentException( "Unknown log type " + logType + "; Usage: " + logUsage_ ) .initCause( e ); } sfact.setOriginAuthorizer( useCors ? OriginAuthorizers.TRUE : OriginAuthorizers.FALSE ); sfact.setAllowFlash( useFlash ); sfact.setAllowSilverlight( useSilverlight ); // Prepare client authorizer. final ClientAuthorizer clientAuth; if ( "swing".equalsIgnoreCase( authType ) ) { clientAuth = ClientAuthorizers .createLoggingClientAuthorizer( new HubSwingClientAuthorizer( null ), Level.INFO, Level.INFO ); } else if ( "extreme".equalsIgnoreCase( authType ) ) { clientAuth = ClientAuthorizers .createLoggingClientAuthorizer( new ExtremeSwingClientAuthorizer( null ), Level.WARNING, Level.INFO ); } else if ( "true".equalsIgnoreCase( authType ) ) { clientAuth = ClientAuthorizers.TRUE; } else if ( "false".equalsIgnoreCase( authType ) ) { clientAuth = ClientAuthorizers.FALSE; } else { throw new IllegalArgumentException( "Unknown authorizer type " + authType + "; Usage: " + authUsage_ ); } // Prepare subscriptions mask. MessageRestriction mrestrict = restrictMtypes ? ListMessageRestriction.DEFAULT : null; // Construct and return an appropriately configured hub profile. return new WebHubProfile( sfact, clientAuth, mrestrict, WebHubProfile.createKeyGenerator(), urlControl ); } public Class getHubProfileClass() { return WebHubProfile.class; } } jsamp/src/java/org/astrogrid/samp/web/WebClientProfile.java0000664000175000017500000001376712730747754023600 0ustar sladensladenpackage org.astrogrid.samp.web; import java.io.IOException; import java.net.ConnectException; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; import java.util.HashMap; import java.util.Map; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.Platform; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.DefaultClientProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.xmlrpc.SampXmlRpcClient; import org.astrogrid.samp.xmlrpc.SampXmlRpcClientFactory; import org.astrogrid.samp.xmlrpc.XmlRpcKit; /** * ClientProfile implementation for Web Profile. * * @author Mark Taylor * @since 3 Feb 2011 */ public class WebClientProfile implements ClientProfile { private final SampXmlRpcClientFactory xClientFactory_; private final Map securityMap_; private final URL hubEndpoint_; /** Web Profile hub port number ({@value}). */ public static final int WEBSAMP_PORT = 21012; /** * Path on WEBSAMP_PORT web server at which XML-RPC server lives * ({@value}). */ public static final String WEBSAMP_PATH = "/"; /** * Prefix to hub interface operation names for XML-RPC method names * ({@value}). */ public static final String WEBSAMP_HUB_PREFIX = "samp.webhub."; /** * Prefix to client interface opeation names for XML-RPC method names * ({@value}). */ public static final String WEBSAMP_CLIENT_PREFIX = ""; /** * RegInfo map key for URL translation service base URL * ({@value}). */ public static final String URLTRANS_KEY = "samp.url-translator"; /** * Prefix in SAMP_HUB value indicating web profile application name * ({@value}). */ public static final String WEBPROFILE_HUB_PREFIX = "web-appname:"; /** * Constructor with configuration options. * * @param securityMap map containing security information for registration * @param xClientFactory XML-RPC client factory * @param hubEndpoint XML-RPC endpoint for hub server */ public WebClientProfile( Map securityMap, SampXmlRpcClientFactory xClientFactory, URL hubEndpoint ) { securityMap_ = securityMap; xClientFactory_ = xClientFactory; hubEndpoint_ = hubEndpoint; } /** * Constructor with declared client name. * * @param appName client's declared application name * (samp.name entry in security-info map) */ public WebClientProfile( String appName ) { this( createSecurityMap( appName ), XmlRpcKit.getInstance().getClientFactory(), getDefaultHubEndpoint() ); } /** * Constructor with no arguments. The client's declared application * name will be as given by {@link #getDefaultAppName}. */ public WebClientProfile() { this( getDefaultAppName() ); } public boolean isHubRunning() { try { SampXmlRpcClient xClient = xClientFactory_.createClient( hubEndpoint_ ); xClient.callAndWait( WEBSAMP_HUB_PREFIX + "ping", new ArrayList() ); return true; } catch ( IOException e ) { return false; } } public HubConnection register() throws SampException { try { return new WebHubConnection( xClientFactory_ .createClient( hubEndpoint_ ), securityMap_ ); } catch ( SampException e ) { for ( Throwable ex = e; ex != null; ex = ex.getCause() ) { if ( ex instanceof ConnectException ) { return null; } } throw e; } catch ( ConnectException e ) { return null; } catch ( IOException e ) { throw new SampException( e ); } } /** * Returns the hub XML-RPC endpoint used by this profile. * * @return hub endpoint URL */ public URL getHubEndpoint() { return hubEndpoint_; } /** * Returns the hub XML-RPC endpoint defined by the Web Profile. * * @return Web Profile hub endpoint URL */ public static URL getDefaultHubEndpoint() { String surl = "http://" + SampUtils.getLocalhost() + ":" + WEBSAMP_PORT + WEBSAMP_PATH; try { return new URL( surl ); } catch ( MalformedURLException e ) { throw new AssertionError( "http scheme not supported?? " + surl ); } } /** * Returns a default instance of this profile. * * @return default web client profile instance */ public static WebClientProfile getInstance() { return new WebClientProfile(); } /** * Returns the default application name used by this profile if none * is supplied explicitly. * If the SAMP_HUB environment variable has the form * "web-appname:<appname>" it is taken from there; * otherwise it's something like "Unknown". * * @return default declared client name */ public static String getDefaultAppName() { String hubloc = Platform.getPlatform() .getEnv( DefaultClientProfile.HUBLOC_ENV ); return hubloc != null && hubloc.startsWith( WEBPROFILE_HUB_PREFIX ) ? hubloc.substring( WEBPROFILE_HUB_PREFIX.length() ) : "Unknown Application"; } /** * Constructs a security-info map suitable for presentation at * registration time, containing the mandatory samp.name entry. * * @param appName samp.name entry * @return security map */ private static Map createSecurityMap( String appName ) { Map map = new HashMap(); map.put( Metadata.NAME_KEY, appName ); return map; } } jsamp/src/java/org/astrogrid/samp/web/CorsHttpServer.java0000664000175000017500000002725712730747754023337 0ustar sladensladenpackage org.astrogrid.samp.web; import java.io.IOException; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.SocketAddress; import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.logging.Logger; import java.util.regex.Pattern; import org.astrogrid.samp.httpd.HttpServer; /** * HttpServer which allows or rejects cross-origin access according to * the W3C Cross-Origin Resource Sharing standard. * This standard is used by XMLHttpResource Level 2 and some other * web-based platforms, implemented by a number of modern browsers, * and works by the browser inserting and interpreting special headers * when cross-origin requests are made by sandboxed clients. * The effect is that sandboxed clients will under some circumstances * be permitted to access resources served by instances of this server, * where they wouldn't for an HTTP server which did not take special * measures. * * @author Mark Taylor * @since 2 Feb 2011 * @see Cross-Origin Resource Sharing W3C Standard */ public class CorsHttpServer extends HttpServer { private final OriginAuthorizer authorizer_; private static final String ORIGIN_KEY = "Origin"; private static final String ALLOW_ORIGIN_KEY = "Access-Control-Allow-Origin"; private static final String REQUEST_METHOD_KEY = "Access-Control-Request-Method"; private static final String ALLOW_METHOD_KEY = "Access-Control-Allow-Methods"; private static final String ALLOW_HEADERS_KEY = "Access-Control-Allow-Headers"; private static final Pattern ORIGIN_REGEX = Pattern.compile( "https?://[a-zA-Z0-9_-]+" + "(\\.[a-zA-Z0-9_-]+)*(:[0-9]+)?" ); private static final Logger logger_ = Logger.getLogger( CorsHttpServer.class.getName() ); /** * System property ({@value}) which can be used to supply host addresses * explicitly permitted to connect via the Web Profile alongside * the local host. * Normally any non-local host is blocked from access to the CORS * web server for security reasons. However, any host specified * by hostname or IP number as one element of a comma-separated * list in the value of this system property will also be allowed. * This might be used to allow access from a "friendly" near-local * host like a tablet. */ public static final String EXTRAHOSTS_PROP = "jsamp.web.extrahosts"; /** Set of permitted InetAddrs along side localhost. */ private static final Set extraAddrSet_ = new HashSet( Arrays.asList( getExtraHostAddresses() ) ); /** * Constructor. * * @param socket socket hosting the service * @param authorizer defines which domains requests will be * permitted from */ public CorsHttpServer( ServerSocket socket, OriginAuthorizer authorizer ) throws IOException { super( socket ); authorizer_ = authorizer; } public Response serve( Request request ) { if ( ! isPermittedHost( request.getRemoteAddress() ) ) { return createNonLocalErrorResponse( request ); } Map hdrMap = request.getHeaderMap(); String method = request.getMethod(); String originTxt = getHeader( hdrMap, ORIGIN_KEY ); if ( originTxt != null ) { String reqMethod = getHeader( hdrMap, REQUEST_METHOD_KEY ); if ( method.equals( "OPTIONS" ) && reqMethod != null ) { return servePreflightOriginRequest( request, originTxt, reqMethod ); } else { return serveSimpleOriginRequest( request, originTxt ); } } else { return super.serve( request ); } } /** * Does the work for serving simple requests which bear an * origin header. Simple requests are effectively ones which do not * require pre-flight requests - see the CORS standard for details. * * @param request HTTP request * @param originTxt content of the Origin header * @return HTTP response */ private Response serveSimpleOriginRequest( Request request, String originTxt ) { Response response = super.serve( request ); if ( isAuthorized( originTxt ) ) { Map headerMap = response.getHeaderMap(); if ( getHeader( headerMap, ALLOW_ORIGIN_KEY ) == null ) { headerMap.put( ALLOW_ORIGIN_KEY, originTxt ); } } return response; } /** * Does the work for serving pre-flight requests. * See the CORS standard for details. * * @param request HTTP request * @param originTxt content of the Origin header * @param reqMethod content of the Access-Control-Request-Method header * @return HTTP response */ private Response servePreflightOriginRequest( Request request, String originTxt, String reqMethod ) { Map hdrMap = new LinkedHashMap(); hdrMap.put( "Content-Length", "0" ); if ( isAuthorized( originTxt ) ) { hdrMap.put( ALLOW_ORIGIN_KEY, originTxt ); hdrMap.put( ALLOW_METHOD_KEY, reqMethod ); hdrMap.put( ALLOW_HEADERS_KEY, "Content-Type" ); // allow all here? } return new Response( 200, "OK", hdrMap ) { public void writeBody( OutputStream out ) { } }; } private Response createNonLocalErrorResponse( Request request ) { int status = 403; String msg = "Forbidden"; String method = request.getMethod(); if ( "HEAD".equals( method ) ) { return createErrorResponse( status, msg ); } else { Map hdrMap = new LinkedHashMap(); hdrMap.put( HDR_CONTENT_TYPE, "text/plain" ); byte[] mbuf; try { mbuf = ( "Access to server from non-local hosts " + "is not permitted.\r\n" ) .getBytes( "UTF-8" ); } catch ( UnsupportedEncodingException e ) { logger_.warning( "Unsupported UTF-8??" ); mbuf = new byte[ 0 ]; } final byte[] mbuf1 = mbuf; hdrMap.put( "Content-Length", Integer.toString( mbuf1.length ) ); return new Response( status, msg, hdrMap ) { public void writeBody( OutputStream out ) throws IOException { out.write( mbuf1 ); out.flush(); } }; } } /** * Determines whether a given origin is permitted access. * This is done by interrogating this server's OriginAuthorizer policy. * Results are cached. * * @param originTxt content of Origin header */ private boolean isAuthorized( String originTxt ) { // CORS sec 5.1 says multiple space-separated origins may be present // - but why?? Treat the string as a single origin for now. // Not incorrect, though possibly annoying if the same origin // crops up multiple times in different sets (unlikely as far // as I can see). boolean hasLegalOrigin; try { checkOriginList( originTxt ); hasLegalOrigin = true; } catch ( RuntimeException e ) { logger_.warning( "Origin header: " + e.getMessage() ); hasLegalOrigin = false; } return hasLegalOrigin && authorizer_.authorize( originTxt ); } /** * Indicates whether a network address is known to represent * a host permitted to access this server. * That generally means the local host, but "extra" hosts may be * permitted as well. * * @param address socket address * @return true iff address is known to be permitted */ public boolean isPermittedHost( SocketAddress address ) { if ( address instanceof InetSocketAddress ) { InetAddress iAddress = ((InetSocketAddress) address).getAddress(); if ( iAddress == null ) { return false; } else if ( iAddress.isLoopbackAddress() ) { return true; } else if ( isExtraHost( iAddress ) ) { return true; } else { try { return iAddress.equals( InetAddress.getLocalHost() ); } catch ( UnknownHostException e ) { return false; } } } else { logger_.warning( "Socket address not from internet? " + address ); return false; } } /** * Acquires and returns a list of permitted non-local hosts from the * environment. * * @return list of addresses for non-local hosts permitted to access * CORS web servers in this JVM */ private static InetAddress[] getExtraHostAddresses() { String list; try { list = System.getProperty( EXTRAHOSTS_PROP ); } catch ( SecurityException e ) { list = null; } String[] names; if ( list != null ) { list = list.trim(); names = list.length() > 0 ? list.split( ", *" ) : new String[ 0 ]; } else { names = new String[ 0 ]; } int naddr = names.length; List addrList = new ArrayList(); for ( int i = 0; i < naddr; i++ ) { String name = names[ i ]; try { addrList.add( InetAddress.getByName( name ) ); logger_.warning( "Adding web hub exception for host " + "\"" + name + "\"" ); } catch ( UnknownHostException e ) { logger_.warning( "Unknown host \"" + name + "\"" + " - not adding web hub exception" ); } } return (InetAddress[]) addrList.toArray( new InetAddress[ 0 ] ); } /** * Indicates whether a given address represents one of the "extra" hosts * permitted to access this server alongside the localhost. * * @param iaddr address of non-local host to test * @return true iff host is permitted to access this server */ private static boolean isExtraHost( InetAddress iaddr ) { return extraAddrSet_.contains( iaddr ); } /** * Checks that the content of an Origin header is syntactically legal. * * @param originTxt content of Origin header * @throws IllegalArgumentExeption if originTxt does not represent * a legal origin or (non-empty) list of origins */ private static void checkOriginList( String originTxt ) { String[] origins = originTxt.split( " +" ); if ( origins.length > 0 ) { for ( int i = 0; i < origins.length; i++ ) { if ( ! ORIGIN_REGEX.matcher( origins[ i ] ).matches() ) { throw new IllegalArgumentException( "Bad origin syntax: \"" + origins[ i ] + "\"" ); } } } else { throw new IllegalArgumentException( "No origins supplied" ); } } } jsamp/src/java/org/astrogrid/samp/web/SwingOriginAuthorizer.java0000664000175000017500000000310512730747754024700 0ustar sladensladenpackage org.astrogrid.samp.web; import java.awt.Component; import javax.swing.JOptionPane; /** * OriginAuthorizer which uses a popup dialogue to ask the user. * * @author Mark Taylor * @since 2 Feb 2011 */ class SwingOriginAuthorizer implements OriginAuthorizer { private final Component parent_; /** * Constructor. * * @param parent parent component */ public SwingOriginAuthorizer( Component parent ) { parent_ = parent; } public boolean authorize( String origin ) { return getResponse( new String[] { "Is the following origin authorized for cross-domain HTTP access?", " " + origin, } ); } public boolean authorizeAll() { return getResponse( new String[] { "Are all origins authorized for cross-domain HTTP access?", } ); } /** * Presents some lines of text to the user and solicits a yes/no * response from them. * This method does not need to be called from the AWT event dispatch * thread. * * @param lines lines of formatted plain text * (not too many; not too long) * @return true/false for use yes/no response */ protected boolean getResponse( String[] lines ) { return JOptionPane .showOptionDialog( parent_, lines, "Security", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE, null, new String[] { "Yes", "No" }, "No" ) == 0; } } jsamp/src/java/org/astrogrid/samp/web/OriginAuthorizers.java0000664000175000017500000001127612730747754024063 0ustar sladensladenpackage org.astrogrid.samp.web; import java.util.HashSet; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; /** * Utility class containing OriginAuthorizer implementations. * * @author Mark Taylor * @since 2 Feb 2011 */ public class OriginAuthorizers { /** OriginAuthorizer which always denies access. */ public static final OriginAuthorizer FALSE = createFixedOriginAuthorizer( false, false ); /** OriginAuthorizer which always permits access. */ public static final OriginAuthorizer TRUE = createFixedOriginAuthorizer( true, true ); /** OriginAuthorizer which queries the user via a popup dialogue. */ public static final OriginAuthorizer SWING = createMemoryOriginAuthorizer( createLoggingOriginAuthorizer( new SwingOriginAuthorizer( null ), Level.INFO, Level.WARNING ) ); private static final Logger logger_ = Logger.getLogger( OriginAuthorizers.class.getName() ); /** * Private constructor prevents instantiation. */ private OriginAuthorizers() { } /** * Returns an OriginAuthorizer with fixed responses, regardless of input. * * @param individualPolicy invariable response of * authorize method * @param generalPolicy invariable response of * authorizeAll method */ public static OriginAuthorizer createFixedOriginAuthorizer( final boolean individualPolicy, final boolean generalPolicy ) { return new OriginAuthorizer() { public boolean authorize( String origin ) { return individualPolicy; } public boolean authorizeAll() { return generalPolicy; } }; } /** * Returns an OriginAuthorizer based on an existing one which logs * responses. * * @param auth base authorizer * @param acceptLevel level at which acceptances will be logged * @param refuseLevel level at which refusals will be logged */ public static OriginAuthorizer createLoggingOriginAuthorizer( final OriginAuthorizer auth, final Level acceptLevel, final Level refuseLevel ) { return new OriginAuthorizer() { public synchronized boolean authorize( String origin ) { boolean accept = auth.authorize( origin ); log( accept, "\"" + origin + "\"" ); return accept; } public synchronized boolean authorizeAll() { boolean accept = auth.authorizeAll(); log( accept, "all origins" ); return accept; } private void log( boolean accept, String domain ) { if ( accept ) { logger_.log( acceptLevel, "Accepted cross-origin requests for " + domain ); } else { logger_.log( refuseLevel, "Rejected cross-origin requests for " + domain ); } } }; } /** * Returns an OriginAuthorizer based on an existing one which caches * responses. * * @param auth base authorizer */ public static OriginAuthorizer createMemoryOriginAuthorizer( final OriginAuthorizer auth ) { return new OriginAuthorizer() { private final OriginAuthorizer baseAuth_ = auth; private final Set acceptedSet_ = new HashSet(); private final Set refusedSet_ = new HashSet(); private Boolean authorizeAll_; public synchronized boolean authorize( String origin ) { if ( refusedSet_.contains( origin ) ) { return false; } else if ( acceptedSet_.contains( origin ) ) { return true; } else { boolean accepted = baseAuth_.authorize( origin ); ( accepted ? acceptedSet_ : refusedSet_ ).add( origin ); return accepted; } } public synchronized boolean authorizeAll() { if ( authorizeAll_ == null ) { authorizeAll_ = Boolean.valueOf( baseAuth_.authorizeAll() ); } return authorizeAll_.booleanValue(); } }; } } jsamp/src/java/org/astrogrid/samp/web/WebHubConnection.java0000664000175000017500000001416112730747754023564 0ustar sladensladenpackage org.astrogrid.samp.web; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.xmlrpc.SampXmlRpcClient; import org.astrogrid.samp.xmlrpc.XmlRpcHubConnection; /** * HubConnection implementation for the Web Profile. * * @author Mark Taylor * @since 3 Feb 2011 */ class WebHubConnection extends XmlRpcHubConnection { private final String appName_; private final String clientKey_; private CallWorker callWorker_; private static Logger logger_ = Logger.getLogger( WebHubConnection.class.getName() ); /** * Constructor. * * @param xClient XML-RPC client * @param securityMap security information map * @param appName client's declared name */ public WebHubConnection( SampXmlRpcClient xClient, Map securityMap ) throws SampException { super( xClient, WebClientProfile.WEBSAMP_HUB_PREFIX, Collections.singletonList( securityMap ) ); Object nameObj = securityMap.get( Metadata.NAME_KEY ); appName_ = nameObj instanceof String ? (String) nameObj : "??"; clientKey_ = getRegInfo().getPrivateKey(); } public Object getClientKey() { return clientKey_; } public void setCallable( CallableClient client ) throws SampException { CallWorker oldWorker = callWorker_; callWorker_ = null; if ( oldWorker != null ) { oldWorker.stopped_ = true; } exec( "allowReverseCallbacks", new Object[] { SampUtils.encodeBoolean( client != null ) } ); if ( client != null ) { CallWorker callWorker = new CallWorker( this, client, appName_ ); callWorker.start(); callWorker_ = callWorker; } } /** * Thread that performs repeated long polls to pull callbacks from the * hub and passes them on to this connection's CallableClient for * execution. */ private static class CallWorker extends Thread { private final XmlRpcHubConnection xconn_; private final CallableClient client_; private final int timeoutSec_ = 60 * 10; private final long minWaitMillis_ = 5 * 1000; private volatile boolean stopped_; /** * Constructor. * * @param xconn hub connection * @parma client callable client * @param appName client's name */ CallWorker( XmlRpcHubConnection xconn, CallableClient client, String appName ) { super( "Web Profile Callback Puller for " + appName ); xconn_ = xconn; client_ = client; setDaemon( true ); } public void run() { String stimeout = SampUtils.encodeInt( timeoutSec_ ); while ( true && ! stopped_ ) { long start = System.currentTimeMillis(); Object result; try { result = xconn_.exec( "pullCallbacks", new Object[] { stimeout } ); } catch ( Exception e ) { long wait = System.currentTimeMillis() - start; if ( wait < minWaitMillis_ ) { seriousError( e ); } else { logger_.config( "pullCallbacks timeout? " + ( wait / 1000 ) + "s" ); } break; } catch ( Throwable e ) { seriousError( e ); break; } if ( ! stopped_ ) { if ( result instanceof List ) { List resultList = (List) result; for ( Iterator it = resultList.iterator(); it.hasNext(); ) { try { final Callback cb = new Callback( (Map) it.next() ); new Thread( "Web Profile Callback" ) { public void run() { try { ClientCallbackOperation .invoke( cb, client_ ); } catch ( Throwable e ) { logger_.log( Level.WARNING, "Callback failure: " + e.getMessage(), e ); } } }.start(); } catch ( Throwable e ) { logger_.log( Level.WARNING, e.getMessage(), e ); } } } else { logger_.warning( "pullCallbacks result " + "is not a List - ignore" ); } } } } /** * Invoked if there is a serious (non-timeout) error when polling * for callbacks. This currently stops the polling for good. * That may be a drastic response, but at least it prevents * repeated high-frequency polling attempts to a broken server, * which might otherwise result. * * @parm e error which caused the trouble */ private void seriousError( Throwable e ) { stopped_ = true; logger_.log( Level.WARNING, "Fatal pullCallbacks error - stopped listening", e ); } } } jsamp/src/java/org/astrogrid/samp/web/Callback.java0000664000175000017500000000515012730747754022062 0ustar sladensladenpackage org.astrogrid.samp.web; import java.util.List; import java.util.Map; import org.astrogrid.samp.SampMap; import org.astrogrid.samp.SampUtils; /** * Map representing a client callback from the hub. * It normally contains a callback method name and a list of parameters. * An instance of this class can be used to correspond to one of the calls * in the {@link org.astrogrid.samp.client.CallableClient} interface. * * @author Mark Taylor * @since 2 Feb 2011 */ class Callback extends SampMap { /** Key for the callback method name (a string). */ public static final String METHODNAME_KEY = "samp.methodName"; /** Key for the callback parameters (a list). */ public static final String PARAMS_KEY = "samp.params"; private static final String[] KNOWN_KEYS = new String[] { METHODNAME_KEY, PARAMS_KEY, }; /** * Constructs an empty callback. */ public Callback() { super( KNOWN_KEYS ); } /** * Constructs a callback based on an existing map. * * @param map contents */ public Callback( Map map ) { this(); putAll( map ); } /** * Constructs a callback given a method name and parameter list. */ public Callback( String methodName, List params ) { this(); setMethodName( methodName ); setParams( params ); } /** * Sets the method name. * * @param methodName method name */ public void setMethodName( String methodName ) { put( METHODNAME_KEY, methodName ); } /** * Returns the method name. * * @return method name */ public String getMethodName() { return getString( METHODNAME_KEY ); } /** * Sets the parameter list. * * @param params parameter list */ public void setParams( List params ) { SampUtils.checkList( params ); put( PARAMS_KEY, params ); } /** * Returns the parameter list. * * @return parameter list */ public List getParams() { return getList( PARAMS_KEY ); } public void check() { super.check(); checkHasKeys( new String[] { METHODNAME_KEY, PARAMS_KEY, } ); SampUtils.checkString( getMethodName() ); SampUtils.checkList( getParams() ); } /** * Returns a given map as a Callback object. * * @param map map * @return callback */ public static Callback asCallback( Map map ) { return ( map instanceof Callback || map == null ) ? (Callback) map : new Callback( map ); } } jsamp/src/java/org/astrogrid/samp/web/ExtremeSwingClientAuthorizer.java0000664000175000017500000000662212730747754026230 0ustar sladensladenpackage org.astrogrid.samp.web; import java.awt.Color; import java.awt.Component; import java.awt.Font; import java.awt.GraphicsEnvironment; import java.awt.HeadlessException; import java.net.URL; import javax.swing.BorderFactory; import javax.swing.Box; import javax.swing.ImageIcon; import javax.swing.JComponent; import javax.swing.JLabel; import javax.swing.JOptionPane; import javax.swing.border.Border; import org.astrogrid.samp.Client; import org.astrogrid.samp.gui.IconStore; import org.astrogrid.samp.httpd.HttpServer; /** * Client authorizer implementaion that does its very best to discourage * users from accepting regitrations. * * @author Mark Taylor * @since 29 Sep 2011 */ public class ExtremeSwingClientAuthorizer implements ClientAuthorizer { private final Component parent_; /** * Constructor. * * @param parent parent component, may be null */ public ExtremeSwingClientAuthorizer( Component parent ) { parent_ = parent; if ( GraphicsEnvironment.isHeadless() ) { throw new HeadlessException( "No graphics - lucky escape" ); } } public boolean authorize( HttpServer.Request request, String appName ) { JComponent panel = Box.createVerticalBox(); JComponent linePic = Box.createHorizontalBox(); URL imageUrl = Client.class.getResource( "images/danger.jpeg" ); linePic.add( Box.createHorizontalGlue() ); linePic.add( new JLabel( new ImageIcon( imageUrl ) ) ); linePic.add( Box.createHorizontalGlue() ); panel.add( linePic ); panel.add( Box.createVerticalStrut( 5 ) ); JComponent line1 = Box.createHorizontalBox(); line1.add( new JLabel( "Client \"" + appName + "\" is requesting Web Profile registration." ) ); line1.add( Box.createHorizontalGlue() ); line1.setBorder( createBorder( false ) ); panel.add( line1 ); JLabel deathLabel = new JLabel( "CERTAIN DEATH" ); deathLabel.setForeground( Color.RED ); deathLabel.setFont( deathLabel.getFont() .deriveFont( deathLabel.getFont().getSize() + 2f ) ); JComponent line2 = Box.createHorizontalBox(); line2.add( new JLabel( "Accepting this request will lead to " ) ); line2.add( deathLabel ); line2.add( new JLabel( "!" ) ); line2.add( Box.createHorizontalGlue() ); line2.setBorder( createBorder( true ) ); panel.add( line2 ); return JOptionPane .showOptionDialog( parent_, panel, "Registration Request", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE, IconStore.createEmptyIcon( 0 ), new String[] { "Accept", "Reject" }, "Reject" ) == 0; } /** * Returns a new border of fixed dimensions which may or may not include * an element of highlighting. * * @param highlight true to highlight border * @return new border */ private Border createBorder( boolean highlight ) { Color color = new Color( 0x00ff0000, ! highlight ); return BorderFactory.createCompoundBorder( BorderFactory.createLineBorder( new Color( 0xff0000, !highlight ) ), BorderFactory.createEmptyBorder( 2, 2, 2, 2 ) ); } } jsamp/src/java/org/astrogrid/samp/web/HubSwingClientAuthorizer.java0000664000175000017500000002362012730747754025332 0ustar sladensladenpackage org.astrogrid.samp.web; import java.awt.Component; import java.awt.Dimension; import java.awt.GraphicsEnvironment; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; import java.awt.HeadlessException; import java.awt.Insets; import java.awt.Window; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.ResourceBundle; import java.util.logging.Logger; import javax.swing.BorderFactory; import javax.swing.Box; import javax.swing.JComponent; import javax.swing.JDialog; import javax.swing.JLabel; import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JTextField; import org.astrogrid.samp.httpd.HttpServer; /** * ClientAuthorizer implementation that queries the user for permission * via a popup dialogue. * * @author Mark Taylor * @since 2 Feb 2011 */ public class HubSwingClientAuthorizer implements ClientAuthorizer { private final Component parent_; private static final int MAX_POPUP_WIDTH = 500; private static final Logger logger_ = Logger.getLogger( HubSwingClientAuthorizer.class.getName() ); /** * Constructor. * * @param parent parent component */ public HubSwingClientAuthorizer( Component parent ) { parent_ = parent; if ( GraphicsEnvironment.isHeadless() ) { throw new HeadlessException( "Client authorization dialogues " + "impossible - no graphics" ); } } public boolean authorize( HttpServer.Request request, String appName ) { // Prepare an internationalised query dialogue. AuthResourceBundle.Content authContent = AuthResourceBundle .getAuthContent( ResourceBundle .getBundle( AuthResourceBundle.class.getName() ) ); Object[] qmsg = getMessageLines( request, appName, authContent ); String noOpt = authContent.noWord(); String yesOpt = authContent.yesWord(); // Just calling showOptionDialog can end up with the popup being // obscured by other windows on the desktop, at least for win XP. JOptionPane jop = new JOptionPane( qmsg, JOptionPane.WARNING_MESSAGE, JOptionPane.YES_NO_OPTION, null, new String[] { noOpt, yesOpt }, noOpt ); JDialog dialog = jop.createDialog( parent_, authContent.windowTitle() ); attemptSetAlwaysOnTop( dialog, true ); dialog.setModal( true ); dialog.setDefaultCloseOperation( JDialog.DISPOSE_ON_CLOSE ); // It seems to be OK to call Dialog.setVisible on a modal dialogue // from threads other than the AWT Event Dispatch Thread. // I admit though that I haven't seen document which assures that // this is true however. dialog.setVisible( true ); dialog.dispose(); return jop.getValue() == yesOpt; } /** * Returns a "message" object describing the applying client to the user. * The return value is suitable for use as the msg argument * of one of JOptionPane's methods. * * @param request HTTP request bearing the application * @param appName application name claimed by the applicant * @param authContent content of AuthResourceBundle bundle * @return message array describing the applicant to the user * @see javax.swing.JOptionPane */ private Object[] getMessageLines( HttpServer.Request request, String appName, AuthResourceBundle.Content authContent ) { Map headerMap = request.getHeaderMap(); // Application origin (see http://www.w3.org/TR/cors/, // http://tools.ietf.org/html/draft-abarth-origin); // present if CORS is in use. String origin = HttpServer.getHeader( headerMap, "Origin" ); // Referer header (RFC2616 sec 14.36) - present at whim of browser. String referer = HttpServer.getHeader( headerMap, "Referer" ); List lineList = new ArrayList(); lineList.addAll( toLineList( authContent.appIntroductionLines() ) ); lineList.add( "\n" ); Map infoMap = new LinkedHashMap(); infoMap.put( authContent.nameWord(), appName ); infoMap.put( authContent.originWord(), origin ); infoMap.put( "URL", referer ); lineList.add( createLabelledFields( infoMap, authContent.undeclaredWord() ) ); lineList.add( "\n" ); if ( referer != null && origin != null && ! origin.equals( getOrigin( referer ) ) ) { logger_.warning( "Origin/Referer header mismatch: " + "\"" + origin + "\" != " + "\"" + getOrigin( referer ) + "\"" ); lineList.add( "WARNING: Origin/Referer header mismatch!" ); lineList.add( "WARNING: This looks suspicious." ); lineList.add( "\n" ); } lineList.addAll( toLineList( authContent.privilegeWarningLines() ) ); lineList.add( "\n" ); lineList.addAll( toLineList( authContent.adviceLines() ) ); lineList.add( "\n" ); lineList.add( authContent.questionLine() ); return lineList.toArray(); } /** * Returns a component displaying name/value pairs represented by * a given String->String map. * * @param infoMap String->String map of key->value pairs * @param undeclaredWord text to use to indicate a null value * @return display component */ private JComponent createLabelledFields( Map infoMap, String undeclaredWord ) { GridBagLayout layer = new GridBagLayout(); JComponent box = new JPanel( layer ) { public Dimension getPreferredSize() { Dimension size = super.getPreferredSize(); return new Dimension( Math.min( size.width, MAX_POPUP_WIDTH ), size.height ); } }; GridBagConstraints keyCons = new GridBagConstraints(); GridBagConstraints valCons = new GridBagConstraints(); keyCons.gridy = 0; valCons.gridy = 0; keyCons.gridx = 0; valCons.gridx = 1; keyCons.anchor = GridBagConstraints.WEST; valCons.anchor = GridBagConstraints.WEST; keyCons.fill = GridBagConstraints.NONE; valCons.fill = GridBagConstraints.HORIZONTAL; keyCons.weighty = 1; valCons.weighty = 1; keyCons.weightx = 0; valCons.weightx = 1; valCons.insets = new Insets( 1, 1, 1, 1 ); JComponent stack = Box.createVerticalBox(); for ( Iterator it = infoMap.keySet().iterator(); it.hasNext(); ) { String key = (String) it.next(); String value = (String) infoMap.get( key ); String valtxt = value == null ? undeclaredWord : value; JComponent keyComp = new JLabel( key + ": " ); JTextField valueField = new JTextField( valtxt ); valueField.setEditable( false ); layer.setConstraints( keyComp, keyCons ); layer.setConstraints( valueField, valCons ); box.add( keyComp ); box.add( valueField ); keyCons.gridy++; valCons.gridy++; } box.setBorder( BorderFactory.createEmptyBorder( 0, 0, 0, 0 ) ); return box; } /** * Returns the serialized origin for a given URI string. * @see The Web Origin Concept * * @param uri URI * @return origin of uri, * null (note: not "null") if it cannot be determined */ private String getOrigin( String uri ) { if ( uri == null ) { return null; } URL url; try { url = new URL( uri ); } catch ( MalformedURLException e ) { return null; } String scheme = url.getProtocol(); String host = url.getHost(); int portnum = url.getPort(); StringBuffer sbuf = new StringBuffer() .append( scheme ) .append( "://" ) .append( host ); if ( portnum >= 0 && portnum != url.getDefaultPort() ) { sbuf.append( ":" ) .append( Integer.toString( portnum ) ); } return sbuf.toString().toLowerCase(); } /** * Turns a multi-line string into an array of strings. * * @param linesTxt string perhaps with embedded \n characters * @return array of lines */ private static String[] toLines( String linesTxt ) { return linesTxt.split( "\\n" ); } /** * Turns a multi-line string into a List of strings. * * @param linesTxt string perhaps with embedded \n characters * @return list of String lines */ private static List toLineList( String linesTxt ) { return Arrays.asList( toLines( linesTxt ) ); } /** * Tries to set the always-on-top property of a window. * This is only possible in JRE1.5 and later, so it's done here by * reflection. If it fails, a logging message is emitted. * * @param win window to set * @param isOnTop true for on top, false for not */ private static void attemptSetAlwaysOnTop( Window win, boolean isOnTop ) { try { Window.class.getMethod( "setAlwaysOnTop", new Class[] { boolean.class } ) .invoke( win, new Object[] { Boolean.valueOf( isOnTop ) } ); } catch ( Throwable e ) { logger_.info( "Can't set window on top, not J2SE5" ); } } } jsamp/src/java/org/astrogrid/samp/web/WebHubXmlRpcHandler.java0000664000175000017500000004400312730747754024166 0ustar sladensladenpackage org.astrogrid.samp.web; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.MalformedURLException; import java.net.URL; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.logging.Logger; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.RegInfo; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.httpd.HttpServer; import org.astrogrid.samp.httpd.URLMapperHandler; import org.astrogrid.samp.hub.KeyGenerator; import org.astrogrid.samp.xmlrpc.ActorHandler; /** * SampXmlRpcHandler implementation which passes Web Profile-type XML-RPC calls * to a hub connection factory to provide a Web Profile hub server. * * @author Mark Taylor * @since 2 Feb 2011 */ class WebHubXmlRpcHandler extends ActorHandler { private final WebHubActorImpl impl_; private static final Logger logger_ = Logger.getLogger( WebHubXmlRpcHandler.class.getName() ); /** * Constructor. * * @param profile hub connection factory * @param auth client authorizer * @param keyGen key generator for private keys * @param baseUrl base URL of HTTP server, used for URL translation * @param urlTracker tracks URLs in messages to restrict use in URL * translation service for security reasons; may be null for * no restrictions */ public WebHubXmlRpcHandler( ClientProfile profile, ClientAuthorizer auth, KeyGenerator keyGen, URL baseUrl, UrlTracker urlTracker ) { super( WebClientProfile.WEBSAMP_HUB_PREFIX, WebHubActor.class, new WebHubActorImpl( profile, auth, keyGen, baseUrl, urlTracker ) ); impl_ = (WebHubActorImpl) getActor(); } public Object handleCall( String fqName, List params, Object reqObj ) throws Exception { String regMethod = WebClientProfile.WEBSAMP_HUB_PREFIX + "register"; if ( regMethod.equals( fqName ) && reqObj instanceof HttpServer.Request ) { HttpServer.Request req = (HttpServer.Request) reqObj; final Map securityMap; if ( params.size() == 1 && params.get( 0 ) instanceof Map ) { securityMap = (Map) params.get( 0 ); } else if ( params.size() == 1 && params.get( 0 ) instanceof String ) { securityMap = new HashMap(); securityMap.put( Metadata.NAME_KEY, (String) params.get( 0 ) ); logger_.info( "Deprecated register call signature " + "(arg is string appName not map security-info)" ); } else { throw new IllegalArgumentException( "Bad args for " + regMethod + "(map)" ); } Map result = impl_.register( req, securityMap ); assert result != null; return result; } else { return super.handleCall( fqName, params, reqObj ); } } /** * Returns a handler suitable for performing URL translations on behalf * of sandboxed clients as required by the Web Profile. * * @return url translation handler */ public HttpServer.Handler getUrlTranslationHandler() { return impl_.getUrlTranslationHandler(); } protected Object invokeMethod( Method method, Object obj, Object[] args ) throws IllegalAccessException, InvocationTargetException { return method.invoke( obj, args ); } /** * WebHubActor implementation. */ private static class WebHubActorImpl implements WebHubActor { private final ClientProfile profile_; private final ClientAuthorizer auth_; private final KeyGenerator keyGen_; private final Map regMap_; private final URLTranslationHandler urlTranslator_; private final URL baseUrl_; private final UrlTracker urlTracker_; /** * Constructor. * * @param profile hub connection factory * @param auth client authorizer * @param keyGen key generator for private keys * @param baseUrl HTTP server base URL * @param urlTracker controls access to translated URLs, * may be null for no control */ public WebHubActorImpl( ClientProfile profile, ClientAuthorizer auth, KeyGenerator keyGen, URL baseUrl, UrlTracker urlTracker ) { profile_ = profile; auth_ = auth; keyGen_ = keyGen; baseUrl_ = baseUrl; urlTracker_ = urlTracker; regMap_ = Collections.synchronizedMap( new HashMap() ); urlTranslator_ = new URLTranslationHandler( "/proxied/", regMap_.keySet(), urlTracker ); } /** * Returns a handler suitable for performing URL translations on behalf * of sandboxed clients as required by the Web Profile. * * @return url translation handler */ public HttpServer.Handler getUrlTranslationHandler() { return urlTranslator_; } /** * Attempt client registration. An exception is thrown if registration * fails for any reason. * * @param request HTTP request from applicant * @param securityMap map of required security information * supplied by applicant * @return registration information if registration is successful */ public RegInfo register( HttpServer.Request request, Map securityMap ) throws SampException { if ( profile_.isHubRunning() ) { Object appNameObj = securityMap.get( Metadata.NAME_KEY ); final String appName; if ( appNameObj instanceof String ) { appName = (String) appNameObj; } else { throw new SampException( "Wrong data type (not string) for " + Metadata.NAME_KEY + " securityInfo" + " entry" ); } boolean isAuth = auth_.authorize( request, appName ); if ( ! isAuth ) { throw new SampException( "Registration denied" ); } else { HubConnection connection = profile_.register(); if ( connection != null ) { if ( urlTracker_ != null ) { connection = new UrlTrackerHubConnection( connection, urlTracker_ ); } String clientKey = keyGen_.next(); regMap_.put( clientKey, new Registration( connection ) ); String urlTrans = baseUrl_ + urlTranslator_ .getTranslationBasePath( clientKey ); RegInfo regInfo = new RegInfo( connection.getRegInfo() ); regInfo.put( RegInfo.PRIVATEKEY_KEY, clientKey ); regInfo.put( WebClientProfile.URLTRANS_KEY, urlTrans ); return regInfo; } else { throw new SampException( "Hub is not running" ); } } } else { throw new SampException( "Hub not running" ); } } public void unregister( String clientKey ) throws SampException { HubConnection connection = getConnection( clientKey ); regMap_.remove( clientKey ); connection.unregister(); } public void allowReverseCallbacks( String clientKey, String allow ) throws SampException { boolean isAllowed = SampUtils.decodeBoolean( allow ); Registration reg = getRegistration( clientKey ); synchronized ( reg ) { if ( isAllowed == ( reg.callable_ != null ) ) { return; } else if ( isAllowed ) { WebCallableClient callable = new WebCallableClient(); reg.connection_.setCallable( callable ); reg.callable_ = callable; } else { reg.connection_.setCallable( null ); reg.callable_.endCallbacks(); reg.callable_ = null; } assert isAllowed == ( reg.callable_ != null ); } } public List pullCallbacks( String clientKey, String timeout ) throws SampException { WebCallableClient callable = getRegistration( clientKey ).callable_; if ( callable != null ) { return callable .pullCallbacks( SampUtils.decodeInt( timeout ) ); } else { throw new SampException( "Client is not callable (first invoke" + " allowReverseCallbacks)" ); } } public void declareMetadata( String clientKey, Map meta ) throws SampException { getConnection( clientKey ).declareMetadata( meta ); } public Map getMetadata( String clientKey, String clientId ) throws SampException { return getConnection( clientKey ).getMetadata( clientId ); } public void declareSubscriptions( String clientKey, Map subs ) throws SampException { getRegistration( clientKey ).subs_ = new Subscriptions( subs == null ? new HashMap() : subs ); getConnection( clientKey ).declareSubscriptions( subs ); } public Map getSubscriptions( String clientKey, String clientId ) throws SampException { return getConnection( clientKey ).getSubscriptions( clientId ); } public List getRegisteredClients( String clientKey ) throws SampException { return Arrays.asList( getConnection( clientKey ) .getRegisteredClients() ); } public Map getSubscribedClients( String clientKey, String mtype ) throws SampException { return getConnection( clientKey ).getSubscribedClients( mtype ); } public void notify( String clientKey, String recipientId, Map msg ) throws SampException { getConnection( clientKey ).notify( recipientId, msg ); } public List notifyAll( String clientKey, Map msg ) throws SampException { return getConnection( clientKey ).notifyAll( msg ); } public String call( String clientKey, String recipientId, String msgTag, Map msg ) throws SampException { return getConnection( clientKey ).call( recipientId, msgTag, msg ); } public Map callAll( String clientKey, String msgTag, Map msg ) throws SampException { return getConnection( clientKey ).callAll( msgTag, msg ); } public Map callAndWait( String clientKey, String recipientId, Map msg, String timeout ) throws SampException { return getConnection( clientKey ) .callAndWait( recipientId, msg, SampUtils.decodeInt( timeout ) ); } public void reply( String clientKey, String msgId, Map response ) throws SampException { getConnection( clientKey ).reply( msgId, response ); } public void ping() { if ( ! profile_.isHubRunning() ) { throw new RuntimeException( "No hub running" ); } } public void ping( String clientKey ) { ping(); } /** * Returns the registration object associated with a given private key. * * @param privateKey private key string known by client and hub * to identify the connection * @return registration object for client with key * privateKey * @throws SampException if no client is known with that private key */ private Registration getRegistration( String privateKey ) throws SampException { Registration reg = (Registration) regMap_.get( privateKey ); if ( reg == null ) { throw new SampException( "Unknown client key" ); } else { return reg; } } /** * Returns the connection object associated with a given private key. * * @param privateKey private key string known by client and hub * to identify the connection * @return connection object for client with key * privateKey * @throws SampException if no client is known with that private key */ private HubConnection getConnection( String privateKey ) throws SampException { return getRegistration( privateKey ).connection_; } } /** * HTTP handler which provides URL translation services for sandboxed * clients. */ private static class URLTranslationHandler implements HttpServer.Handler { private final String basePath_; private final Set keySet_; private final UrlTracker urlTracker_; /** * Constructor. * * @param basePath base path for HTTP server * @param keySet set of strings which contains keys for all * currently registered clients * @param urlTracker controls access to translated URLs, * may be null for no control */ public URLTranslationHandler( String basePath, Set keySet, UrlTracker urlTracker ) { if ( ! basePath.startsWith( "/" ) ) { basePath = "/" + basePath; } if ( ! basePath.endsWith( "/" ) ) { basePath = basePath + "/"; } basePath_ = basePath; keySet_ = keySet; urlTracker_ = urlTracker; } /** * Returns the translation base path that can be used by a client * with a given private key. * * @param privateKey client private key * @return URL translation base path that can be used by a * registered client with the given private key */ public String getTranslationBasePath( String privateKey ) { return basePath_ + privateKey + "?"; } public HttpServer.Response serveRequest( HttpServer.Request request ) { // Ignore requests outside this handler's domain. String path = request.getUrl(); if ( ! path.startsWith( basePath_ ) ) { return null; } // Ensure the URL has a query part. String relPath = path.substring( basePath_.length() ); int qIndex = relPath.indexOf( '?' ); if ( qIndex < 0 ) { return HttpServer.createErrorResponse( 404, "Not Found" ); } // Ensure a valid key for authorization is present; this makes // sure that only registered clients can use this service. String authKey = relPath.substring( 0, qIndex ); if ( ! keySet_.contains( authKey ) ) { return HttpServer.createErrorResponse( 403, "Forbidden" ); } // Extract the URL whose translation is being requested. assert path.substring( 0, path.indexOf( '?' ) + 1 ) .equals( getTranslationBasePath( authKey ) ); String targetString; try { targetString = SampUtils.uriDecode( relPath.substring( qIndex + 1 ) ); } catch ( RuntimeException e ) { return HttpServer.createErrorResponse( 400, "Bad Request" ); } URL targetUrl; try { targetUrl = new URL( targetString ); } catch ( MalformedURLException e ) { return HttpServer.createErrorResponse( 400, "Bad Request" ); } // Check permissions. if ( urlTracker_ != null && ! urlTracker_.isUrlPermitted( targetUrl ) ) { return HttpServer.createErrorResponse( 403, "Forbidden" ); } // Perform the translation and return the result. return URLMapperHandler.mapUrlResponse( request.getMethod(), targetUrl ); } } /** * Utility class to aggregate information about a registered client. */ private static class Registration { final HubConnection connection_; WebCallableClient callable_; Subscriptions subs_; /** * Constructor. * * @param connection hub connection */ Registration( HubConnection connection ) { connection_ = connection; } } } jsamp/src/java/org/astrogrid/samp/web/OriginAuthorizer.java0000664000175000017500000000142412730747754023672 0ustar sladensladenpackage org.astrogrid.samp.web; /** * Controls which origins are authorized to perform cross-origin access * to resources. * * @author Mark Taylor * @since 2 Feb 2011 */ public interface OriginAuthorizer { /** * Indicates whether a client with a given origin is permitted * to access resources. * * @param origin client Origin * @return true iff access is permitted * @see Web Origin concept */ boolean authorize( String origin ); /** * Indicates whether clients from arbitrary origins (including none) * are permitted to access resources. * * @return true iff access is permitted */ boolean authorizeAll(); } jsamp/src/java/org/astrogrid/samp/web/WebHubActor.java0000664000175000017500000001430612730747754022536 0ustar sladensladenpackage org.astrogrid.samp.web; import java.util.List; import java.util.Map; import org.astrogrid.samp.client.SampException; /** * Defines the XML-RPC methods which must be implemented by a * Web Profile hub. * The register method is handled separately, since it has special * requirements as regards the HTTP request that it arrives on. * * @author Mark Taylor * @since 2 Feb 2011 */ interface WebHubActor { /** * Throws an exception if service is not operating. */ void ping() throws SampException; /** * Throws an exception if service is not operating. * * @param privateKey ignored */ void ping( String privateKey ) throws SampException; /** * Unregisters a registered client. * * @param privateKey calling client private key */ void unregister( String privateKey ) throws SampException; /** * Declares metadata for the calling client. * * @param privateKey calling client private key * @param meta {@link org.astrogrid.samp.Metadata}-like map */ void declareMetadata( String privateKey, Map meta ) throws SampException; /** * Returns metadata for a given client. * * @param privateKey calling client private key * @param clientId public ID for client whose metadata is required * @return {@link org.astrogrid.samp.Metadata}-like map */ Map getMetadata( String privateKey, String clientId ) throws SampException; /** * Declares subscription information for the calling client. * * @param privateKey calling client private key * @param subs {@link org.astrogrid.samp.Subscriptions}-like map */ void declareSubscriptions( String privateKey, Map subs ) throws SampException; /** * Returns subscriptions for a given client. * * @param privateKey calling client private key * @return {@link org.astrogrid.samp.Subscriptions}-like map */ Map getSubscriptions( String privateKey, String clientId ) throws SampException; /** * Returns a list of the public-ids of all currently registered clients. * * @param privateKey calling client private key * @return list of Strings */ List getRegisteredClients( String privateKey ) throws SampException; /** * Returns a map of the clients subscribed to a given MType. * * @param privateKey calling client private key * @param mtype MType of interest * @return map in which the keys are the public-ids of clients subscribed * to mtype */ Map getSubscribedClients( String privateKey, String mtype ) throws SampException; /** * Sends a message to a given client without wanting a response. * * @param privateKey calling client private key * @param recipientId public-id of client to receive message * @param msg {@link org.astrogrid.samp.Message}-like map */ void notify( String privateKey, String recipientId, Map msg ) throws SampException; /** * Sends a message to all subscribed clients without wanting a response. * * @param privateKey calling client private key * @param msg {@link org.astrogrid.samp.Message}-like map * @return list of public-ids for clients to which the notify will be sent */ List notifyAll( String privateKey, Map msg ) throws SampException; /** * Sends a message to a given client expecting a response. * * @param privateKey calling client private key * @param recipientId public-id of client to receive message * @param msgTag arbitrary string tagging this message for caller's * benefit * @param msg {@link org.astrogrid.samp.Message}-like map * @return message ID */ String call( String privateKey, String recipientId, String msgTag, Map msg ) throws SampException; /** * Sends a message to all subscribed clients expecting responses. * * @param privateKey calling client private key * @param msgTag arbitrary string tagging this message for caller's * benefit * @param msg {@link org.astrogrid.samp.Message}-like map * @return public-id->msg-id map for clients to which an attempt to * send the call will be made */ Map callAll( String privateKey, String msgTag, Map msg ) throws SampException; /** * Sends a message synchronously to a client. * * @param privateKey calling client private key * @param recipientId public-id of client to receive message * @param msg {@link org.astrogrid.samp.Message}-like map * @param timeout timeout in seconds encoded as a SAMP int * @return {@link org.astrogrid.samp.Response}-like map */ Map callAndWait( String privateKey, String recipientId, Map msg, String timeout ) throws SampException; /** * Responds to a previously sent message. * * @param privateKey calling client private key * @param msgId ID associated with earlier send * @param response {@link org.astrogrid.samp.Response}-like map */ void reply( String privateKey, String msgId, Map response ) throws SampException; /** * Indicates that the client will or will not be calling * {@link #pullCallbacks} to receive callable client-type * callbacks until further notice. * * @param privateKey calling client private key * @param allow flag indicating that the client will/will not * be pulling callbacks, encoded as a SAMP boolean ("1"/"0") */ void allowReverseCallbacks( String privateKey, String allow ) throws SampException; /** * Waits for up to a certain length of time for any callbacks to be * delivered. * * @param privateKey calling client private key * @param timeout timeout in seconds encoded as a SAMP int * @return list of {@link Callback}-like maps ready for * processing by the client; may be empty if none are ready */ List pullCallbacks( String privateKey, String timeout ) throws SampException; } jsamp/src/java/org/astrogrid/samp/web/UrlTracker.java0000664000175000017500000001237112730747754022447 0ustar sladensladenpackage org.astrogrid.samp.web; import java.net.InetAddress; import java.net.URL; import java.net.UnknownHostException; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; /** * Keeps track of which URLs have been seen in communications inbound to * and outbound from Web Profile clients. * On the basis of these observations it is able to advise whether a * Web Profile client ought to be permitted to dereference a given URL. * The idea is that a Web Profile client, which may not be entirely * trustworthy, has no legitimate reason for dereferencing an arbitrary * URL, and should only be permitted to dereference local URLs if they * have previously been sent as message arguments to it. * (so for instance an attempt to read file:///etc/password is likely to * be blocked). * Since a SAMP client may be able to provoke some kind of echo, any * URL which was mentioned by a Web Profile client before any other * client mentions it is automatically marked as suspicious. * *

      Details of the implementation are arguable. * * @author Mark Taylor * @since 22 Jul 2011 */ class UrlTracker { private final Set permittedSet_; private final Set blockedSet_; private final String[] localhostNames_; private final Logger logger_ = Logger.getLogger( UrlTracker.class.getName() ); /** * Constructor. */ public UrlTracker() { permittedSet_ = new HashSet(); blockedSet_ = new HashSet(); // Set up a list of aliases for the local host so we can identify // and prepare to restrict access to localhost URLs. List localNameList = new ArrayList(); localNameList.add( "localhost" ); localNameList.add( "127.0.0.1" ); try { InetAddress localAddr = InetAddress.getLocalHost(); localNameList.add( localAddr.getHostName() ); localNameList.add( localAddr.getHostAddress() ); localNameList.add( localAddr.getCanonicalHostName() ); } catch ( UnknownHostException e ) { logger_.log( Level.WARNING, "Can't determine local host name", e ); } localhostNames_ = (String[]) localNameList.toArray( new String[ 0 ] ); } /** * Note that a URL has been communicated to a Web Profile client * from the outside world. * * @param url incoming URL */ public synchronized void noteIncomingUrl( URL url ) { if ( isSensitive( url ) ) { if ( ! blockedSet_.contains( url ) ) { if ( permittedSet_.add( url ) ) { logger_.config( "Mark for translate permission URL " + url ); } } } } /** * Note that a Web Profile client has communicated a URL to the * outside world. * * @param url outgoing URL */ public synchronized void noteOutgoingUrl( URL url ) { if ( isSensitive( url ) ) { if ( ! permittedSet_.contains( url ) ) { if ( blockedSet_.add( url ) ) { logger_.config( "Mark for translate blocking URL " + url ); } } } } /** * Indicates whether access to a given URL should be permitted, * according to the strategy implemented by this class, * from a Web Profile client. * * @param url URL to assess * @return true iff permission to access is appropriate */ public synchronized boolean isUrlPermitted( URL url ) { if ( isSensitive( url ) ) { if ( permittedSet_.contains( url ) ) { logger_.config( "Translation permitted for marked URL " + url ); return true; } else { logger_.warning( "Translation denied for unmarked URL " + url ); return false; } } else { logger_.config( "Translation permitted for non-sensitive URL " + url ); return true; } } /** * Indicates whether a given URL is potentially sensitive. * The current implementation always returns true. * This is probably correct, since it's not in general possible * to tell whether or not a given URL accords privileges to * requests from the local host. But if this ends up letting * too much through, identifying only file URLs and http/https * ones on the local domain would probably be OK. * * @param url URL to assess * @return true iff access should be restricted */ protected boolean isSensitive( URL url ) { return true; } /** * Determines whether a hostname appears to reference the localhost. * * @param host hostname from URL * @return true iff host appears to be, or may be, local */ private boolean isLocalHost( String host ) { if ( host == null || host.length() == 0 ) { return true; } for ( int i = 0; i < localhostNames_.length; i++ ) { if ( host.equalsIgnoreCase( localhostNames_[ i ] ) ) { return true; } } return false; } } jsamp/src/java/org/astrogrid/samp/web/OpenPolicyResourceHandler.java0000664000175000017500000001545112730747754025462 0ustar sladensladenpackage org.astrogrid.samp.web; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.URL; import java.util.LinkedHashMap; import java.util.Map; import org.astrogrid.samp.httpd.HttpServer; import org.astrogrid.samp.httpd.ServerResource; /** * HTTP resource handler suitable for serving static cross-origin policy files. * * @author Mark Taylor * @since 2 Feb 2011 */ public class OpenPolicyResourceHandler implements HttpServer.Handler { private final String policyPath_; private final ServerResource policyResource_; private final OriginAuthorizer authorizer_; private final HttpServer.Response response405_; /** * Constructor. * * @param policyPath path at which the policy file will reside on * this handler's server * @param policyResource content of policy file * @param authorizer controls who is permitted to view the policy file */ public OpenPolicyResourceHandler( String policyPath, ServerResource policyResource, OriginAuthorizer authorizer ) { policyPath_ = policyPath; policyResource_ = policyResource; authorizer_ = authorizer; response405_ = HttpServer.create405Response( new String[] { "GET", "HEAD", } ); } public HttpServer.Response serveRequest( HttpServer.Request request ) { if ( request.getUrl().equals( policyPath_ ) ) { String method = request.getMethod(); if ( ! method.equals( "HEAD" ) && ! method.equals( "GET" ) ) { return response405_; } else if ( authorizer_.authorizeAll() ) { Map hdrMap = new LinkedHashMap(); hdrMap.put( "Content-Type", policyResource_.getContentType() ); long contentLength = policyResource_.getContentLength(); if ( contentLength >= 0 ) { hdrMap.put( "Content-Length", Long.toString( contentLength ) ); } if ( method.equals( "HEAD" ) ) { return new HttpServer.Response( 200, "OK", hdrMap ) { public void writeBody( OutputStream out ) { } }; } else if ( method.equals( "GET" ) ) { return new HttpServer.Response( 200, "OK", hdrMap ) { public void writeBody( OutputStream out ) throws IOException { policyResource_.writeBody( out ); } }; } else { assert false; return response405_; } } else { return HttpServer.createErrorResponse( 404, "Not found" ); } } else { return null; } } /** * Creates a handler suitable for serving static cross-origin policy files. * * @param path path at which the policy file will reside on the * handler's HTTP server * @param contentUrl external URL at which the resource contents * can be found; this will be retrieved once and * cached * @param oAuth controls who is permitted to retrieve the policy file */ public static HttpServer.Handler createPolicyHandler( String path, URL contentUrl, String contentType, OriginAuthorizer oAuth ) throws IOException { ServerResource resource = createCachedResource( contentUrl, contentType ); return new OpenPolicyResourceHandler( path, resource, oAuth ); } /** * Returns a handler which can serve the /crossdomain.xml file * used by Adobe Flash. The policy file permits access from anywhere. * * @param oAuth controls who is permitted to retrieve the policy file * @return policy file handler * @see Adobe Flash cross-origin policy */ public static HttpServer.Handler createFlashPolicyHandler( OriginAuthorizer oAuth ) throws IOException { return createPolicyHandler( "/crossdomain.xml", OpenPolicyResourceHandler.class .getResource( "crossdomain.xml" ), "text/x-cross-domain-policy", oAuth ); } /** * Returns a handler which can serve the /clientaccesspolicy.xml file * used by Microsoft Silverlight. The policy file permits access * from anywhere. * * @param oAuth controls who is permitted to retrieve the policy file * @return policy file handler * @see MS Silverlight cross-origin policy */ public static HttpServer.Handler createSilverlightPolicyHandler( OriginAuthorizer oAuth ) throws IOException { return createPolicyHandler( "/clientaccesspolicy.xml", OpenPolicyResourceHandler.class .getResource( "clientaccesspolicy.xml" ), "text/xml", oAuth ); } /** * Returns a ServerResource which caches the contents of a given * (presumably smallish and unchanging) external resource. * * @param dataUrl location of external resource * @param contentType MIME type for content of dataUrl * @return new cached resource representing content of * dataUrl */ private static ServerResource createCachedResource( URL dataUrl, final String contentType ) throws IOException { ByteArrayOutputStream bout = new ByteArrayOutputStream(); InputStream uin = dataUrl.openStream(); byte[] buf = new byte[ 1024 ]; for ( int count; ( count = uin.read( buf ) ) >= 0; ) { bout.write( buf, 0, count ); } bout.close(); final byte[] data = bout.toByteArray(); return new ServerResource() { public long getContentLength() { return data.length; } public String getContentType() { return contentType; } public void writeBody( OutputStream out ) throws IOException { out.write( data ); } }; } } jsamp/src/java/org/astrogrid/samp/web/ClientAuthorizer.java0000664000175000017500000000225112730747754023660 0ustar sladensladenpackage org.astrogrid.samp.web; import org.astrogrid.samp.httpd.HttpServer; /** * Defines authorization functionality which is used to determine whether * a client is permitted to register with the hub. * * @author Mark Taylor * @since 2 Feb 2011 */ public interface ClientAuthorizer { /** * Indicates whether an HTTP request representing an otherwise * unauthorized connection attempt will be permitted access to * sensitive system resources. * The client submitting the request provides the * appName parameter by way of additional information about * its identity. However, the value of this name is supplied by the * (potentially malicious) applicant, so cannot in itself be regarded * as an additional security measure. * * @param request incoming HTTP request * @param appName name by which the application submitting the request * wishes to be known * @return true iff submitter of the request should be permitted access * to sensitive system resources in the future */ boolean authorize( HttpServer.Request request, String appName ); } jsamp/src/java/org/astrogrid/samp/web/UrlTrackerHubConnection.java0000664000175000017500000001751612730747754025134 0ustar sladensladenpackage org.astrogrid.samp.web; import java.net.MalformedURLException; import java.net.URL; import java.util.Collection; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import org.astrogrid.samp.Message; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.RegInfo; import org.astrogrid.samp.Response; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; /** * HubConnection wrapper implementation which intercepts all incoming * and outgoing communications, scans them for URLs in the payload, * and notifies a supplied UrlTracker object. * * @author Mark Taylor * @since 22 Jul 2011 */ class UrlTrackerHubConnection implements HubConnection { private final HubConnection base_; private final UrlTracker urlTracker_; /** * Constructor. * * @param base connection on which this one is based * @param urlTracker tracker for URL usage */ public UrlTrackerHubConnection( HubConnection base, UrlTracker urlTracker ) { base_ = base; urlTracker_ = urlTracker; } /** * Recursively scans a SAMP map for items that look like URLs, * and notifies the tracker that they are incoming. * As a convenience the input map is returned. * * @param map map to scan * @return the input map, unchanged */ private Map scanIncoming( Map map ) { URL[] urls = scanForUrls( map ); for ( int iu = 0; iu < urls.length; iu++ ) { urlTracker_.noteIncomingUrl( urls[ iu ] ); } return map; } /** * Recursively scans a SAMP map for items that look like URLs, * and notifies the tracker that they are outgoing. * As a convenience the input map is returned. * * @param map map to scan * @return the input map, unchanged */ private Map scanOutgoing( Map map ) { URL[] urls = scanForUrls( map ); for ( int iu = 0; iu < urls.length; iu++ ) { urlTracker_.noteOutgoingUrl( urls[ iu ] ); } return map; } /** * Recursively scans a map for items that look like URLs and * returns them as an array. * * @param map map to scan * @return array of URLs found in map */ private URL[] scanForUrls( Map map ) { Collection urlSet = new HashSet(); scanForUrls( map, urlSet ); return (URL[]) urlSet.toArray( new URL[ 0 ] ); } /** * Recursively scans a SAMP data item for items that look like URLs * and appends them into a supplied list. * * @param item SAMP data item (String, List or Map) * @param urlSet list of URL objects to which URLs can be added */ private void scanForUrls( Object item, Collection urlSet ) { if ( item instanceof String ) { if ( isUrl( (String) item ) ) { try { urlSet.add( new URL( (String) item ) ); } catch ( MalformedURLException e ) { } } } else if ( item instanceof List ) { for ( Iterator it = ((List) item).iterator(); it.hasNext(); ) { scanForUrls( it.next(), urlSet ); } } else if ( item instanceof Map ) { for ( Iterator it = ((Map) item).values().iterator(); it.hasNext(); ) { scanForUrls( it.next(), urlSet ); } } } /** * Determines whether a given string is apparently a URL. * * @param str string to test * @return true iff str looks like a URL */ private boolean isUrl( String str ) { if ( str == null || str.indexOf( ":/" ) <= 0 ) { return false; } else { try { new URL( str ); return true; } catch ( MalformedURLException e ) { return false; } } } public void setCallable( CallableClient callable ) throws SampException { base_.setCallable( new UrlTrackerCallableClient( callable ) ); } public void notify( String recipientId, Map msg ) throws SampException { base_.notify( recipientId, scanOutgoing( msg ) ); } public List notifyAll( Map msg ) throws SampException { return base_.notifyAll( scanOutgoing( msg ) ); } public String call( String recipientId, String msgTag, Map msg ) throws SampException { return base_.call( recipientId, msgTag, scanOutgoing( msg ) ); } public Map callAll( String msgTag, Map msg ) throws SampException { return base_.callAll( msgTag, scanOutgoing( msg ) ); } public Response callAndWait( String recipientId, Map msg, int timeout ) throws SampException { return (Response) scanIncoming( base_.callAndWait( recipientId, scanOutgoing( msg ), timeout ) ); } public void reply( String msgId, Map response ) throws SampException { base_.reply( msgId, scanOutgoing( response ) ); } public RegInfo getRegInfo() { return (RegInfo) scanIncoming( base_.getRegInfo() ); } public void ping() throws SampException { base_.ping(); } public void unregister() throws SampException { base_.unregister(); } public void declareMetadata( Map meta ) throws SampException { base_.declareMetadata( scanOutgoing( meta ) ); } public Metadata getMetadata( String clientId ) throws SampException { return (Metadata) scanIncoming( base_.getMetadata( clientId ) ); } public void declareSubscriptions( Map subs ) throws SampException { base_.declareSubscriptions( scanOutgoing( subs ) ); } public Subscriptions getSubscriptions( String clientId ) throws SampException { return (Subscriptions) scanIncoming( base_.getSubscriptions( clientId ) ); } public String[] getRegisteredClients() throws SampException { return base_.getRegisteredClients(); } public Map getSubscribedClients( String mtype ) throws SampException { return scanIncoming( base_.getSubscribedClients( mtype ) ); } /** * CallableClient wrapper implementation which intercepts * communications, scans the payloads for URLs, and informs an * associated UrlTracker. */ private class UrlTrackerCallableClient implements CallableClient { private final CallableClient baseCallable_; /** * Constructor. * * @param baseCallable object on which this one is based */ UrlTrackerCallableClient( CallableClient baseCallable ) { baseCallable_ = baseCallable; } public void receiveCall( String senderId, String msgId, Message msg ) throws Exception { baseCallable_.receiveCall( senderId, msgId, (Message) scanIncoming( msg ) ); } public void receiveNotification( String senderId, Message msg ) throws Exception { baseCallable_.receiveNotification( senderId, (Message) scanIncoming( msg ) ); } public void receiveResponse( String responderId, String msgTag, Response response ) throws Exception { baseCallable_.receiveResponse( responderId, msgTag, (Response) scanIncoming( response ) ); } } } jsamp/src/java/org/astrogrid/samp/web/AuthResourceBundle_en.java0000664000175000017500000000316012730747754024612 0ustar sladensladenpackage org.astrogrid.samp.web; /** * AuthResourceBundle with English text. * * @author Mark Taylor * @since 15 Jul 2011 */ public class AuthResourceBundle_en extends AuthResourceBundle { /** * Constructor. */ public AuthResourceBundle_en() { super( new EnglishContent() ); } /** * Content implementation for English. */ static class EnglishContent implements Content { public String windowTitle() { return "SAMP Hub Security"; } public String appIntroductionLines() { return "The following application, probably running in a browser,\n" + "is requesting SAMP Hub registration:"; } public String nameWord() { return "Name"; } public String originWord() { return "Origin"; } public String undeclaredWord() { return "undeclared"; } public String privilegeWarningLines() { return "If you permit this, it may be able to access local files\n" + "and other resources on your computer."; } public String adviceLines() { return "You should only accept if you have just performed\n" + "some action in the browser, on a web site you trust,\n" + "that you expect to have caused this."; } public String questionLine() { return "Do you authorize connection?"; } public String yesWord() { return "Yes"; } public String noWord() { return "No"; } } } jsamp/src/java/org/astrogrid/samp/web/AuthResourceBundle_fr.java0000664000175000017500000000351512730747754024623 0ustar sladensladenpackage org.astrogrid.samp.web; /** * AuthResourceBundle with French text. * * @author Thomas Boch * @author Mark Taylor * @since 23 Aug 2011 */ public class AuthResourceBundle_fr extends AuthResourceBundle { /** * Constructor. */ public AuthResourceBundle_fr() { super( new FrenchContent() ); } /** * Content implementation for French. */ private static class FrenchContent implements Content { public String windowTitle() { return "Avertissement de s\u00e9curit\u00e9 du hub SAMP"; } public String appIntroductionLines() { return "L'application suivante, qui s'ex\u00e9cute probablement " + "depuis un\n" + "navigateur, demande \u00e0 s'enregistrer " + "aupr\u00e8s du hub SAMP:"; } public String nameWord() { return "Nom"; } public String originWord() { return "Origine"; } public String undeclaredWord() { return "Inconnue"; } public String privilegeWarningLines() { return "Si vous l'autorisez, elle pourra acc\u00e9der aux " + "fichiers locaux\n" + "et autres ressources de votre ordinateur."; } public String adviceLines() { return "Acceptez uniquement si vous venez d'effectuer dans le " + "navigateur\n" + "une action, sur un site de confiance, susceptible d'avoir " + "entra\u00een\u00e9\n" + "cette demande."; } public String questionLine() { return "Acceptez-vous?"; } public String yesWord() { return "Oui"; } public String noWord() { return "Non"; } } } jsamp/src/java/org/astrogrid/samp/web/AuthResourceBundle.java0000664000175000017500000002534312730747754024137 0ustar sladensladenpackage org.astrogrid.samp.web; import java.lang.reflect.InvocationHandler; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.util.Collection; import java.util.Enumeration; import java.util.HashSet; import java.util.Hashtable; import java.util.MissingResourceException; import java.util.ResourceBundle; import java.util.logging.Logger; /** * ResourceBundle for internationalising the Web Profile authorization * dialogue. * * @author Mark Taylor * @since 15 Jul 2011 */ public class AuthResourceBundle extends ResourceBundle { private final Hashtable map_; private static final Logger logger_ = Logger.getLogger( AuthResourceBundle.class.getName() ); /** * Constructs default (English) instance. */ public AuthResourceBundle() { this( getDefaultContent() ); } /** * Constructs a bundle based on a Content implementation. * * @param content contains information required for bundle */ protected AuthResourceBundle( Content content ) { map_ = new Hashtable(); Method[] methods = getContentMethods(); Object[] noArgs = new Object[ 0 ]; for ( int im = 0; im < methods.length; im++ ) { Method method = methods[ im ]; String mname = method.getName(); try { map_.put( method.getName(), method.invoke( content, noArgs ) ); } catch ( IllegalAccessException e ) { throw (RuntimeException) new RuntimeException( "Failed to call method " + method.getName() ) .initCause( e ); } catch ( InvocationTargetException e ) { throw (RuntimeException) new RuntimeException( "Failed to call method " + method.getName() ) .initCause( e ); } } checkHasAllKeys( this ); } protected final Object handleGetObject( String key ) { return map_.get( key ); } public final Enumeration getKeys() { return map_.keys(); } /** * Returns a Content object based on a bundle which has the keys * that AuthResourceBundle is supposed to have. * If any of the required keys are missing, the result falls back * to a default bundle. * * @param bundle resource bundle * @return content object guaranteed to have non-null contents for * all its attributes */ public static Content getAuthContent( final ResourceBundle bundle ) { try { checkHasAllKeys( bundle ); } catch ( MissingResourceException e ) { logger_.warning( "Some keys missing from localised auth resource " + "bundle; using English" ); return getDefaultContent(); } InvocationHandler ihandler = new InvocationHandler() { public Object invoke( Object proxy, Method method, Object[] args ) throws Throwable { String key = method.getName(); Class rclazz = method.getReturnType(); if ( String.class.equals( rclazz ) ) { return bundle.getString( key ); } else { throw new RuntimeException( "Unsuitable return type " + rclazz.getName() + " (shouldn't happen)" ); } } }; return (Content) Proxy .newProxyInstance( AuthResourceBundle.class.getClassLoader(), new Class[] { Content.class }, ihandler ); } /** * Returns all the methods of the Content interface which correspond * to AuthResourceBundle entries. * * @return resource bundle methods, all have no arguments and return String */ static Method[] getContentMethods() { return Content.class.getMethods(); } /** * Determines if a bundle has all the required keys for this class. * * @param bundle bundle to test * @return true iff bundle has all required keys */ static void checkHasAllKeys( ResourceBundle bundle ) { Collection bkeys = new HashSet(); for ( Enumeration en = bundle.getKeys(); en.hasMoreElements(); ) { bkeys.add( en.nextElement() ); } Collection mnames = new HashSet(); Method[] methods = getContentMethods(); for ( int im = 0; im < methods.length; im++ ) { mnames.add( methods[ im ].getName() ); } mnames.removeAll( bkeys ); if ( ! mnames.isEmpty() ) { throw new MissingResourceException( "Missing resources " + mnames, AuthResourceBundle.class.getName(), mnames.iterator().next().toString() ); } } /** * Returns a default Content implementation. * * @return english content */ private static Content getDefaultContent() { return new AuthResourceBundle_en.EnglishContent(); } /** * Returns a string suitable for entry into a .properties file * for a given Method of a given Content object. * * @param content auth resource content * @param method Content method (public String x()) */ private static String toPropertyString( Content content, Method method ) { try { String value = (String) method.invoke( content, new Object[ 0 ] ); value = value.replaceAll( "\n", "\\\\n" ); StringBuffer sbuf = new StringBuffer(); sbuf.append( method.getName() ) .append( '=' ); for ( int i = 0; i < value.length(); i++ ) { char c = value.charAt( i ); if ( c == '\n' ) { sbuf.append( "\\n" ); } else if ( c >= 32 && c < 128 ) { sbuf.append( c ); } else { String xs = Integer.toHexString( (int) c ); sbuf.append( "\\u" ); for ( int j = xs.length(); j < 4; j++ ) { sbuf.append( '0' ); } sbuf.append( xs ); } } return sbuf.toString(); } catch ( IllegalAccessException e ) { throw (RuntimeException) new RuntimeException( "Failed to call method " + method.getName() ) .initCause( e ); } catch ( InvocationTargetException e ) { throw (RuntimeException) new RuntimeException( "Failed to call method " + method.getName() ) .initCause( e ); } } /** * Writes a template .properties file. Sensitive to the locale. */ public static void main( String[] args ) { ResourceBundle lBundle = ResourceBundle.getBundle( AuthResourceBundle.class.getName() ); Content lContent = getAuthContent( lBundle ); Content dContent = getDefaultContent(); Method[] methods = getContentMethods(); System.out.println( "# Template for " + AuthResourceBundle.class.getName() + "_xx.properties file," ); System.out.println( "# giving localised text " + "for Web Profile client authorization dialogue." ); System.out.println( "# Please fill in language-specific values for " + "each key, as in the example." ); System.out.println( "# Follow the capitalization and punctuation " + "of the English version." ); System.out.println( "# Long lines should be broken up with return " + "characters (\\n)." ); System.out.println( "# Encoding is ISO 8859-1; " + "see java.util.Properties docs for detailed " + "syntax." ); System.out.println( "#" ); System.out.println( "# Alternatively, implement " + AuthResourceBundle.class.getName() + "_xx" ); System.out.println( "# using " + AuthResourceBundle_en.class.getName() + " as an example." ); System.out.println(); for ( int im = 0; im < methods.length; im++ ) { Method method = methods[ im ]; System.out.println( "# " + toPropertyString( dContent, method ) ); System.out.println( toPropertyString( lContent, method ) ); } } /** * Defines the keys and value types required for a bundle of this class. * See the English language implementation in * {@link AuthResourceBundle_en} for example text. * *

      All methods have no arguments and return a String. * The methods with names * that end "Lines" should return text which contains * line breaks (\n characters). Each such line will * be displayed as it stands in the GUI, so it shouldn't be too long. * *

      The method names define the keys which can be used if a * property resource file is used to supply the content. */ public static interface Content { /** * Returns the title for the confirmation window. */ String windowTitle(); /** * Returns lines introducing the registration request. */ String appIntroductionLines(); /** * Returns the word meaning "Name" (initial capitalised). */ String nameWord(); /** * Returns the word meaning "Origin" (initial capitalised). */ String originWord(); /** * Returns the word meaning "undeclared" (not capitalised). */ String undeclaredWord(); /** * Returns lines suitable explaining the privileges that a * registered client will have. */ String privilegeWarningLines(); /** * Returns lines with advice on whether you should accept or decline. */ String adviceLines(); /** * Returns a line asking whether to authorize (yes/no). */ String questionLine(); /** * Returns the word meaning "Yes" (initial capitalised). */ String yesWord(); /** * Returns the word meaning "No" (initial capitalised). */ String noWord(); } } jsamp/src/java/org/astrogrid/samp/web/WebCallableClient.java0000664000175000017500000000775412730747754023676 0ustar sladensladenpackage org.astrogrid.samp.web; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import org.astrogrid.samp.Message; import org.astrogrid.samp.Response; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.SampException; /** * CallableClient implementation used internally by the Web Profile hub. * * @author Mark Taylor * @since 2 Feb 2011 */ class WebCallableClient implements CallableClient { private final List queue_; private final int capacity_; private boolean ended_; /** Default maximum for queued callbacks. */ public final static int DEFAULT_CAPACITY = 4096; /** * Constructs a callable client with default maximum capacity. */ public WebCallableClient() { this( DEFAULT_CAPACITY ); } /** * Constructs a callable client with a given maximum callback capacity. * * @param capacity maximum number of queued callbacks */ public WebCallableClient( int capacity ) { capacity_ = capacity; queue_ = new ArrayList(); } /** * Blocks for up to a given number of seconds or until any callbacks * are ready, then returns any ready callbacks. * * @param timeout timeout in seconds * @return list of {@link Callback}-like Maps */ public List pullCallbacks( int timeout ) throws SampException { // Calculate the timeout epoch as currentTimeMillis. long end = timeout >= 0 ? System.currentTimeMillis() + timeout * 1000 : Long.MAX_VALUE; // Wait until either there are callbacks or timeout is reached. try { synchronized ( queue_ ) { while ( queue_.isEmpty() && end - System.currentTimeMillis() > 0 && ! ended_ ) { queue_.wait( end - System.currentTimeMillis() ); } // Remove available callbacks from the queue, if any, // and return them. List callbacks = new ArrayList( queue_ ); queue_.clear(); return callbacks; } } catch ( InterruptedException e ) { throw new SampException( "Interrupted", e ); } } public void receiveNotification( String senderId, Message message ) { enqueue( "receiveNotification", new Object[] { senderId, message } ); } public void receiveCall( String senderId, String msgId, Message message ) { enqueue( "receiveCall", new Object[] { senderId, msgId, message } ); } public void receiveResponse( String responderId, String msgTag, Response response ) { enqueue( "receiveResponse", new Object[] { responderId, msgTag, response } ); } /** * Informs this client that no further callbacks (receive* methods) * will be made on it. */ public void endCallbacks() { ended_ = true; synchronized ( queue_ ) { queue_.notifyAll(); } } /** * Adds a new callback to the queue which can be passed out via the * {@link #pullCallbacks} method. * * @param methodName callback method name * @param params callback parameter list */ private void enqueue( String methodName, Object[] params ) { Callback callback = new Callback( WebClientProfile.WEBSAMP_CLIENT_PREFIX + methodName, Arrays.asList( params ) ); callback.check(); synchronized ( queue_ ) { if ( queue_.size() < capacity_ ) { queue_.add( callback ); queue_.notifyAll(); } else { throw new IllegalStateException( "Callback queue is full" + " (" + capacity_ + " objects)" ); } } } } jsamp/src/java/org/astrogrid/samp/web/ClientAuthorizers.java0000664000175000017500000000627312730747754024053 0ustar sladensladenpackage org.astrogrid.samp.web; import java.util.logging.Level; import java.util.logging.Logger; import org.astrogrid.samp.httpd.HttpServer; /** * Utility class containing ClientAuthorizer implementations. * * @author Mark Taylor * @since 2 Feb 2011 */ public class ClientAuthorizers { /** * Authorizer which always denies access, * with INFO logging either way. */ public static final ClientAuthorizer FALSE = createLoggingClientAuthorizer( createFixedClientAuthorizer( false ), Level.INFO, Level.INFO ); /** * Authorizer which always permits access, * with WARNING logging either way. */ public static final ClientAuthorizer TRUE = createLoggingClientAuthorizer( createFixedClientAuthorizer( true ), Level.WARNING, Level.WARNING ); /** * Authorizer which queries the user via a popup dialogue, * with INFO logging either way. */ private static ClientAuthorizer swingAuth_; private static final Logger logger_ = Logger.getLogger( ClientAuthorizers.class.getName() ); private ClientAuthorizers() { } /** * Returns a new authorizer instance which always produces the same * authorization status. * * @param policy value to return from the authorize method * @return new authorizer */ public static ClientAuthorizer createFixedClientAuthorizer( final boolean policy ) { return new ClientAuthorizer() { public boolean authorize( HttpServer.Request request, String appName ) { return policy; } }; } /** * Returns a new authorizer instance based on an existing one which * logs authorization results through the logging system. * * @param auth base authorizer * @param acceptLevel logging level at which auth acceptances are logged * @param refuseLevel logging level at which auth refusals are logged * @return new authorizer */ public static ClientAuthorizer createLoggingClientAuthorizer( final ClientAuthorizer auth, final Level acceptLevel, final Level refuseLevel ) { return new ClientAuthorizer() { public synchronized boolean authorize( HttpServer.Request request, String appName ) { boolean accept = auth.authorize( request, appName ); log( accept, "\"" + appName + "\"" ); return accept; } private void log( boolean accept, String appName ) { if ( accept ) { logger_.log( acceptLevel, "Accepted registration for client " + appName ); } else { logger_.log( refuseLevel, "Rejected registration for client " + appName ); } } }; } } jsamp/src/java/org/astrogrid/samp/web/AuthResourceBundle_it.java0000664000175000017500000000341012730747754024622 0ustar sladensladenpackage org.astrogrid.samp.web; /** * AuthResourceBundle with English text. * * @author Luigi Paioro * @author Mark Taylor * @since 15 Jul 2011 */ public class AuthResourceBundle_it extends AuthResourceBundle { /** * Constructor. */ public AuthResourceBundle_it() { super( new ItalianContent() ); } /** * Content implementation for Italian. */ private static class ItalianContent implements Content { public String windowTitle() { return "Sicurezza del SAMP Hub"; } public String appIntroductionLines() { return "Il seguente programma, probabilmente eseguito all'interno\n" + "di un browser, chiede di essere registrato al SAMP Hub:"; } public String nameWord() { return "Nome"; } public String originWord() { return "Origine"; } public String undeclaredWord() { return "non dichiarato"; } public String privilegeWarningLines() { return "Se ne consentite la registrazione, esso potrebbe accedere\n" + "ai files locali e ad altre risorse del vostro computer."; } public String adviceLines() { return "Il vostro consenso dovrebbe essere dato solo se avete\n" + "appena eseguito qualche azione con il browser,\n" + "su un sito Web conosciuto, che vi aspettate possa aver\n" + "causato questa richiesta."; } public String questionLine() { return "Autorizzate la registrazione?"; } public String yesWord() { return "S\u00ec"; } public String noWord() { return "No"; } } } jsamp/src/java/org/astrogrid/samp/web/WebHubProfile.java0000664000175000017500000003755712730747754023103 0ustar sladensladenpackage org.astrogrid.samp.web; import java.io.IOException; import java.io.PrintStream; import java.net.InetSocketAddress; import java.net.ServerSocket; import java.util.logging.Logger; import javax.swing.JToggleButton; import javax.swing.SwingUtilities; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.httpd.HttpServer; import org.astrogrid.samp.hub.ConfigHubProfile; import org.astrogrid.samp.hub.HubProfile; import org.astrogrid.samp.hub.KeyGenerator; import org.astrogrid.samp.hub.MessageRestriction; import org.astrogrid.samp.xmlrpc.internal.InternalServer; import org.astrogrid.samp.xmlrpc.internal.RpcLoggingInternalServer; import org.astrogrid.samp.xmlrpc.internal.XmlLoggingInternalServer; /** * HubProfile implementation for Web Profile. * * @author Mark Taylor * @author Laurent Bourges * @since 2 Feb 2011 */ public class WebHubProfile implements HubProfile, ConfigHubProfile { private final ServerFactory serverFactory_; private final ClientAuthorizer auth_; private final KeyGenerator keyGen_; private final ConfigEnabler configEnabler_; private final ConfigEnabler configDisabler_; private MessageRestriction mrestrict_; private boolean controlUrls_; private InternalServer xServer_; private JToggleButton.ToggleButtonModel[] configModels_; private static final Logger logger_ = Logger.getLogger( WebHubProfile.class.getName() ); /** * Constructs a profile with configuration options. * * @param serverFactory factory for server providing HTTP * and XML-RPC implementation * @param auth client authorizer implementation * @param mrestrict restriction for permitted outward MTypes * @param keyGen key generator for private keys * @param controlUrls true iff access to local URLs is to be restricted */ public WebHubProfile( ServerFactory serverFactory, ClientAuthorizer auth, MessageRestriction mrestrict, KeyGenerator keyGen, boolean controlUrls ) { serverFactory_ = serverFactory; auth_ = auth; mrestrict_ = mrestrict; keyGen_ = keyGen; controlUrls_ = controlUrls; /* These Runnables are set up here rather than being defined as * anonymous classes where they are used to work round an obscure * JNLP bug related to classloading and JVM shutdown. */ configEnabler_ = new ConfigEnabler( true ); configDisabler_ = new ConfigEnabler( false ); } /** * Constructs a profile with default configuration. */ public WebHubProfile() throws IOException { this( new ServerFactory(), new HubSwingClientAuthorizer( null ), ListMessageRestriction.DEFAULT, createKeyGenerator(), true ); } public String getProfileName() { return "Web"; } public MessageRestriction getMessageRestriction() { return mrestrict_; } public synchronized void start( ClientProfile profile ) throws IOException { if ( isRunning() ) { logger_.info( "Profile already running" ); return; } xServer_ = serverFactory_.createSampXmlRpcServer(); HttpServer hServer = xServer_.getHttpServer(); WebHubXmlRpcHandler wxHandler = new WebHubXmlRpcHandler( profile, auth_, keyGen_, hServer.getBaseUrl(), controlUrls_ ? new UrlTracker() : null ); logger_.info( "Web Profile URL controls: " + ( controlUrls_ ? "on" : "off" ) ); logger_.info( "Web Profile MType restrictions: " + mrestrict_ ); xServer_.addHandler( wxHandler ); hServer.addHandler( wxHandler.getUrlTranslationHandler() ); hServer.start(); if ( configModels_ != null ) { SwingUtilities.invokeLater( configDisabler_ ); } } public synchronized boolean isRunning() { return xServer_ != null; } public synchronized void stop() { if ( ! isRunning() ) { logger_.info( "Profile already stopped" ); return; } xServer_.getHttpServer().stop(); xServer_ = null; if ( configModels_ != null ) { SwingUtilities.invokeLater( configEnabler_ ); } } public synchronized JToggleButton.ToggleButtonModel[] getConfigModels() { if ( configModels_ == null ) { configModels_ = createConfigModels(); } return configModels_; } /** * Creates and returns some toggle models for configuration. * They are only enabled when the profile is not running. */ private JToggleButton.ToggleButtonModel[] createConfigModels() { ConfigModel[] models = new ConfigModel[] { new ConfigModel( "CORS cross-domain access" ) { void setOn( boolean on ) { serverFactory_ .setOriginAuthorizer( on ? OriginAuthorizers.TRUE : OriginAuthorizers.FALSE ); } boolean isOn() { return serverFactory_.getOriginAuthorizer().authorize( "" ); } }, new ConfigModel( "Flash cross-domain access" ) { void setOn( boolean on ) { serverFactory_.setAllowFlash( on ); } boolean isOn() { return serverFactory_.isAllowFlash(); } }, new ConfigModel( "Silverlight cross-domain access" ) { void setOn( boolean on ) { serverFactory_.setAllowSilverlight( on ); } boolean isOn() { return serverFactory_.isAllowSilverlight(); } }, new ConfigModel( "URL Controls" ) { void setOn( boolean on ) { controlUrls_ = on; } boolean isOn() { return controlUrls_; } }, new ConfigModel( "MType Restrictions" ) { void setOn( boolean on ) { mrestrict_ = on ? ListMessageRestriction.DEFAULT : null; } boolean isOn() { return mrestrict_ != null; } }, }; boolean enabled = ! isRunning(); for ( int i = 0; i < models.length; i++ ) { models[ i ].setEnabled( enabled ); } return models; } /** * Convenience method to return a new key generator * suitable for use with a WebHubProfile. * * @return new key generator for web hub private keys */ public static KeyGenerator createKeyGenerator() { return new KeyGenerator( "wk:", 24, KeyGenerator.createRandom() ); } /** * Runnable to be called on the Event Dispatch Thread which sets the * enabledness of the user controls for configuration of this profile. */ private class ConfigEnabler implements Runnable { private final boolean isEnabled_; /** * Constructor. * * @param isEnabled status assigned to config controls by calling * this object's run() method */ ConfigEnabler( boolean isEnabled ) { isEnabled_ = isEnabled; } public void run() { JToggleButton.ToggleButtonModel[] configModels = configModels_; if ( configModels != null ) { for ( int i = 0; i < configModels.length; i++ ) { configModels[ i ].setEnabled( isEnabled_ ); } } } } /** * Helper class to generate toggle button models for hub configuration. */ private static abstract class ConfigModel extends JToggleButton.ToggleButtonModel { private final String name_; /** * Constructor. * * @param name control name */ public ConfigModel( String name ) { name_ = name; } /** * Indicates whether this toggle is on. * * @return true iff selected */ abstract boolean isOn(); /** * Sets whether this toggle is on. * * @param on new selected value */ abstract void setOn( boolean on ); public boolean isSelected() { return isOn(); } public void setSelected( boolean on ) { setOn( on ); super.setSelected( on ); } public String toString() { return name_; } } /** * Creates and configures the HTTP server on which the Web Profile resides. */ public static class ServerFactory { private String logType_; private int port_; private String xmlrpcPath_; private boolean allowFlash_; private boolean allowSilverlight_; private OriginAuthorizer oAuth_; /** * Constructs a ServerFactory with default properties. */ public ServerFactory() { logType_ = null; port_ = WebClientProfile.WEBSAMP_PORT; xmlrpcPath_ = WebClientProfile.WEBSAMP_PATH; allowFlash_ = true; allowSilverlight_ = false; oAuth_ = OriginAuthorizers.TRUE; } /** * Returns a new internal server. * * @return new server for use with WebHubProfile */ public InternalServer createSampXmlRpcServer() throws IOException { String path = getXmlrpcPath(); ServerSocket socket = createServerSocket( getPort() ); String logType = getLogType(); OriginAuthorizer oAuth = getOriginAuthorizer(); PrintStream logOut = System.err; CorsHttpServer hServer = "http".equals( logType ) ? new LoggingCorsHttpServer( socket, oAuth, logOut ) : new CorsHttpServer( socket, oAuth ); if ( isAllowFlash() ) { hServer.addHandler( OpenPolicyResourceHandler .createFlashPolicyHandler( oAuth ) ); logger_.info( "Web Profile HTTP server permits " + "Flash-style cross-domain access" ); } else { logger_.info( "Web Profile HTTP server does not permit " + "Flash-style cross-domain access" ); } if ( isAllowSilverlight() ) { hServer.addHandler( OpenPolicyResourceHandler .createSilverlightPolicyHandler( oAuth ) ); logger_.info( "Web Profile HTTP server permits " + "Silverlight-style cross-domain access" ); } else { logger_.info( "Web Profile HTTP server does not permit " + "Silverlight-style cross-domain access" ); } hServer.setDaemon( true ); if ( "rpc".equals( logType ) ) { return new RpcLoggingInternalServer( hServer, path, logOut ); } else if ( "xml".equals( logType ) ) { return new XmlLoggingInternalServer( hServer, path, logOut ); } else if ( "none".equals( logType ) || "http".equals( logType ) || logType == null || logType.length() == 0 ) { return new InternalServer( hServer, path ); } else { throw new IllegalArgumentException( "Unknown logType " + logType ); } } /** * Sets the type of logging to use. * * @param logType logging type; * may be "http", "rpc", "xml", "none" or null */ public void setLogType( String logType ) { if ( logType == null || logType.equals( "http" ) || logType.equals( "rpc" ) || logType.equals( "xml" ) || logType.equals( "none" ) ) { logType_ = logType; } else { throw new IllegalArgumentException( "Unknown log type " + logType ); } } /** * Returns the type of logging to use. * * @return logging type; may be "http", "rpc", "xml", "none" or null */ public String getLogType() { return logType_; } /** * Sets the port number the server will run on. * If port=0, then an unused port will be used at run time. * * @param port port number */ public void setPort( int port ) { port_ = port; } /** * Returns the port number the server will run on. * * @return port number */ public int getPort() { return port_; } /** * Sets the path on the HTTP server at which the XML-RPC server * will reside. * * @param xmlrpcPath server path for XML-RPC server */ public void setXmlrpcPath( String xmlrpcPath ) { xmlrpcPath_ = xmlrpcPath; } /** * Returns the path on the HTTP server at which the XML-RPC server * will reside. * * @return XML-RPC path on server */ public String getXmlrpcPath() { return xmlrpcPath_; } /** * Sets whether Adobe Flash cross-domain workaround will be supported. * * @param allowFlash true iff supported */ public void setAllowFlash( boolean allowFlash ) { allowFlash_ = allowFlash; } /** * Indicates whether Adobe Flash cross-domain workaround * will be supported. * * @return true iff supported */ public boolean isAllowFlash() { return allowFlash_; } /** * Sets whether Microsoft Silverlight cross-domain workaround * will be supported. * * @param allowSilverlight true iff supported */ public void setAllowSilverlight( boolean allowSilverlight ) { allowSilverlight_ = allowSilverlight; } /** * Indicates whether Microsoft Silverlight cross-domain workaround * will be supported. * * @return true iff supported */ public boolean isAllowSilverlight() { return allowSilverlight_; } /** * Sets the authorization policy for external origins. * * @param oAuth authorizer */ public void setOriginAuthorizer( OriginAuthorizer oAuth ) { oAuth_ = oAuth; } /** * Returns the authorization policy for external origins. * * @return authorizer */ public OriginAuthorizer getOriginAuthorizer() { return oAuth_; } /** * Creates a socket on a given port to be used by the server this * object produces. * * @param port port number * @return new server socket */ protected ServerSocket createServerSocket( int port ) throws IOException { ServerSocket sock = new ServerSocket(); sock.setReuseAddress( true ); sock.bind( new InetSocketAddress( port ) ); return sock; } } } jsamp/src/java/org/astrogrid/samp/web/package.html0000664000175000017500000000007112730747754022001 0ustar sladensladen Classes relating to the SAMP Web Profile. jsamp/src/java/org/astrogrid/samp/web/ListMessageRestriction.java0000664000175000017500000001157712730747754025046 0ustar sladensladenpackage org.astrogrid.samp.web; import java.util.Map; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.hub.MessageRestriction; /** * General purpose implementation of MessageRestriction. * It allows to either whitelist or blacklist a given list of MType * patterns, with the option for client subscriptions to override * this policy by setting the "x-samp.mostly-harmless" key in the * annotation map corresponding to a given MType subscription. * * @author Mark Taylor * @since 23 Nov 2011 */ public class ListMessageRestriction implements MessageRestriction { private final boolean allow_; private final boolean useSubsInfo_; private final Subscriptions subs_; /** * Default list of MType patterns returned by {@link #getSafeMTypes}. */ public static String[] DEFAULT_SAFE_MTYPES = new String[] { "samp.app.*", "samp.msg.progress", "table.*", "image.*", "coord.*", "spectrum.*", "bibcode.*", "voresource.*", }; /** * System property used to specify a default list of known safe MTypes, * which the {@link #DEFAULT} policy will permit. * The value is a comma-separated list of MType patterns. */ public static final String SAFE_MTYPE_PROP = "jsamp.mtypes.safe"; /** * Default MessageRestriction implementation. * The current implementation allows a list of MTypes believed to be safe, * as given by calling {@link #getSafeMTypes}, and blocks all others. * However, client subscriptions may override this by annotating their * subscriptions with an entry having the key * "x-samp.mostly-harmless". * If this has the value "1" the MType thus annotated is allowed, * and if it has the value "0" it is blocked, regardless of the safe list. */ public static final MessageRestriction DEFAULT = new ListMessageRestriction( true, getSafeMTypes(), true ); /** * MessageRestriction that permits all MTypes, except as overridden * by x-samp.mostly-harmless annotations. */ public static final MessageRestriction ALLOW_ALL = new ListMessageRestriction( false, new String[ 0 ], true ) { public String toString() { return "ALLOW_ALL"; } }; /** * MessageRestriction that blocks all MTypes, except as overridden * by x-samp.mostly-harmless annotations. */ public static final MessageRestriction DENY_ALL = new ListMessageRestriction( true, new String[ 0 ], true ) { public String toString() { return "DENY_ALL"; } }; /** * Constructor. * * @param allow whether the sense of the mtypes list is those * that should be allowed (true) or blocked (false) * @param mtypes mtype patterns to be allowed or blocked * @param useSubsInfo if true, honour x-samp.mostly-harmless * subscription annotations */ public ListMessageRestriction( boolean allow, String[] mtypes, boolean useSubsInfo ) { allow_ = allow; useSubsInfo_ = useSubsInfo; subs_ = new Subscriptions(); for ( int im = 0; im < mtypes.length; im++ ) { subs_.addMType( mtypes[ im ] ); } } public boolean permitSend( String mtype, Map subsInfo ) { if ( useSubsInfo_ ) { Object markedHarmless = subsInfo.get( "samp.mostly-harmless" ); if ( markedHarmless == null ) { markedHarmless = subsInfo.get( "x-samp.mostly-harmless" ); } if ( "0".equals( markedHarmless ) ) { return false; } else if ( "1".equals( markedHarmless ) ) { return true; } } boolean knownSafe = ( ! allow_ ) ^ subs_.isSubscribed( mtype ); return knownSafe; } public String toString() { StringBuffer sbuf = new StringBuffer() .append( allow_ ? "Allow" : "Deny" ) .append( ' ' ) .append( subs_.keySet() ); if ( useSubsInfo_ ) { sbuf.append( "; " ) .append( "honour (x-)samp.mostly-harmless" ); } return sbuf.toString(); } /** * Returns a list of MType patterns which are permitted by the DEFAULT * policy. If the System Property {@value SAFE_MTYPE_PROP} exists, * its value is taken as a comma-separated list of known permitted MType * patterns. Otherwise, the {@link #DEFAULT_SAFE_MTYPES} array is returned. * * @return list of MTypes treated as harmless by default */ public static String[] getSafeMTypes() { String safeMtypes = System.getProperty( SAFE_MTYPE_PROP ); if ( safeMtypes == null ) { return DEFAULT_SAFE_MTYPES; } else { return safeMtypes.split( "," ); } } } jsamp/src/java/org/astrogrid/samp/web/AuthResourceBundle_de.java0000664000175000017500000000334012730747754024600 0ustar sladensladenpackage org.astrogrid.samp.web; /** * AuthResourceBundle with German text. * * @author Markus Demleitner * @author Mark Taylor * @since 1 Aug 2011 */ public class AuthResourceBundle_de extends AuthResourceBundle { /** * Constructor. */ public AuthResourceBundle_de() { super( new GermanContent() ); } /** * Content implementation for English. */ private static class GermanContent implements Content { public String windowTitle() { return "SAMP Zugriffskontrolle"; } public String appIntroductionLines() { return "Folgendes Programm (vermutlich im Browser laufend)\n" + "m\u00f6chte sich am SAMP Hub anmelden:"; } public String nameWord() { return "Name"; } public String originWord() { return "Auf Seite"; } public String undeclaredWord() { return "Nicht gegeben"; } public String privilegeWarningLines() { return "Wenn Sie dies zulassen, k\u00f6nnte der Dienst unter\n" + "Umst\u00e4nden auf Dateien oder andere Resourcen auf\n" + "Ihrem Rechner zugreifen k\u00f6nnen."; } public String adviceLines() { return "Lassen Sie die Verbindung nur zu, wenn Sie gerade\n" + "auf einer Seite, der Sie vertrauen, eine Handlung\n" + "ausgef\u00fchrt haben, die SAMP anspricht."; } public String questionLine() { return "Die Verbindung erlauben?"; } public String yesWord() { return "Ja"; } public String noWord() { return "Nein"; } } } jsamp/src/java/org/astrogrid/samp/web/ClientCallbackOperation.java0000664000175000017500000001301112730747754025075 0ustar sladensladenpackage org.astrogrid.samp.web; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import org.astrogrid.samp.Message; import org.astrogrid.samp.Response; import org.astrogrid.samp.client.CallableClient; /** * Represents one of the possible callbacks which may be invoked on a * CallableClient. The {@link #invoke} static method arranges for a Callback * acquired from the hub to be dispatched to a CallableClient. * * @author Mark Taylor * @since 3 Feb 2011 */ abstract class ClientCallbackOperation { private final String fqName_; private final Class[] sampSig_; private static final Map OPERATION_MAP = createOperationMap(); /** * Constructor. * * @param unqualified callback method name * @param signature of callback; an array of SAMP-friendly classes, * one for each argument */ private ClientCallbackOperation( String methodName, Class[] sampSig ) { fqName_ = WebClientProfile.WEBSAMP_CLIENT_PREFIX + methodName; sampSig_ = sampSig; } /** * Makes a call to a callable client of the method represented by * this operation with a given list of parameters. * No checking is performed on the parameter list. * *

      This method should be private really, but abstract private is * not permitted. * * @param client target callable client * @param paramList parameters for call, assumed to be valid */ abstract void dispatch( CallableClient client, List paramList ) throws Exception; /** * Dispatches a callback to a CallableClient. * * @param callback callback acquired from the hub * @param client client which should execute callback */ public static void invoke( Callback callback, CallableClient client ) throws Exception { callback.check(); String methodName = callback.getMethodName(); List paramList = callback.getParams(); ClientCallbackOperation op = (ClientCallbackOperation) OPERATION_MAP.get( methodName ); if ( op == null ) { throw new UnsupportedOperationException( "Unknown callback operation " + methodName ); } else { boolean sigOk = op.sampSig_.length == paramList.size(); for ( int i = 0; sigOk && i < op.sampSig_.length; i++ ) { sigOk = sigOk && op.sampSig_[ i ] .isAssignableFrom( paramList.get( i ).getClass() ); } if ( ! sigOk ) { throw new IllegalArgumentException( methodName + " callback signature mismatch" ); } else { op.dispatch( client, paramList ); } } } /** * Returns a map, keyed by unqualified operation name, * of known callback operations. * * @param String->ClientCallbackOperation map */ private static Map createOperationMap() { // First assemble an array of known callback operations. // It would be possible to assemble this array using reflection // on the CallableClient interface, but more trouble than it's // worth for three methods. ClientCallbackOperation[] operations = new ClientCallbackOperation[] { new ClientCallbackOperation( "receiveNotification", new Class[] { String.class, Map.class } ) { public void dispatch( CallableClient client, List params ) throws Exception { client.receiveNotification( (String) params.get( 0 ), new Message( (Map) params.get( 1 ) ) ); } }, new ClientCallbackOperation( "receiveCall", new Class[] { String.class, String.class, Map.class } ) { public void dispatch( CallableClient client, List params ) throws Exception { client.receiveCall( (String) params.get( 0 ), (String) params.get( 1 ), new Message( (Map) params.get( 2 ) ) ); } }, new ClientCallbackOperation( "receiveResponse", new Class[] { String.class, String.class, Map.class } ) { public void dispatch( CallableClient client, List params ) throws Exception { client.receiveResponse( (String) params.get( 0 ), (String) params.get( 1 ), new Response( (Map) params.get( 2 ) ) ); } }, }; // Turn it into a map keyed by operation name, and return. Map opMap = new HashMap(); for ( int i = 0; i < operations.length; i++ ) { ClientCallbackOperation op = operations[ i ]; opMap.put( op.fqName_, op ); } return Collections.unmodifiableMap( opMap ); } } jsamp/src/java/org/astrogrid/samp/web/LoggingCorsHttpServer.java0000664000175000017500000001776112730747754024645 0ustar sladensladenpackage org.astrogrid.samp.web; import java.net.ServerSocket; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.io.PrintStream; import java.io.UnsupportedEncodingException; import java.util.Iterator; import java.util.Map; /** * CorsHttpServer subclass which performs logging to a given print stream * at the HTTP level. Logging is not done through the logging system. * * @author Mark Taylor * @since 2 Feb 2011 */ public class LoggingCorsHttpServer extends CorsHttpServer { private final PrintStream out_; private int iSeq_; /** * Constructor. * * @param socket socket hosting the service * @param auth defines which domains requests will be permitted from * @param out destination stream for logging */ public LoggingCorsHttpServer( ServerSocket socket, OriginAuthorizer auth, PrintStream out ) throws IOException { super( socket, auth ); out_ = out; } public Response serve( Request request ) { int iseq; synchronized ( this ) { iseq = ++iSeq_; } logRequest( request, iseq ); return new LoggedResponse( super.serve( request ), iseq, "POST".equals( request.getMethod() ) ); } /** * Logs a given request. * * @param request HTTP request * @param iseq index of the request; unique integer for each request */ private void logRequest( Request request, int iseq ) { StringBuffer sbuf = new StringBuffer(); sbuf.append( '\n' ); appendBanner( sbuf, '>', iseq ); sbuf.append( request.getMethod() ) .append( ' ' ) .append( request.getUrl() ) .append( '\n' ); appendHeaders( sbuf, request.getHeaderMap() ); byte[] body = request.getBody(); if ( body != null && body.length > 0 ) { sbuf.append( '\n' ); try { sbuf.append( new String( request.getBody(), "utf-8" ) ); } catch ( UnsupportedEncodingException e ) { throw new AssertionError( "No utf-8??" ); } } out_.println( sbuf ); } /** * Adds a line to the given stringbuffer which indicates information * relating to a given sequence number follows. * * @param sbuf string buffer to add to * @param c filler character * @param iseq sequence number */ private void appendBanner( StringBuffer sbuf, char c, int iseq ) { String label = Integer.toString( iseq ); int nc = 75 - label.length(); for ( int i = 0; i < nc; i++ ) { sbuf.append( c ); } sbuf.append( ' ' ) .append( label ) .append( '\n' ); } /** * Adds HTTP header information to a string buffer. * * @param sbuf buffer to add lines to * @param map header key->value pair map */ private void appendHeaders( StringBuffer sbuf, Map map ) { for ( Iterator it = map.entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); sbuf.append( entry.getKey() ) .append( ": " ) .append( entry.getValue() ) .append( '\n' ); } } /** * HTTP response which will log its content at an appropriate time. */ private class LoggedResponse extends Response { private final Response base_; private final boolean logBody_; private final String headText_; private String bodyText_; /** * Constructor. * * @param base response on which this one is based * @param iseq sequence number of request that this is a response to * @param logBody true iff the body of the response is to be logged */ LoggedResponse( Response base, int iseq, boolean logBody ) { super( base.getStatusCode(), base.getStatusPhrase(), base.getHeaderMap() ); base_ = base; logBody_ = logBody; StringBuffer sbuf = new StringBuffer(); sbuf.append( '\n' ); appendBanner( sbuf, '<', iseq ); sbuf.append( getStatusCode() ) .append( ' ' ) .append( getStatusPhrase() ) .append( '\n' ); appendHeaders( sbuf, getHeaderMap() ); headText_ = sbuf.toString(); } public void writeBody( final OutputStream out ) throws IOException { // This method captures the logging output as well as writing // the body text to the given stream. The purpose of doing it // like this rather than writing the logging information // directly is so that this method can be harmlessly called // multiple times (doesn't normally happen, but can do sometimes). // Prepare an object (an OutputStream) to which you can write // the body content and then use its toString method to get // the loggable text. This loggable text is either the // body content itself, or an indication of how many bytes it // contained, depending on the logBody_ flag. final OutputStream lout = logBody_ ? (OutputStream) new ByteArrayOutputStream() { public String toString() { String txt; try { txt = new String( buf, 0, count, "utf-8" ); } catch ( UnsupportedEncodingException e ) { txt = e.toString(); } return "\n" + txt + "\n"; } } : (OutputStream) new CountOutputStream() { public String toString() { return count_ > 0 ? "<" + count_ + " bytes of output omitted>\n" : ""; } }; // Prepare an output stream which writes both to the normal // response destination and to the content logging object we've // just set up. OutputStream teeOut = new OutputStream() { public void write( byte[] b ) throws IOException { lout.write( b ); out.write( b ); } public void write( byte[] b, int off, int len ) throws IOException { lout.write( b, off, len ); out.write( b, off, len ); } public void write( int b ) throws IOException { lout.write( b ); out.write( b ); } }; // Write the body content to the response output stream, // and store the loggable output. String slog; try { base_.writeBody( teeOut ); slog = lout.toString(); } catch ( IOException e ) { slog = "log error? " + e + "\n"; } bodyText_ = slog; } public void writeResponse( final OutputStream out ) throws IOException { super.writeResponse( out ); out_.print( headText_ + bodyText_ ); } } /** * OutputStream subclass which counts the number of bytes it is being * asked to write, but otherwise does nothing. */ private static class CountOutputStream extends OutputStream { long count_; // number of bytes counted so far public void write( byte[] b ) { count_ += b.length; } public void write( byte[] b, int off, int len ) { count_ += len; } public void write( int b ) { count_++; } } } jsamp/src/java/org/astrogrid/samp/ErrInfo.java0000664000175000017500000001230512730747754021155 0ustar sladensladenpackage org.astrogrid.samp; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.PrintStream; import java.util.Map; /** * Represents the error information associated with a SAMP response. * This corresponds to the samp.error entry in a response map. * * @author Mark Taylor * @since 14 Jul 2008 */ public class ErrInfo extends SampMap { /** Key for short description of what went wrong. */ public static final String ERRORTXT_KEY = "samp.errortxt"; /** Key for free-form text given more information about the error. */ public static final String USERTXT_KEY = "samp.usertxt"; /** Key for debugging information such as a stack trace. */ public static final String DEBUGTXT_KEY = "samp.debugtxt"; /** Key for a numeric or textual code identifying the error. */ public static final String CODE_KEY = "samp.code"; private static final String[] KNOWN_KEYS = new String[] { ERRORTXT_KEY, USERTXT_KEY, DEBUGTXT_KEY, CODE_KEY, }; /** * Constructs an empty ErrInfo. */ public ErrInfo() { super( KNOWN_KEYS ); } /** * Constructs an ErrInfo based on a given Throwable. * * @param e error */ public ErrInfo( Throwable e ) { this(); String txt = e.getMessage(); if ( txt == null || txt.trim().length() == 0 ) { txt = e.getClass().getName(); } put( ERRORTXT_KEY, txt ); put( USERTXT_KEY, e.toString() ); put( DEBUGTXT_KEY, getStackTrace( e ) ); put( CODE_KEY, e.getClass().getName() ); } /** * Constructs an ErrInfo based on an existing map. * * @param map map containing initial data for this object */ public ErrInfo( Map map ) { this(); putAll( map ); } /** * Constructs an ErrInfo with a given {@link #ERRORTXT_KEY} value. * * @param errortxt short string describing what went wrong */ public ErrInfo( String errortxt ) { this(); put( ERRORTXT_KEY, errortxt ); } /** * Sets the value for the {@link #ERRORTXT_KEY} key. * * @param errortxt short string describing what went wrong */ public void setErrortxt( String errortxt ) { put( ERRORTXT_KEY, errortxt ); } /** * Returns the value for the {@link #ERRORTXT_KEY} key. * * @return short string describing what went wrong */ public String getErrortxt() { return getString( ERRORTXT_KEY ); } /** * Sets the value for the {@link #USERTXT_KEY} key. * * @param usertxt free-form string giving more detail on the error */ public void setUsertxt( String usertxt ) { put( USERTXT_KEY, usertxt ); } /** * Returns the value for the {@link #USERTXT_KEY} key. * * @return free-form string giving more detail on the error */ public String getUsertxt() { return getString( USERTXT_KEY ); } /** * Sets the value for the {@link #DEBUGTXT_KEY} key. * * @param debugtxt string containing debugging information, such as a * a stack trace */ public void setDebugtxt( String debugtxt ) { put( DEBUGTXT_KEY, debugtxt ); } /** * Returns the value for the {@link #DEBUGTXT_KEY} key. * * @return string containing debugging information, such as a stack trace */ public String getDebugtxt() { return getString( DEBUGTXT_KEY ); } /** * Sets the value for the {@link #CODE_KEY} key. * * @param code numeric or textual code identifying the error */ public void setCode( String code ) { put( CODE_KEY, code ); } /** * Returns the value for the {@link #CODE_KEY} key. * * @return numeric or textual code identifying the error */ public String getCode() { return getString( CODE_KEY ); } public void check() { super.check(); checkHasKeys( new String[] { ERRORTXT_KEY, } ); } /** * Returns a given map as an ErrInfo object. * * @param map map * @return errInfo */ public static ErrInfo asErrInfo( Map map ) { return ( map instanceof ErrInfo || map == null ) ? (ErrInfo) map : new ErrInfo( map ); } /** * Generates a string containing a stack trace for a given throwable. * * @param e error * @return stacktrace */ private static String getStackTrace( Throwable e ) { byte[] bbuf; try { ByteArrayOutputStream bOut = new ByteArrayOutputStream(); e.printStackTrace( new PrintStream( bOut ) ); bOut.close(); bbuf = bOut.toByteArray(); } catch ( IOException ioex ) { assert false; return "error generating stacktrace"; } StringBuffer sbuf = new StringBuffer( bbuf.length ); for ( int ic = 0; ic < bbuf.length; ic++ ) { char c = (char) bbuf[ ic ]; if ( SampUtils.isStringChar( c ) ) { sbuf.append( c ); } } return sbuf.toString(); } } jsamp/src/java/org/astrogrid/samp/bridge/0000775000175000017500000000000012730747754020201 5ustar sladensladenjsamp/src/java/org/astrogrid/samp/bridge/Bridge.java0000664000175000017500000004410112730747754022240 0ustar sladensladenpackage org.astrogrid.samp.bridge; import java.io.File; import java.io.IOException; import java.net.InetAddress; import java.net.MalformedURLException; import java.net.URL; import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Arrays; import java.util.Iterator; import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.HubConnector; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.httpd.UtilServer; import org.astrogrid.samp.xmlrpc.LockInfo; import org.astrogrid.samp.xmlrpc.StandardClientProfile; import org.astrogrid.samp.xmlrpc.XmlRpcKit; /** * Runs a bridging service between two or more hubs. * For each client on one hub, a proxy client appears on all other * participating hubs. These proxies can be treated in exactly the * same way as normal clients by other registered clients; any * messages sent to/from them will be marshalled over the bridge * in a transparent way. One application for this is to allow * collaboration between users who each have their own hub running. * *

      A {@link java.lang.Object#notifyAll notifyAll} call is made on * the Bridge object whenever the number of live hubs connected by * the bridge changes. * * @author Mark Taylor * @since 15 Jul 2009 */ public class Bridge { private final ProxyManager[] proxyManagers_; private static final Logger logger_ = Logger.getLogger( Bridge.class.getName() ); /** * Constructor. * * @param profiles array of SAMP profile objects, one for each * hub which is to participate in the bridge */ public Bridge( ClientProfile[] profiles ) throws IOException { int nhub = profiles.length; proxyManagers_ = new ProxyManager[ nhub ]; UtilServer server = UtilServer.getInstance(); for ( int ih = 0; ih < nhub; ih++ ) { proxyManagers_[ ih ] = new ProxyManager( profiles[ ih ], server ) { protected void managerConnectionChanged( boolean isConnected ) { super.managerConnectionChanged( isConnected ); synchronized ( Bridge.this ) { Bridge.this.notifyAll(); } } }; } for ( int ih = 0; ih < nhub; ih++ ) { proxyManagers_[ ih ].init( proxyManagers_ ); } for ( int ih = 0; ih < nhub; ih++ ) { proxyManagers_[ ih ].getManagerConnector().setAutoconnect( 0 ); } } /** * Returns the client profiles which define the hubs this bridge links. * * @return profile array, one for each connected hub */ public ClientProfile[] getProfiles() { int nhub = proxyManagers_.length; ClientProfile[] profiles = new ClientProfile[ nhub ]; for ( int ih = 0; ih < nhub; ih++ ) { profiles[ ih ] = proxyManagers_[ ih ].getProfile(); } return profiles; } /** * Returns the hub connectors representing the bridge client running * on each linked hub. Note this does not include any proxy clients, * only the one-per-hub manager clients. * * @return array of bridge manager clients, one for each hub * (in corresponding positions to the profiles) */ public HubConnector[] getBridgeClients() { int nhub = proxyManagers_.length; HubConnector[] connectors = new HubConnector[ nhub ]; for ( int ih = 0; ih < nhub; ih++ ) { connectors[ ih ] = proxyManagers_[ ih ].getManagerConnector(); } return connectors; } /** * Sets up a URL exporter for one of the hubs. This will attempt to * edit transmitted data contents for use in remote contexts; * the main job is to adjust loopback host references in URLs * (127.0.0.1 or localhost) to become fully qualified domain names * for non-local use. It's not an exact science, but a best effort * is made. * * @param index index of the profile for which to export URLs * @param host the name substitute for loopback host identifiers * on the host on which that profile's hub is running */ public void exportUrls( int index, String host ) { proxyManagers_[ index ] .setExporter( new UrlExporter( host, isLocalHost( host ) ) ); } /** * Starts this bridge running. * * @return true iff all the participating hubs have been contacted * successfully */ public boolean start() { HubConnector[] connectors = getBridgeClients(); boolean allConnected = true; for ( int ih = 0; ih < connectors.length; ih++ ) { HubConnector connector = connectors[ ih ]; connector.setActive( true ); allConnected = allConnected && connector.isConnected(); } return allConnected; } /** * Stops this bridge running. * All associated manager and proxy clients are unregistered. */ public void stop() { HubConnector[] connectors = getBridgeClients(); for ( int ih = 0; ih < connectors.length; ih++ ) { connectors[ ih ].setActive( false ); } } /** * Returns the number of hubs currently connected by this bridge. * Only connections which are currently live are counted. * * @return number of live hubs */ private int getConnectionCount() { HubConnector[] connectors = getBridgeClients(); int nc = 0; for ( int ih = 0; ih < connectors.length; ih++ ) { if ( connectors[ ih ].isConnected() ) { nc++; } } return nc; } /** * Indicates whether a given hostname corresponds to the local host. * * @param host hostname to test * @return true if host is known to be the local host */ private static boolean isLocalHost( String host ) { if ( host == null ) { return false; } if ( SampUtils.getLocalhost().equals( host ) ) { return true; } try { InetAddress hostAddr = InetAddress.getByName( host ); return hostAddr != null && ( hostAddr.isLoopbackAddress() || hostAddr.equals( InetAddress.getLocalHost() ) ); } catch ( UnknownHostException e ) { return false; } } /** * Main method. Runs a bridge. */ public static void main( String[] args ) throws IOException { // Unless specially requested, make sure that the local host // is referred to by something publicly useful, not the loopback // address, which would be no good if there will be communications // to/from an external host. String hostspec = System.getProperty( SampUtils.LOCALHOST_PROP ); if ( hostspec == null ) { System.setProperty( SampUtils.LOCALHOST_PROP, "[hostname]" ); } // Run the application. int status = runMain( args ); if ( status != 0 ) { System.exit( status ); } } /** * Does the work for the main method. * Use -help flag. */ public static int runMain( String[] args ) throws IOException { String usage = new StringBuffer() .append( "\n Usage:" ) .append( "\n " ) .append( Bridge.class.getName() ) .append( "\n " ) .append( " [-help]" ) .append( " [-/+verbose]" ) .append( " [-[no]exporturls]" ) .append( "\n " ) .append( " [-nostandard]" ) .append( " [-sampdir ]" ) .append( " [-sampfile ]" ) .append( " [-sampurl ]" ) .append( "\n " ) .append( " [-keys ]" ) .append( " [-profile ]" ) .append( "\n" ) .toString(); List argList = new ArrayList( Arrays.asList( args ) ); // Handle administrative flags - best done before other parameters. int verbAdjust = 0; for ( Iterator it = argList.iterator(); it.hasNext(); ) { String arg = (String) it.next(); if ( arg.equals( "-v" ) || arg.equals( "-verbose" ) ) { it.remove(); verbAdjust--; } else if ( arg.equals( "+v" ) || arg.equals( "+verbose" ) ) { it.remove(); verbAdjust++; } else if ( arg.equals( "-h" ) || arg.equals( "-help" ) || arg.equals( "--help" ) ) { it.remove(); System.out.println( usage ); return 0; } } // Adjust logging in accordance with verbosity flags. int logLevel = Level.WARNING.intValue() + 100 * verbAdjust; Logger.getLogger( "org.astrogrid.samp" ) .setLevel( Level.parse( Integer.toString( logLevel ) ) ); // Assemble list of profiles to use from command line arguments. List profileList = new ArrayList(); XmlRpcKit xmlrpcKit = XmlRpcKit.getInstance(); ClientProfile standardProfile = new ClientProfile() { public boolean isHubRunning() { return StandardClientProfile.getInstance().isHubRunning(); } public HubConnection register() throws SampException { return StandardClientProfile.getInstance().register(); } public String toString() { return "standard"; } }; profileList.add( standardProfile ); Boolean reqExportUrls = null; for ( Iterator it = argList.iterator(); it.hasNext(); ) { String arg = (String) it.next(); // Determine whether to export localhost-type URLs. if ( arg.equals( "-exporturls" ) ) { reqExportUrls = Boolean.TRUE; } else if ( arg.equals( "-noexporturls" ) ) { reqExportUrls = Boolean.FALSE; } // Accumulate various profiles. else if ( arg.equals( "-standard" ) ) { it.remove(); profileList.remove( standardProfile ); profileList.add( standardProfile ); } else if ( arg.equals( "-nostandard" ) ) { it.remove(); profileList.remove( standardProfile ); } else if ( arg.equals( "-sampfile" ) && it.hasNext() ) { it.remove(); String fname = (String) it.next(); it.remove(); final File lockfile = new File( fname ); profileList.add( new StandardClientProfile( xmlrpcKit ) { public LockInfo getLockInfo() throws IOException { return LockInfo .readLockFile( SampUtils.fileToUrl( lockfile ) ); } public String toString() { return lockfile.toString(); } } ); } else if ( arg.equals( "-sampdir" ) && it.hasNext() ) { it.remove(); final String dirname = (String) it.next(); it.remove(); final File lockfile = new File( dirname, StandardClientProfile.LOCKFILE_NAME ); profileList.add( new StandardClientProfile( xmlrpcKit ) { public LockInfo getLockInfo() throws IOException { return LockInfo .readLockFile( SampUtils.fileToUrl( lockfile ) ); } public String toString() { return dirname; } } ); } else if ( arg.equals( "-sampurl" ) && it.hasNext() ) { it.remove(); final URL lockUrl = new URL( (String) it.next() ); it.remove(); profileList.add( new StandardClientProfile( xmlrpcKit ) { public LockInfo getLockInfo() throws IOException { return LockInfo.readLockFile( lockUrl ); } public String toString() { return lockUrl.toString(); } } ); } else if ( arg.equals( "-keys" ) && it.hasNext() ) { it.remove(); String endpoint = (String) it.next(); final URL url; try { url = new URL( endpoint ); } catch ( MalformedURLException e ) { System.err.println( "Not a URL: " + endpoint ); System.err.println( usage ); return 1; } it.remove(); if ( ! it.hasNext() ) { System.err.println( usage ); return 1; } final String secret = (String) it.next(); it.remove(); profileList.add( new StandardClientProfile( xmlrpcKit ) { public LockInfo getLockInfo() throws IOException { return new LockInfo( secret, url.toString() ); } public String toString() { return url.toString(); } } ); } else if ( arg.equals( "-profile" ) && it.hasNext() ) { it.remove(); String cname = (String) it.next(); it.remove(); final ClientProfile profile; try { profile = (ClientProfile) Class.forName( cname ).newInstance(); } catch ( Exception e ) { System.err.println( "Error instantiating class " + cname + "; " + e ); System.err.println( usage ); return 1; } profileList.add( profile ); } else { it.remove(); System.err.println( usage ); return 1; } } assert argList.isEmpty(); // Get the array of profiles to bridge between. ClientProfile[] profiles = (ClientProfile[]) profileList.toArray( new ClientProfile[ 0 ] ); if ( profiles.length < 2 ) { System.err.println( ( profiles.length == 0 ? "No" : "Only one" ) + " hub specified - no bridging to be done" ); if ( args.length == 0 ) { System.err.println( usage ); } return 1; } // Try to work out what hosts all the profiles are running on. boolean allLocal = true; String[] hosts = new String[ profiles.length ]; for ( int ip = 0; ip < profiles.length; ip++ ) { ClientProfile profile = profiles[ ip ]; String host = null; if ( profile == standardProfile ) { host = SampUtils.getLocalhost(); } else if ( profile instanceof StandardClientProfile ) { URL xurl = ((StandardClientProfile) profile).getLockInfo() .getXmlrpcUrl(); if ( xurl != null ) { host = xurl.getHost(); } } allLocal = allLocal && isLocalHost( host ); hosts[ ip ] = host; } // Work out whether to export URLs. final boolean exporturls; if ( reqExportUrls == null ) { if ( allLocal ) { logger_.info( "All hubs apparently on local host; " + "no URL exporting will be attempted" ); exporturls = false; } else { logger_.info( "Bridge apparently running between hosts; " + "URL exporting will be attempted" ); exporturls = true; } } else { exporturls = reqExportUrls.booleanValue(); logger_.info( "By request, URL exporting " + ( exporturls ? "will" : "will not" ) + " be attempted" ); } // Create a bridge. Bridge bridge = new Bridge( profiles ); // Arrange to export URLs if appropriate. if ( exporturls ) { for ( int ip = 0; ip < profiles.length; ip++ ) { String host = hosts[ ip ]; if ( host != null ) { try { InetAddress addr = InetAddress.getByName( host ); if ( addr.isLoopbackAddress() ) { addr = InetAddress .getByName( SampUtils.getLocalhost() ); } String ehost = addr.getCanonicalHostName(); bridge.exportUrls( ip, ehost ); } catch ( UnknownHostException e ) { logger_.log( Level.WARNING, "Can't export URLs for host " + host, e ); } } } } // Start the bridge. if ( ! bridge.start() ) { System.err.println( "Couldn't contact all hubs" ); return 1; } // Wait until there's only one hub connected. try { synchronized ( bridge ) { while ( bridge.getConnectionCount() > 1 ) { bridge.wait(); } } } catch ( InterruptedException e ) { } return 0; } } jsamp/src/java/org/astrogrid/samp/bridge/UrlExporter.java0000664000175000017500000001243012730747754023337 0ustar sladensladenpackage org.astrogrid.samp.bridge; import java.io.File; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import java.util.Iterator; import java.util.List; import java.util.ListIterator; import java.util.Map; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.astrogrid.samp.httpd.UtilServer; /** * Exports SAMP data objects which have been created on a given host * for use in a remote context. The job that needs doing is to * convert URLs which reference the host in a way that only makes sense * locally (as a loopback address 127.0.0.1 or localhost) to a form * which can be used on remote hosts. * *

      This is not an exact science; a best effort is made. * * @author Mark Taylor * @since 29 Jul 2009 */ class UrlExporter { private final String host_; private final boolean exportFiles_; private static final Logger logger_ = Logger.getLogger( UrlExporter.class.getName() ); private static final Pattern LOCALHOST_REGEX = Pattern.compile( "(http://|ftp://)" + "(127\\.0\\.0\\.1|localhost)" + "([:/].*)" ); private static final Pattern FILE_REGEX = Pattern.compile( "(file://)" + "([^/]*)" + "/.*" ); /** * Constructor. * * @param host public name of the host to which loopback addresses * refer * @param exportFiles whether to export file-protocol URLs * by turning them into http ones; * this only makes sense if the current JVM * is running on a machine which can see * host's filesystem(s) */ public UrlExporter( String host, boolean exportFiles ) { host_ = host; exportFiles_ = exportFiles; } /** * Exports a single string for remote usage. * If it looks like a URL, it's changed. Not foolproof. * * @param text string to assess * @return copy of text if it's not a URL, otherwise a possibly * edited URL with the same content */ public String exportString( String text ) { String t2 = doExportString( text ); if ( t2 != null && ! t2.equals( text ) ) { logger_.info( "Exported string \"" + text + "\" -> \"" + t2 + '"' ); } return t2; } /** * Does the work for {@link #exportString}. * * @param text string to assess * @return copy of text if it's not a URL, otherwise a URL with a * possibly edited host part */ private String doExportString( String text ) { Matcher localMatcher = LOCALHOST_REGEX.matcher( text ); if ( localMatcher.matches() ) { return localMatcher.group( 1 ) + host_ + localMatcher.group( 3 ); } else if ( exportFiles_ && FILE_REGEX.matcher( text ).matches() ) { try { URL fileUrl = new URL( text ); String path = fileUrl.getPath(); if ( File.separatorChar != '/' ) { path = path.replace( '/', File.separatorChar ); } File file = new File( path ); if ( file.canRead() && ! file.isDirectory() ) { URL expUrl = UtilServer.getInstance() .getMapperHandler() .addLocalUrl( fileUrl ); if ( expUrl != null ) { return expUrl.toString(); } } } catch ( MalformedURLException e ) { // not a URL at all - don't attempt to export it } catch ( IOException e ) { // something else went wrong - leave alone } return text; } else { return text; } } /** * Exports a list for remote usage by changing its contents in place. * * @param list list to edit */ public void exportList( List list ) { for ( ListIterator it = list.listIterator(); it.hasNext(); ) { Object value = it.next(); if ( value instanceof String ) { it.set( exportString( (String) value ) ); } else if ( value instanceof List ) { exportList( (List) value ); } else if ( value instanceof Map ) { exportMap( (Map) value ); } } } /** * Exports a map for remote usage by changing its contents in place. * * @param map map to edit */ public void exportMap( Map map ) { for ( Iterator it = map.entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); Object value = entry.getValue(); if ( value instanceof String ) { entry.setValue( exportString( (String) value ) ); } else if ( value instanceof List ) { exportList( (List) value ); } else if ( value instanceof Map ) { exportMap( (Map) value ); } } } } jsamp/src/java/org/astrogrid/samp/bridge/IconAdjuster.java0000664000175000017500000001307712730747754023446 0ustar sladensladenpackage org.astrogrid.samp.bridge; import java.awt.image.BufferedImage; import java.awt.image.RenderedImage; import java.io.FileNotFoundException; import java.io.IOException; import java.io.OutputStream; import java.net.MalformedURLException; import java.net.URL; import java.util.HashMap; import java.util.Map; import javax.imageio.ImageIO; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.httpd.HttpServer; /** * HttpServer handler for turning the URL of one icon into the URL of * another, related icon. * * @author Mark Taylor * @since 23 Jul 2009 */ abstract class IconAdjuster implements HttpServer.Handler { private final URL baseUrl_; private static final String OUTPUT_FORMAT_NAME = "png"; private static final String OUTPUT_MIME_TYPE = "image/png"; /** * Constructor. * * @param server server with which this handler will be used * @param basePath path at which the dynamic URLs generated by * this server will be rooted */ public IconAdjuster( HttpServer server, String basePath ) { if ( ! basePath.startsWith( "/" ) ) { basePath = "/" + basePath; } if ( ! basePath.endsWith( "/" ) ) { basePath = basePath + "/"; } try { baseUrl_ = new URL( server.getBaseUrl(), basePath ); } catch ( MalformedURLException e ) { throw (AssertionError) new AssertionError().initCause( e ); } } /** * Produces an adjusted image for serving. * * @param inImage input image * @return adjusted version of inImage */ public abstract RenderedImage adjustImage( BufferedImage inImage ); /** * Returns a URL at which the dynamically adjusted version of the icon * at the given URL will be served. * * @param iconUrl URL of existing icon (GIF, PNG or JPEG) */ public URL exportAdjustedIcon( URL iconUrl ) { try { return new URL( baseUrl_ + "?" + SampUtils.uriEncode( iconUrl.toString() ) ); } catch ( MalformedURLException e ) { throw (AssertionError) new AssertionError().initCause( e ); } } /** * Returns the URL at which the underlying icon for the one represented * by the given server path. The resourcePath should be * the path part of a URL returned from an earlier call to * {@link #exportAdjustedIcon}. * * @param resourcePath path part of a URL requesting an adjusted icon * @return original icon corresponding to resourcePath, or null * if it doesn't look like a path this object dispensed */ private URL getOriginalUrl( String resourcePath ) throws MalformedURLException { // If there's no query part, it's not one of ours. URL resourceUrl = new URL( baseUrl_, resourcePath ); String query = resourceUrl.getQuery(); if ( query == null ) { return null; } else { // If the base does not match our base URL, it's not one of ours. String base = resourceUrl.toString(); base = base.substring( 0, base.length() - query.length() ); if ( ! base.equals( baseUrl_.toString() + "?" ) ) { return null; } // Otherwise, try to interpret the query as a URL. // It should be the URL of the original icon. // If it's not, an exception will result. String qurl; try { qurl = SampUtils.uriDecode( query ); } catch ( RuntimeException e ) { throw (MalformedURLException) new MalformedURLException().initCause( e ); } return new URL( qurl ); } } public HttpServer.Response serveRequest( HttpServer.Request request ) { URL baseIconUrl; try { baseIconUrl = getOriginalUrl( request.getUrl() ); } catch ( MalformedURLException e ) { return HttpServer.createErrorResponse( 404, "Not found", e ); } if ( baseIconUrl == null ) { return null; } // Prepare the headers. Map hdrMap = new HashMap(); hdrMap.put( "Content-Type", OUTPUT_MIME_TYPE ); // Generate the response object. String method = request.getMethod(); if ( "HEAD".equals( method ) ) { return new HttpServer.Response( 200, "OK", hdrMap ) { public void writeBody( OutputStream out ) { } }; } else if ( "GET".equals( method ) ) { BufferedImage baseImage; try { baseImage = ImageIO.read( baseIconUrl ); if ( baseImage == null ) { throw new FileNotFoundException( baseIconUrl.toString() ); } } catch ( IOException e ) { return HttpServer .createErrorResponse( 500, "Server I/O error", e ); } final RenderedImage adjustedImage = adjustImage( baseImage ); return new HttpServer.Response( 200, "OK", hdrMap ) { public void writeBody( OutputStream out ) throws IOException { ImageIO.write( adjustedImage, OUTPUT_FORMAT_NAME, out ); } }; } else { return HttpServer .create405Response( new String[] { "GET", "HEAD" } ); } } } jsamp/src/java/org/astrogrid/samp/bridge/ProxyManager.java0000664000175000017500000007651012730747754023471 0ustar sladensladenpackage org.astrogrid.samp.bridge; import java.awt.Image; import java.awt.AlphaComposite; import java.awt.Composite; import java.awt.Graphics2D; import java.awt.image.BufferedImage; import java.awt.image.RenderedImage; import java.io.IOException; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.logging.Logger; import org.astrogrid.samp.Client; import org.astrogrid.samp.ErrInfo; import org.astrogrid.samp.Message; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.RegInfo; import org.astrogrid.samp.Response; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.HubConnector; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.client.TrackedClientSet; import org.astrogrid.samp.httpd.HttpServer; import org.astrogrid.samp.httpd.UtilServer; /** * Takes care of client connections for the SAMP Bridge. * An instance of this class is associated with a given 'local' hub * participating in the bridge, and makes the following connections: *

        *
      1. On the local hub, one connection which is there to monitor * client changes
      2. *
      3. On each remote hub participating in the bridge, one 'proxy' connection * for every client on this manager's local hub.
      4. *
      * Callbacks from the hub to the proxy clients can be tunnelled by this * proxy manager to their true destination on the local hub. * Note that each proxy manager needs the cooperation of all the other * proxy managers (the ones associated with the other bridged hubs) to * make this work, so each instance of this class must be made aware of * the other ProxyMangers before use (see {@link #init}). * * @author Mark Taylor * @since 15 Jul 2009 */ class ProxyManager { private final ClientProfile localProfile_; private final UtilServer server_; private final HubConnector pmConnector_; private final Map connectionMap_; // local client ID -> HubConnection[] private final Map tagMap_; private final IconAdjuster iconAdjuster_; private ProxyManager[] remoteManagers_; private UrlExporter exporter_; private boolean useProxyHub_; private int nRemote_; private static final Logger logger_ = Logger.getLogger( ProxyManager.class.getName() ); /** * Constructor. * * @param localProfile profile for connection to this manager's local hub * @param server server instance */ public ProxyManager( ClientProfile localProfile, UtilServer server ) { localProfile_ = localProfile; server_ = server; // Set up the local hub connection to monitor client list changes. pmConnector_ = new HubConnector( localProfile, new ProxyManagerClientSet() ) { protected void connectionChanged( boolean isConnected ) { super.connectionChanged( isConnected ); managerConnectionChanged( isConnected ); } }; Metadata meta = new Metadata(); meta.setName( "bridge" ); meta.setDescriptionText( "Bridge between hubs" ); try { meta.setIconUrl( server_ .exportResource( "/org/astrogrid/samp/images/" + "bridge.png" ) .toString() ); } catch ( IOException e ) { logger_.warning( "Couldn't set icon" ); } meta.put( "author.name", "Mark Taylor" ); meta.put( "author.email", "m.b.taylor@bristol.ac.uk" ); pmConnector_.declareMetadata( meta ); Subscriptions subs = pmConnector_.computeSubscriptions(); pmConnector_.declareSubscriptions( subs ); // Set up other required data structures. connectionMap_ = Collections.synchronizedMap( new HashMap() ); tagMap_ = Collections.synchronizedMap( new HashMap() ); iconAdjuster_ = new ProxyIconAdjuster(); server_.getServer().addHandler( iconAdjuster_ ); } /** * Returns the profile for this manager's local hub. * * @return profile */ public ClientProfile getProfile() { return localProfile_; } /** * Returns the hub connector used by this manager for client monitoring * on the local hub. * * @return hub connector */ public HubConnector getManagerConnector() { return pmConnector_; } /** * Sets an object which is used to export SAMP data contents for use * in remote contexts. * * @param exporter new exporter; may be null */ public void setExporter( UrlExporter exporter ) { exporter_ = exporter; } /** * Sets whether remote proxy should be generated for the local client * representing the local hub. * Default is not, since they are not very interesting to talk to. * * @param useProxyHub true iff the client representing the local hub * should be proxied remotely */ public void setUseProxyHub( boolean useProxyHub ) { useProxyHub_ = useProxyHub; } /** * Prepares this manager for use by informing it about all its sibling * managers. This must be done before the bridge can start operations. * * @param allManagers array of ProxyManagers including this one, * one for each hub participating in the bridge */ public void init( ProxyManager[] allManagers ) { // Store an array of all the other managers, excluding this one, // for later use. List remoteList = new ArrayList(); int selfCount = 0; for ( int im = 0; im < allManagers.length; im++ ) { ProxyManager pm = allManagers[ im ]; if ( pm == this ) { selfCount++; } else { remoteList.add( pm ); } } if ( selfCount != 1 ) { throw new IllegalArgumentException( "Self not in list once" ); } remoteManagers_ = (ProxyManager[]) remoteList.toArray( new ProxyManager[ 0 ] ); nRemote_ = remoteManagers_.length; assert nRemote_ == allManagers.length - 1; } public String toString() { return localProfile_.toString(); } /** * Returns the connection on the hub associated with a remote * proxy manager which is the proxy for a given local client. * * @param remoteManager proxy manager for a remote bridged hub * @param localClientId client ID of a client registered with * this manager's local hub * @return proxy connection */ private HubConnection getProxyConnection( ProxyManager remoteManager, String localClientId ) { HubConnection[] proxyConnections = (HubConnection[]) connectionMap_.get( localClientId ); return proxyConnections == null ? null : proxyConnections[ getManagerIndex( remoteManager ) ]; } /** * Deletes the record of the connection on the hub associated with * a remote proxy manager which is the proxy for a given local client. * This proxy can no longer be used. * * @param remoteManager proxy manager for a remote bridged hub * @param localClientId client ID of a client registered with * this manager's local hub */ private void removeProxyConnection( ProxyManager remoteManager, String localClientId ) { HubConnection[] proxyConnections = (HubConnection[]) connectionMap_.get( localClientId ); if ( proxyConnections != null ) { proxyConnections[ getManagerIndex( remoteManager ) ] = null; } } /** * Returns the index by which this manager labels a given remote * proxy manager. * * @param remoteManager manager to locate * @return index of remoteManager in the list */ private int getManagerIndex( ProxyManager remoteManager ) { return Arrays.asList( remoteManagers_ ).indexOf( remoteManager ); } /** * Returns the metadata to use for the remote proxy of a local client. * This resembles the metadata of the local client itself, but may * have some adjustments. * * @param localClient local client * @return metadata to use for client's remote proxy */ private Metadata getProxyMetadata( Client localClient ) { Metadata meta = localClient.getMetadata(); if ( meta == null ) { return null; } else { meta = new Metadata( meta ); if ( exporter_ != null ) { exporter_.exportMap( meta ); } meta.setName( proxyName( meta.getName() ) ); if ( meta.getIconUrl() != null ) { URL iconUrl = proxyIconUrl( meta.getIconUrl() ); meta.setIconUrl( iconUrl == null ? null : iconUrl.toString() ); } meta.put( "bridge.proxy.source", ProxyManager.this.toString() ); } return meta; } /** * Returns the name to be used for a proxy client given its local name. * * @param localName local name * @return proxy name */ private String proxyName( String localName ) { return localName == null ? "(proxy)" : localName + " (proxy)"; } /** * Returns the icon to be used for a proxy client given its local icon. * * @param localIconUrl URL for local icon * @return URL for proxy icon */ private URL proxyIconUrl( URL localIconUrl ) { return localIconUrl != null ? iconAdjuster_.exportAdjustedIcon( localIconUrl ) : localIconUrl; } /** * Returns the subscriptions to use for the remote proxy of a local client. * This resembles the subscriptions of the local client itself, but may * have some adjustments. * * @param localClient local client * @return subscriptions to use for client's remote proxy */ private Subscriptions getProxySubscriptions( Client client ) { Subscriptions subs = client.getSubscriptions(); if ( subs == null ) { return null; } else { // Remove subscriptions to most hub administrative MTypes. // These should not be delivered from the remote hub to the // local client, since the local client should only receive // such messages from its own hub. Note this does not mean // that the local client will not be informed about changes // to clients on the remote hubs; this information will be // relayed by the local hub as a consequence of proxies from // other ProxyManagers making register/declare/etc calls // on this manager's local hub. subs = new Subscriptions( subs ); subs.remove( "samp.hub.event.shutdown" ); subs.remove( "samp.hub.event.register" ); subs.remove( "samp.hub.event.unregister" ); subs.remove( "samp.hub.event.metadata" ); subs.remove( "samp.hub.event.subscriptions" ); if ( exporter_ != null ) { exporter_.exportMap( subs ); } return subs; } } /** * Called when this ProxyManager's connector has been disconnected * (for whatever reason) from its local hub. * It makes sure that any proxies from other ProxyManagers to the local * hub are unregistered, so that no further bridge activity takes * place on the local hub. * * @param isConnected true for a connection; false for a disconnection */ protected void managerConnectionChanged( boolean isConnected ) { if ( ! isConnected ) { for ( int ir = 0; ir < nRemote_; ir++ ) { ProxyManager remoteManager = remoteManagers_[ ir ]; int im = remoteManager.getManagerIndex( this ); for ( Iterator it = remoteManager.connectionMap_.values() .iterator(); it.hasNext(); ) { HubConnection[] connections = (HubConnection[]) it.next(); if ( connections != null ) { HubConnection connection = connections[ im ]; if ( connection != null ) { connections[ im ] = null; try { connection.unregister(); } catch ( SampException e ) { logger_.info( "Unregister failed" ); } } } } } } else { // not expected except for initial connection } } /** * Invoked when a client is added to the local hub. * * @param client newly added client */ private void localClientAdded( Client client ) { if ( ! isProxiedClient( client ) ) { return; } // Register a proxy for the new local client on all the remote hubs // in the bridge. Metadata meta = getProxyMetadata( client ); Subscriptions subs = getProxySubscriptions( client ); HubConnection[] proxyConnections = new HubConnection[ nRemote_ ]; connectionMap_.put( client.getId(), proxyConnections ); for ( int ir = 0; ir < nRemote_; ir++ ) { ProxyManager remoteManager = remoteManagers_[ ir ]; try { // This synchronization is here so that the isProxy method // can work reliably. isProxy may ask whether a client seen // on a remote hub is a proxy controlled by this one. // It can only ask after the registration has been done, // and the determination is synchronized on connectionMap_. // By synchronizing here, we can ensure that it can't ask // after the registration, but before the information has // been recorded in the connectionMap. final HubConnection proxyConnection; synchronized ( connectionMap_ ) { proxyConnection = remoteManager.getProfile().register(); if ( proxyConnection != null ) { CallableClient callable = new ProxyCallableClient( client, proxyConnection, remoteManager ); proxyConnection.setCallable( callable ); proxyConnections[ ir ] = proxyConnection; } } if ( proxyConnection != null ) { if ( meta != null ) { try { proxyConnection.declareMetadata( meta ); } catch ( SampException e ) { logger_.warning( "proxy declareMetadata failed for " + client ); } } if ( subs != null ) { try { proxyConnection.declareSubscriptions( subs ); } catch ( SampException e ) { logger_.warning( "proxy declareSubscriptions failed" + " for " + client ); } } } } catch ( SampException e ) { logger_.warning( "proxy registration failed for " + client ); } } } /** * Invoked when a client is removed from the local hub. * * @param client recently removed client */ private void localClientRemoved( Client client ) { if ( ! isProxiedClient( client ) ) { return; } // Remove all the proxies which were registered on remote hubs // on behalf of the removed client. HubConnection[] proxyConnections = (HubConnection[]) connectionMap_.remove( client.getId() ); if ( proxyConnections != null ) { for ( int ir = 0; ir < nRemote_; ir++ ) { HubConnection connection = proxyConnections[ ir ]; if ( connection != null ) { try { connection.unregister(); } catch ( SampException e ) { logger_.warning( "proxy unregister failed for " + client ); } } } } } /** * Invoked when information (metadata or subscriptions) have been * updated for a client on the local hub. * * @param client updated client * @param metaChanged true if metadata may have changed * (false if known unchanged) * @param subsChanged true if subscriptions may have changed * (false if known unchanged) */ private void localClientUpdated( Client client, boolean metaChanged, boolean subsChanged ) { if ( ! isProxiedClient( client ) ) { return; } // Cause each of the local client's proxies on remote hubs to // declare subscription/metadata updates appropriately. HubConnection[] proxyConnections = (HubConnection[]) connectionMap_.get( client.getId() ); Metadata meta = metaChanged ? getProxyMetadata( client ) : null; Subscriptions subs = subsChanged ? getProxySubscriptions( client ) : null; if ( proxyConnections != null ) { for ( int ir = 0; ir < nRemote_; ir++ ) { HubConnection connection = proxyConnections[ ir ]; if ( connection != null ) { if ( meta != null ) { try { connection.declareMetadata( meta ); } catch ( SampException e ) { logger_.warning( "proxy declareMetadata failed " + "for " + client ); } } if ( subs != null ) { try { connection.declareSubscriptions( subs ); } catch ( SampException e ) { logger_.warning( "proxy declareSubscriptions " + "failed for " + client ); } } } } } } /** * Determines whether a local client is a genuine third party client * which requires a remote proxy. Will return false for clients which * are operating on behalf of this bridge, including the ProxyManager's * client tracking connection and any proxies controlled by remote * ProxyManagers. Unless useProxyHub is true, will also return false * for the hub client on remote hubs, since these are not very * interesting to talk to. * * @param client local client * @param true if client has or should have a proxy; * false if it's an organ of the bridge administration */ private boolean isProxiedClient( Client client ) { // Is it a client on the local hub that we want to exclude? try { if ( pmConnector_.isConnected() ) { HubConnection connection = pmConnector_.getConnection(); if ( connection != null ) { String clientId = client.getId(); RegInfo regInfo = connection.getRegInfo(); if ( clientId.equals( regInfo.getSelfId() ) || ( ! useProxyHub_ && clientId.equals( regInfo.getHubId() ) ) ) { return false; } } } } catch ( SampException e ) { } // Is it a proxy controlled by one of the remote managers? for ( int ir = 0; ir < nRemote_; ir++ ) { if ( remoteManagers_[ ir ].isProxy( client, ProxyManager.this ) ) { return false; } } // No, then it's a genuine local client requiring a proxy. return true; } /** * Determines whether a given local client is a proxy controlled by * a given remote ProxyManager. * * @param client local client * @param remoteManager remote proxy manager * @return true iff client is one of * remoteManager's proxies */ private boolean isProxy( Client client, ProxyManager remoteManager ) { int ir = getManagerIndex( remoteManager ); synchronized ( connectionMap_ ) { for ( Iterator it = connectionMap_.values().iterator(); it.hasNext(); ) { HubConnection[] proxyConnections = (HubConnection[]) it.next(); if ( proxyConnections != null ) { HubConnection proxyConnection = proxyConnections[ ir ]; if ( proxyConnection != null ) { RegInfo proxyReg = proxyConnection.getRegInfo(); if ( proxyReg.getSelfId().equals( client.getId() ) ) { return true; } } } } } return false; } /** * CallableClient implementation used by remote proxy connections on * behalf of local clients. This is the core of the proxy manager. * Callbacks received by the remote proxy client are tunnelled back * to the local hub and forwarded by the local proxy of the remote * sender client to the appropriate local non-proxy client. * Since local proxies are managed by other proxy managers * (this one manages remote proxies of local clients) * this means getting the other proxy managers to do some of the work. */ private class ProxyCallableClient implements CallableClient { private final String localClientId_; private final HubConnection remoteProxy_; private final ProxyManager remoteManager_; private final ProxyManager localManager_; /** * Constructor. * * @param localClient local client * @param remoteProxy hub connection to the remote hub for the proxy * @param remoteManager remote ProxyManager associated with the * hub where this proxy is connected */ ProxyCallableClient( Client localClient, HubConnection remoteProxy, ProxyManager remoteManager ) { localClientId_ = localClient.getId(); remoteProxy_ = remoteProxy; remoteManager_ = remoteManager; localManager_ = ProxyManager.this; } public void receiveNotification( String remoteSenderId, Message msg ) throws SampException { // Forward the notification. if ( remoteManager_.exporter_ != null ) { msg = new Message( msg ); remoteManager_.exporter_.exportMap( msg ); } HubConnection localProxy = getLocalProxy( remoteSenderId ); if ( localProxy != null ) { localProxy.notify( localClientId_, msg ); } proxyProcessMessage( remoteSenderId, msg ); } public void receiveCall( String remoteSenderId, String remoteMsgId, Message msg ) throws SampException { // Choose a tag; use the message ID as its value. // These things are different, but we are free to choose any // form for the tag, and we need something which will allow // us to recover the message ID from it later. // Making them identical is the easiest way to do that. String localMsgTag = remoteMsgId; // Forward the call. if ( remoteManager_.exporter_ != null ) { msg = new Message( msg ); remoteManager_.exporter_.exportMap( msg ); } HubConnection localProxy = getLocalProxy( remoteSenderId ); if ( localProxy != null ) { localProxy.call( localClientId_, localMsgTag, msg ); } else { ErrInfo errInfo = new ErrInfo(); errInfo.setErrortxt( "Bridge can't forward message" ); Client senderClient = (Client) remoteManager_.getManagerConnector() .getClientMap().get( remoteSenderId ); String usertxt = new StringBuffer() .append( "Bridge can't forward message to recipient;\n" ) .append( "sender client " ) .append( senderClient ) .append( " has no proxy on remote hub" ) .toString(); errInfo.setUsertxt( usertxt ); new ErrInfo( "Client " + remoteSenderId + " not present" + " on other side of bridge" ); remoteProxy_.reply( remoteMsgId, Response.createErrorResponse( errInfo ) ); } proxyProcessMessage( remoteSenderId, msg ); } public void receiveResponse( String remoteResponderId, String remoteMsgTag, Response response ) throws SampException { // The message ID we need for forwarding is the one we encoded // (by identity) earlier in the tag. String localMsgId = remoteMsgTag; // Forward the reply appropriately. if ( remoteManager_.exporter_ != null ) { response = new Response( response ); remoteManager_.exporter_.exportMap( response ); } HubConnection localProxy = getLocalProxy( remoteResponderId ); if ( localProxy != null ) { localProxy.reply( localMsgId, response ); } else { // Should only happen if the proxy has been disconnected // between send and receive. logger_.warning( "Bridge can't forward response: " + " missing proxy" ); } } /** * Returns the hub connection for the proxy on the local hub * which corresponds to a given remote client. * * @param remoteClientId client ID of remote client * @return hub connection for local proxy */ private HubConnection getLocalProxy( String remoteClientId ) { return remoteManager_ .getProxyConnection( localManager_, remoteClientId ); } /** * Performs housekeeping tasks for an incoming message if any. * This is in addition to forwarding the message to the client * for which we are proxying. * * @param remoteSenderId id of sending client on remote hub * @param msg message */ private void proxyProcessMessage( String remoteSenderId, Message msg ) { String mtype = msg.getMType(); boolean fromHub = remoteSenderId.equals( remoteProxy_.getRegInfo().getHubId() ); if ( "samp.hub.disconnect".equals( mtype ) ) { if ( fromHub ) { removeProxyConnection( remoteManager_, localClientId_ ); } else { logger_.warning( mtype + " from non-hub client " + remoteSenderId + " - ignored" ); } } } } /** * TrackedClientSet implementation used by a Proxy Manager. * Apart from inheriting default behaviour, this triggers * calls to ProxyManager methods when there are status changes * to local clients. */ private class ProxyManagerClientSet extends TrackedClientSet { /** * Constructor. */ private ProxyManagerClientSet() { super(); } public void addClient( Client client ) { super.addClient( client ); localClientAdded( client ); } public void removeClient( Client client ) { localClientRemoved( client ); super.removeClient( client ); } public void updateClient( Client client, boolean metaChanged, boolean subsChanged ) { super.updateClient( client, metaChanged, subsChanged ); localClientUpdated( client, metaChanged, subsChanged ); } public void setClients( Client[] clients ) { for ( Iterator it = getClientMap().entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); Client client = (Client) entry.getValue(); localClientRemoved( client ); } super.setClients( clients ); for ( int i = 0; i < clients.length; i++ ) { Client client = clients[ i ]; localClientAdded( client ); } } } /** * Class which can turn a client's icon into the icon for the proxy of * the same client. Some visually distinctive adjustment is made to * make it obvious from the icon that it's a proxy. */ private class ProxyIconAdjuster extends IconAdjuster { /** * Constructor. */ ProxyIconAdjuster() { super( server_.getServer(), server_.getBasePath( "proxy" + "-" + localProfile_ ) ); } public RenderedImage adjustImage( BufferedImage inImage ) { int w = inImage.getWidth(); int h = inImage.getHeight(); // Copy the image to a new image. It would be possible to write // directly into the input BufferedImage, but this might not // have the correct image type, so could end up getting the // transparency wrong or something. BufferedImage outImage = new BufferedImage( w, h, BufferedImage.TYPE_4BYTE_ABGR ); Graphics2D g2 = outImage.createGraphics(); g2.drawImage( inImage, null, 0, 0 ); // Slice off half of the image diagonally. int[] xs = new int[] { 0, w, w, }; int[] ys = new int[] { h, h, 0, }; Composite compos = g2.getComposite(); g2.setComposite( AlphaComposite.Clear ); g2.fillPolygon( xs, ys, 3 ); g2.setComposite( compos ); // Return the result. return outImage; } } } jsamp/src/java/org/astrogrid/samp/bridge/package.html0000664000175000017500000000030612730747754022461 0ustar sladensladen Classes for the SAMP Bridge. The {@link org.astrogrid.samp.bridge.Bridge} class is an application which can connect two or more hubs, allowing the clients on each to see the others. jsamp/src/java/org/astrogrid/samp/RegInfo.java0000664000175000017500000000417712730747754021152 0ustar sladensladenpackage org.astrogrid.samp; import java.util.Map; /** * Represents information provided to a client at registration by the hub. * * @author Mark Taylor * @since 14 Jul 2008 */ public class RegInfo extends SampMap { /** Key for client public-id used by hub when sending messages itself. */ public static final String HUBID_KEY = "samp.hub-id"; /** Key for client public-id owned by the registering application. */ public static final String SELFID_KEY = "samp.self-id"; /** Key for private-key token used for communications between hub and * registering client (Standard Profile). */ public static final String PRIVATEKEY_KEY = "samp.private-key"; private static final String[] KNOWN_KEYS = new String[] { HUBID_KEY, SELFID_KEY, PRIVATEKEY_KEY, }; /** * Constructs an empty RegInfo. */ public RegInfo() { super( KNOWN_KEYS ); } /** * Constructs a RegInfo based on an existing map. * * @param map map containing initial data for this object */ public RegInfo( Map map ) { this(); putAll( map ); } /** * Returns the hub's own public client id. * * @return {@link #HUBID_KEY} value */ public String getHubId() { return getString( HUBID_KEY ); } /** * Returns the registered client's public client id. * * @return {@link #SELFID_KEY} value */ public String getSelfId() { return getString( SELFID_KEY ); } /** * Returns the registered client's private key (Standard Profile). * * @return {@link #PRIVATEKEY_KEY} value */ public String getPrivateKey() { return getString( PRIVATEKEY_KEY ); } public void check() { super.check(); checkHasKeys( new String[] { HUBID_KEY, SELFID_KEY, } ); } /** * Returns a given map as a RegInfo. * * @param map map * @return registration info */ public static RegInfo asRegInfo( Map map ) { return map instanceof RegInfo ? (RegInfo) map : new RegInfo( map ); } } jsamp/src/java/org/astrogrid/samp/Client.java0000664000175000017500000000122112730747754021022 0ustar sladensladenpackage org.astrogrid.samp; /** * Describes an application registered with a SAMP hub. * * @author Mark Taylor * @since 14 Jul 2008 */ public interface Client { /** * Returns the public identifier for this client. * * @return public id */ String getId(); /** * Returns the currently declared metadata for this client, if any. * * @return metadata object; may be null */ Metadata getMetadata(); /** * Returns the currently declared subscriptions for this client, if any. * * @return subscriptions object; may be null */ Subscriptions getSubscriptions(); } jsamp/src/java/org/astrogrid/samp/xmlrpc/0000775000175000017500000000000012730747754020252 5ustar sladensladenjsamp/src/java/org/astrogrid/samp/xmlrpc/apache/0000775000175000017500000000000012730747754021473 5ustar sladensladenjsamp/src/java/org/astrogrid/samp/xmlrpc/apache/ApacheClient.java0000664000175000017500000000306612730747754024663 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc.apache; import java.io.IOException; import java.util.List; import java.util.Vector; import org.apache.xmlrpc.XmlRpcClient; import org.apache.xmlrpc.XmlRpcException; import org.astrogrid.samp.xmlrpc.SampXmlRpcClient; /** * SampXmlRpcClient implementation based on Apache XMLRPC classes. * * @author Mark Taylor * @since 16 Sep 2008 */ public class ApacheClient implements SampXmlRpcClient { private final XmlRpcClient xmlrpcClient_; /** * Constructor. * * @param xmlrpcClient Apache XML-RPC client instance */ public ApacheClient( XmlRpcClient xmlrpcClient ) { xmlrpcClient_ = xmlrpcClient; } public Object callAndWait( String method, List params ) throws IOException { try { return xmlrpcClient_ .execute( method, (Vector) ApacheUtils.toApache( params ) ); } catch ( XmlRpcException e ) { throw (IOException) new IOException( e.getMessage() ) .initCause( e ); } } public void callAndForget( String method, List params ) throws IOException { // I'm not sure that the Apache implementation is *sufficiently* // asynchronous. It does leave a thread hanging around waiting // for a response, though the result of this response is // discarded. May cause problems under heavy load. xmlrpcClient_ .executeAsync( method, (Vector) ApacheUtils.toApache( params ), null ); } } jsamp/src/java/org/astrogrid/samp/xmlrpc/apache/ApacheUtils.java0000664000175000017500000000444512730747754024547 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc.apache; import java.util.Hashtable; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Vector; /** * Provides utility methods to perform translations between the * data structurs used by the org.apache.xmlrpc classes and those used * by JSAMP. * * @author Mark Taylor * @since 22 Aug 2008 */ class ApacheUtils { /** * Private constructor prevents instantiation. */ private ApacheUtils() { } /** * Converts an object from JSAMP XML-RPC form to Apache XML-RPC form. * Basically, this means converting * {@link java.util.Map}s to {@link java.util.Hashtable}s and * {@link java.util.List}s to {@link java.util.Vector}s. * * @param obj XML-RPC data structure suitable for use within JSAMP * @return XML-RPC data structure suitable for use within Apache */ public static Object toApache( Object obj ) { if ( obj instanceof List ) { Vector vec = new Vector(); for ( Iterator it = ((List) obj).iterator(); it.hasNext(); ) { vec.add( toApache( it.next() ) ); } return vec; } else if ( obj instanceof Map ) { Hashtable hash = new Hashtable(); for ( Iterator it = ((Map) obj).entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); hash.put( entry.getKey(), toApache( entry.getValue() ) ); } return hash; } else if ( obj instanceof String ) { return obj; } else { throw new IllegalArgumentException( "Non-SAMP object type " + ( obj == null ? null : obj.getClass().getName() ) ); } } /** * Converts an object from Apache XML-RPC form to JSAMP XML-RPC form. * Since Hashtable implements Map and Vector implements List, this is * a no-op. * * @param data XML-RPC data structure suitable for use within Apache * @return XML-RPC data structure suitable for use within JSAMP */ public static Object fromApache( Object data ) { return data; } } jsamp/src/java/org/astrogrid/samp/xmlrpc/apache/ApacheServerFactory.java0000664000175000017500000000125512730747754026241 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc.apache; import java.io.IOException; import org.astrogrid.samp.xmlrpc.SampXmlRpcServer; import org.astrogrid.samp.xmlrpc.SampXmlRpcServerFactory; /** * SampXmlRpcServerFactory implementation which uses Apache classes. * Server construction is lazy and the same server is returned each time. * * @author Mark Taylor * @since 22 Aug 2008 */ public class ApacheServerFactory implements SampXmlRpcServerFactory { private SampXmlRpcServer server_; public synchronized SampXmlRpcServer getServer() throws IOException { if ( server_ == null ) { server_ = new ApacheServer(); } return server_; } } jsamp/src/java/org/astrogrid/samp/xmlrpc/apache/ApacheServer.java0000664000175000017500000001257612730747754024721 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc.apache; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Vector; import org.apache.xmlrpc.WebServer; import org.apache.xmlrpc.XmlRpcHandler; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.xmlrpc.SampXmlRpcServer; import org.astrogrid.samp.xmlrpc.SampXmlRpcHandler; /** * SampXmlRpcServer implementation based on Apache XML-RPC library. * * @author Mark Taylor * @since 22 Aug 2008 */ public class ApacheServer implements SampXmlRpcServer { private final WebServer webServer_; private final URL endpoint_; private final List handlerList_; /** * Private constructor used by all other constructors. * Uses the private LabelledServer class to aggregate the required * information. * * @param server server with metadata */ private ApacheServer( LabelledServer server ) { webServer_ = server.webServer_; endpoint_ = server.endpoint_; handlerList_ = Collections.synchronizedList( new ArrayList() ); webServer_.addHandler( "$default", new XmlRpcHandler() { public Object execute( String method, Vector params ) throws Exception { return doExecute( method, params ); } } ); } /** * Constructs a new server based on a given WebServer object. * Responsibility for starting the WebServer and performing * any other required configuration lies with the caller. * * @param webServer apache xmlrpc webserver object * @param port port number on which the server is running */ public ApacheServer( WebServer webServer, int port ) { this( new LabelledServer( webServer, getServerEndpoint( port ) ) ); } /** * Constructs a new server starting up a new WebServer object. * The server runs in a daemon thread. */ public ApacheServer() throws IOException { this( createLabelledServer( true ) ); webServer_.start(); } public URL getEndpoint() { return endpoint_; } public void addHandler( SampXmlRpcHandler handler ) { handlerList_.add( handler ); } public void removeHandler( SampXmlRpcHandler handler ) { handlerList_.remove( handler ); } /** * Does the work for executing an XML-RPC request. * * @param fqMethod fully qualified XML-RPC method name * @param paramVec Apache-style list of method parameters */ private Object doExecute( String fqMethod, Vector paramVec ) throws Exception { SampXmlRpcHandler[] handlers = (SampXmlRpcHandler[]) handlerList_.toArray( new SampXmlRpcHandler[ 0 ] ); for ( int ih = 0; ih < handlers.length; ih++ ) { SampXmlRpcHandler handler = handlers[ ih ]; if ( handler.canHandleCall( fqMethod ) ) { List paramList = (List) ApacheUtils.fromApache( paramVec ); Object result = handler.handleCall( fqMethod, paramList, null ); return ApacheUtils.toApache( result ); } } throw new UnsupportedOperationException( "No handler for method " + fqMethod ); } /** * Constructs a new LabelledServer object suitable for use with this * server. * * @param isDaemon whether the WebServer's main thread should run * in daemon mode */ private static LabelledServer createLabelledServer( final boolean isDaemon ) throws IOException { int port = SampUtils.getUnusedPort( 2300 ); WebServer server = new WebServer( port ) { // Same as superclass implementation except that the listener // thread is marked as a daemon. public void start() { if ( this.listener == null ) { this.listener = new Thread( this, "XML-RPC Weblistener" ); this.listener.setDaemon( isDaemon ); this.listener.start(); } } }; return new LabelledServer( server, getServerEndpoint( port ) ); } /** * Returns the endpoint URL to use for an Apache server running on a * given port. * * @param port port number * @return URL */ private static URL getServerEndpoint( int port ) { String endpoint = "http://" + SampUtils.getLocalhost() + ":" + port + "/"; try { return new URL( endpoint ); } catch ( MalformedURLException e ) { throw (Error) new AssertionError( "Bad protocol http?? " + endpoint ) .initCause( e ); } } /** * Convenience class which aggregates a WebServer and an endpoint. */ private static class LabelledServer { private final WebServer webServer_; private final URL endpoint_; /** * Constructor. * * @param webServer web server * @param endpoint URL */ LabelledServer( WebServer webServer, URL endpoint ) { webServer_ = webServer; endpoint_ = endpoint; } } } jsamp/src/java/org/astrogrid/samp/xmlrpc/apache/ApacheClientFactory.java0000664000175000017500000000113512730747754026206 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc.apache; import java.io.IOException; import java.net.URL; import org.apache.xmlrpc.XmlRpcClientLite; import org.astrogrid.samp.xmlrpc.SampXmlRpcClient; import org.astrogrid.samp.xmlrpc.SampXmlRpcClientFactory; /** * SampXmlRpcClientFactory implementation based on Apache XMLRPC classes. * * @author Mark Taylor * @since 16 Sep 2008 */ public class ApacheClientFactory implements SampXmlRpcClientFactory { public SampXmlRpcClient createClient( URL endpoint ) throws IOException { return new ApacheClient( new XmlRpcClientLite( endpoint ) ); } } jsamp/src/java/org/astrogrid/samp/xmlrpc/apache/package.html0000664000175000017500000000031512730747754023753 0ustar sladensladen Implementation of pluggable XML-RPC layer using Apache XML-RPC. These classes were developed against the apache-1.2-b1 version; they may or may not work with other versions of that library. jsamp/src/java/org/astrogrid/samp/xmlrpc/ActorHandler.java0000664000175000017500000002330412730747754023465 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.logging.Logger; import org.astrogrid.samp.DataException; /** * Utility class to facilitate constructing a SampXmlRpcHandler which handles * particular named methods. * You supply at construction time an interface which defines the methods * to be handled and an object which implements that interface. * This object then uses reflection to invoke the correct methods on the * implementation object as they are required from incoming XML-RPC * execute requests. This insulates the implementation object * from having to worry about any XML-RPC specifics. * * @author Mark Taylor * @since 15 Jul 2008 */ public abstract class ActorHandler implements SampXmlRpcHandler { private final String prefix_; private final Object actor_; private final Map methodMap_; private final Logger logger_ = Logger.getLogger( ActorHandler.class.getName() ); /** * Constructor. * * @param prefix string prepended to every method name in the * actorType interface to form the XML-RPC * methodName element * @param actorType interface defining the XML-RPC methods * @param actor object implementing actorType */ public ActorHandler( String prefix, Class actorType, Object actor ) { prefix_ = prefix; actor_ = actor; methodMap_ = new HashMap(); // Construct a map keyed by method signature of the known methods. Method[] methods = actorType.getDeclaredMethods(); for ( int im = 0; im < methods.length; im++ ) { Method method = methods[ im ]; if ( Modifier.isPublic( method.getModifiers() ) ) { String name = method.getName(); Class[] clazzes = method.getParameterTypes(); SampType[] types = new SampType[ clazzes.length ]; for ( int ic = 0; ic < clazzes.length; ic++ ) { types[ ic ] = SampType.getClassType( clazzes[ ic ] ); } Signature sig = new Signature( prefix_ + name, types ); methodMap_.put( sig, method ); } } } public boolean canHandleCall( String fqName ) { return fqName.startsWith( prefix_ ); } public Object handleCall( String fqName, List params, Object reqInfo ) throws Exception { if ( ! canHandleCall( fqName ) ) { throw new IllegalArgumentException( "No I can't" ); } // Work out the signature for this method and see if it is recognised. String name = fqName.substring( prefix_.length() ); List typeList = new ArrayList(); for ( Iterator it = params.iterator(); it.hasNext(); ) { typeList.add( SampType.getParamType( it.next() ) ); } SampType[] types = (SampType[]) typeList.toArray( new SampType[ 0 ] ); Signature sig = new Signature( fqName, types ); Method method = (Method) methodMap_.get( sig ); // If the signature is recognised, invoke the relevant method // on the implementation object. if ( method != null ) { Object result; Throwable error; try { result = invokeMethod( method, actor_, params.toArray() ); } catch ( InvocationTargetException e ) { Throwable e2 = e.getCause(); if ( e2 instanceof Error ) { throw (Error) e2; } else { throw (Exception) e2; } } catch ( Exception e ) { throw e; } catch ( Error e ) { throw e; } return result == null ? "" : result; } // If the signature is not recognised, but the method name is, // try to make a helpful comment. else { for ( Iterator it = methodMap_.keySet().iterator(); it.hasNext(); ) { Signature foundSig = (Signature) it.next(); if ( foundSig.name_.equals( fqName ) ) { throw new IllegalArgumentException( "Bad arguments: " + foundSig + " got " + sig.typeList_ ); } } throw new UnsupportedOperationException( "Unknown method " + fqName ); } } /** * Returns the implementation object for this handler. * * @return implementation object */ public Object getActor() { return actor_; } /** * Invokes a method reflectively on an object. * This method should be implemented in the obvious way, that is * return method.invoke(obj,params). * *

      If the implementation is effectively prescribed, why is this * abstract method here? It's tricky. * The reason is so that reflective method invocation from this class * is done by code within the actor implementation class itself * rather than by code in the superclass, ActorHandler. * That in turn means that the actorType class specified * in the constructor does not need to be visible from * ActorHandler's package, only from the package where * the implementation class lives. * * @param method method to invoke * @param obj object to invoke the method on * @param args arguments for the method call * @see java.lang.reflect.Method#invoke */ protected abstract Object invokeMethod( Method method, Object obj, Object[] args ) throws IllegalAccessException, InvocationTargetException; /** * Enumeration of permitted types within a SAMP data structure. */ private static class SampType { /** String type. */ public static final SampType STRING = new SampType( String.class, "string" ); /** List type. */ public static final SampType LIST = new SampType( List.class, "list" ); /** Map type. */ public static final SampType MAP = new SampType( Map.class, "map" ); private final Class clazz_; private final String name_; /** * Constructor. * * @param clazz java class * @param name name of SAMP type */ private SampType( Class clazz, String name ) { clazz_ = clazz; name_ = name; } /** * Returns the java class corresponding to this type. * * @return class */ public Class getTypeClass() { return clazz_; } /** * Returns the SAMP name for this type. * * @return name */ public String toString() { return name_; } /** * Returns the SampType corresponding to a given java class. * * @param clazz class * @return SAMP type */ public static SampType getClassType( Class clazz ) { if ( String.class.equals( clazz ) ) { return STRING; } else if ( List.class.equals( clazz ) ) { return LIST; } else if ( Map.class.equals( clazz ) ) { return MAP; } else { throw new IllegalArgumentException( "Illegal type " + clazz.getName() ); } } /** * Returns the SampType corresponding to a given object. * * @param param object * return SAMP type */ public static SampType getParamType( Object param ) { if ( param instanceof String ) { return STRING; } else if ( param instanceof List ) { return LIST; } else if ( param instanceof Map ) { return MAP; } else { throw new DataException( "Param is not a SAMP type" ); } } } /** * Characterises a method signature. * The equals and hashCode methods are * implemented sensibly. */ private static class Signature { private final String name_; private final List typeList_; /** * Constructor. * * @param name method name * @param types types of method arguments */ Signature( String name, SampType[] types ) { name_ = name; typeList_ = new ArrayList( Arrays.asList( types ) ); } public boolean equals( Object o ) { if ( o instanceof Signature ) { Signature other = (Signature) o; return this.name_.equals( other.name_ ) && this.typeList_.equals( other.typeList_ ); } else { return false; } } public int hashCode() { int code = 999; code = 23 * code + name_.hashCode(); code = 23 * code + typeList_.hashCode(); return code; } public String toString() { return name_ + typeList_; } } } jsamp/src/java/org/astrogrid/samp/xmlrpc/HubActor.java0000664000175000017500000001334512730747754022632 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.util.List; import java.util.Map; import org.astrogrid.samp.client.SampException; /** * Defines the XML-RPC methods which must be implemented by a * Standard Profile hub. * * @author Mark Taylor * @since 15 Jul 2008 */ interface HubActor { /** * Throws an exception if service is not operating. */ void ping() throws SampException; /** * Throws an exception if service is not operating. * * @param privateKey ignored */ void ping( String privateKey ) throws SampException; /** * Registers a new client and returns a map with registration information. * * @param secret registration password * @return {@link org.astrogrid.samp.RegInfo}-like map. */ Map register( String secret ) throws SampException; /** * Unregisters a registered client. * * @param privateKey calling client private key */ void unregister( String privateKey ) throws SampException; /** * Sets the XML-RPC URL to use for callbacks for a callable client. * * @param privateKey calling client private key * @param url XML-RPC endpoint for client API callbacks */ void setXmlrpcCallback( String privateKey, String url ) throws SampException; /** * Declares metadata for the calling client. * * @param privateKey calling client private key * @param meta {@link org.astrogrid.samp.Metadata}-like map */ void declareMetadata( String privateKey, Map meta ) throws SampException; /** * Returns metadata for a given client. * * @param privateKey calling client private key * @param clientId public ID for client whose metadata is required * @return {@link org.astrogrid.samp.Metadata}-like map */ Map getMetadata( String privateKey, String clientId ) throws SampException; /** * Declares subscription information for the calling client. * * @param privateKey calling client private key * @param subs {@link org.astrogrid.samp.Subscriptions}-like map */ void declareSubscriptions( String privateKey, Map subs ) throws SampException; /** * Returns subscriptions for a given client. * * @param privateKey calling client private key * @return {@link org.astrogrid.samp.Subscriptions}-like map */ Map getSubscriptions( String privateKey, String clientId ) throws SampException; /** * Returns a list of the public-ids of all currently registered clients. * * @param privateKey calling client private key * @return list of Strings */ List getRegisteredClients( String privateKey ) throws SampException; /** * Returns a map of the clients subscribed to a given MType. * * @param privateKey calling client private key * @param mtype MType of interest * @return map in which the keys are the public-ids of clients subscribed * to mtype */ Map getSubscribedClients( String privateKey, String mtype ) throws SampException; /** * Sends a message to a given client without wanting a response. * * @param privateKey calling client private key * @param recipientId public-id of client to receive message * @param msg {@link org.astrogrid.samp.Message}-like map */ void notify( String privateKey, String recipientId, Map msg ) throws SampException; /** * Sends a message to all subscribed clients without wanting a response. * * @param privateKey calling client private key * @param msg {@link org.astrogrid.samp.Message}-like map * @return list of public-ids for clients to which the notify will be sent */ List notifyAll( String privateKey, Map msg ) throws SampException; /** * Sends a message to a given client expecting a response. * * @param privateKey calling client private key * @param recipientId public-id of client to receive message * @param msgTag arbitrary string tagging this message for caller's * benefit * @param msg {@link org.astrogrid.samp.Message}-like map * @return message ID */ String call( String privateKey, String recipientId, String msgTag, Map msg ) throws SampException; /** * Sends a message to all subscribed clients expecting responses. * * @param privateKey calling client private key * @param msgTag arbitrary string tagging this message for caller's * benefit * @param msg {@link org.astrogrid.samp.Message}-like map * @return public-id->msg-id map for clients to which an attempt to * send the call will be made */ Map callAll( String privateKey, String msgTag, Map msg ) throws SampException; /** * Sends a message synchronously to a client. * * @param privateKey calling client private key * @param recipientId public-id of client to receive message * @param msg {@link org.astrogrid.samp.Message}-like map * @param timeout timeout in seconds encoded as a SAMP int * @return {@link org.astrogrid.samp.Response}-like map */ Map callAndWait( String privateKey, String recipientId, Map msg, String timeout ) throws SampException; /** * Responds to a previously sent message. * * @param privateKey calling client private key * @param msgId ID associated with earlier send * @param response {@link org.astrogrid.samp.Response}-like map */ void reply( String privateKey, String msgId, Map response ) throws SampException; } jsamp/src/java/org/astrogrid/samp/xmlrpc/ClientXmlRpcHandler.java0000664000175000017500000001522512730747754024764 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.HashMap; import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; import org.astrogrid.samp.ErrInfo; import org.astrogrid.samp.Message; import org.astrogrid.samp.Response; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.HubConnection; /** * SampXmlRpcHandler implementation which passes Standard Profile-like XML-RPC * calls to one or more {@link CallableClient}s to provide client callbacks * from the hub. * * @author Mark Taylor * @since 16 Jul 2008 */ class ClientXmlRpcHandler extends ActorHandler { private final ClientActorImpl clientActor_; private static final Logger logger_ = Logger.getLogger( ClientXmlRpcHandler.class.getName() ); /** * Constructor. */ public ClientXmlRpcHandler() { super( "samp.client.", ClientActor.class, new ClientActorImpl() ); clientActor_ = (ClientActorImpl) getActor(); } /** * Adds a CallableClient object to this server. * * @param connection hub connection for the registered client on behalf * of which the client will operate * @param callable callable client object */ public void addClient( HubConnection connection, CallableClient callable ) { clientActor_.entryMap_.put( connection.getRegInfo().getPrivateKey(), new Entry( connection, callable ) ); } /** * Removes a CallableClient object from this server. * * @param privateKey hub connection for which this client was added */ public void removeClient( HubConnection connection ) { clientActor_.entryMap_ .remove( connection.getRegInfo().getPrivateKey() ); } /** * Returns the number of clients currently owned by this handler. * * @return client count */ public int getClientCount() { return clientActor_.entryMap_.size(); } protected Object invokeMethod( Method method, Object obj, Object[] args ) throws IllegalAccessException, InvocationTargetException { return method.invoke( obj, args ); } /** * Implementation of the {@link ClientActor} interface which does the * work for this class. * The correct CallableClient is determined from the private key, * and the work is then delegated to it. */ private static class ClientActorImpl implements ClientActor { private Map entryMap_ = new HashMap(); // String -> Entry public void receiveNotification( String privateKey, final String senderId, Map msg ) { Entry entry = getEntry( privateKey ); final CallableClient callable = entry.callable_; final Message message = Message.asMessage( msg ); final String label = "Notify " + senderId + " " + message.getMType(); new Thread( label ) { public void run() { try { callable.receiveNotification( senderId, message ); } catch ( Throwable e ) { logger_.log( Level.INFO, label + " error", e ); } } }.start(); } public void receiveCall( String privateKey, final String senderId, final String msgId, Map msg ) throws Exception { Entry entry = getEntry( privateKey ); final CallableClient callable = entry.callable_; final HubConnection connection = entry.connection_; final Message message = Message.asMessage( msg ); final String label = "Call " + senderId + " " + message.getMType(); new Thread( label ) { public void run() { try { callable.receiveCall( senderId, msgId, message ); } catch ( Throwable e ) { try { Response response = Response .createErrorResponse( new ErrInfo( e ) ); connection.reply( msgId, response ); } catch ( Throwable e2 ) { logger_.log( Level.INFO, label + " error replying", e2 ); } } } }.start(); } public void receiveResponse( String privateKey, final String responderId, final String msgTag, Map resp ) throws Exception { Entry entry = getEntry( privateKey ); final CallableClient callable = entry.callable_; final Response response = Response.asResponse( resp ); final String label = "Reply " + responderId; new Thread( label ) { public void run() { try { callable.receiveResponse( responderId, msgTag, response ); } catch ( Throwable e ) { logger_.log( Level.INFO, label + " error replying", e ); } } }.start(); } /** * Returns the CallableClient corresponding to a given private key. * * @param privateKey private key for client * @return entry identified by privateKey * @throws IllegalStateException if privateKey is unknown */ private Entry getEntry( String privateKey ) { Object ent = entryMap_.get( privateKey ); if ( ent instanceof Entry ) { return (Entry) ent; } else { throw new IllegalStateException( "Client is not listening" ); } } } /** * Utility class to aggregate information about a client. */ private static class Entry { final HubConnection connection_; final CallableClient callable_; /** * Constructor. * * @param connection hub connection * @param callable callable client */ Entry( HubConnection connection, CallableClient callable ) { connection_ = connection; callable_ = callable; } } } jsamp/src/java/org/astrogrid/samp/xmlrpc/StandardHubProfileFactory.java0000664000175000017500000000444112730747754026170 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.io.File; import java.io.IOException; import java.util.Iterator; import java.util.List; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.hub.HubProfile; import org.astrogrid.samp.hub.HubProfileFactory; /** * HubProfileFactory implementation for Standard Profile. * * @author Mark Taylor * @since 31 Jan 2011 */ public class StandardHubProfileFactory implements HubProfileFactory { private static final String secretUsage_ = "[-std:secret ]"; private static final String lockUsage_ = "[-std:httplock]"; /** * Returns "std". */ public String getName() { return "std"; } public String[] getFlagsUsage() { return new String[] { secretUsage_, lockUsage_, }; } public HubProfile createHubProfile( List flagList ) throws IOException { String secret = null; boolean httpLock = false; for ( Iterator it = flagList.iterator(); it.hasNext(); ) { String arg = (String) it.next(); if ( arg.equals( "-std:secret" ) ) { it.remove(); if ( it.hasNext() ) { secret = (String) it.next(); it.remove(); } else { throw new IllegalArgumentException( "Usage: " + secretUsage_ ); } } else if ( arg.equals( "-std:httplock" ) ) { it.remove(); httpLock = true; } else if ( arg.equals( "-std:nohttplock" ) ) { it.remove(); httpLock = false; } } File lockfile = httpLock ? null : SampUtils.urlToFile( StandardClientProfile.getLockUrl() ); XmlRpcKit xmlrpc = XmlRpcKit.getInstance(); if ( secret == null ) { secret = StandardHubProfile.createSecret(); } return new StandardHubProfile( xmlrpc.getClientFactory(), xmlrpc.getServerFactory(), lockfile, secret ); } public Class getHubProfileClass() { return StandardHubProfile.class; } } jsamp/src/java/org/astrogrid/samp/xmlrpc/HubXmlRpcHandler.java0000664000175000017500000002052012730747754024256 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.MalformedURLException; import java.net.URL; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import org.astrogrid.samp.RegInfo; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.hub.KeyGenerator; /** * SampXmlRpcHandler implementation which passes Standard Profile-type XML-RPC * calls to a hub connection factory to provide a Standard Profile hub server. * * @author Mark Taylor * @since 15 Jul 2008 */ class HubXmlRpcHandler extends ActorHandler { /** * Constructor. * * @param xClientFactory XML-RPC client factory implementation * @param profile hub connection factory * @param secret password required for client registration */ public HubXmlRpcHandler( SampXmlRpcClientFactory xClientFactory, ClientProfile profile, String secret, KeyGenerator keyGen ) { super( "samp.hub.", HubActor.class, new HubActorImpl( xClientFactory, profile, secret, keyGen ) ); } protected Object invokeMethod( Method method, Object obj, Object[] args ) throws IllegalAccessException, InvocationTargetException { return method.invoke( obj, args ); } /** * Implementation of the {@link HubActor} interface which does * the work for this class. * Apart from a few methods which have Standard-Profile-specific * aspects, the work is simply delegated to the hub connection factory. */ private static class HubActorImpl implements HubActor { private final SampXmlRpcClientFactory xClientFactory_; private final ClientProfile profile_; private final String secret_; private final KeyGenerator keyGen_; private final Map clientMap_; /** * Constructor. * * @param xClientFactory XML-RPC client factory implementation * @param profile hub connection factory * @param secret password required for client registration * @param keyGen generator for private keys */ HubActorImpl( SampXmlRpcClientFactory xClientFactory, ClientProfile profile, String secret, KeyGenerator keyGen ) { xClientFactory_ = xClientFactory; profile_ = profile; secret_ = secret; keyGen_ = keyGen; clientMap_ = Collections.synchronizedMap( new HashMap() ); } public Map register( String secret ) throws SampException { if ( ! profile_.isHubRunning() ) { throw new SampException( "Hub not running" ); } else if ( secret_.equals( secret ) ) { HubConnection connection = profile_.register(); if ( connection == null ) { throw new SampException( "Hub is not running" ); } String privateKey = keyGen_.next(); RegInfo regInfo = connection.getRegInfo(); regInfo.put( RegInfo.PRIVATEKEY_KEY, privateKey ); clientMap_.put( privateKey, connection ); return regInfo; } else { throw new SampException( "Bad password" ); } } public void unregister( String privateKey ) throws SampException { HubConnection connection = (HubConnection) clientMap_.remove( privateKey ); if ( connection == null ) { throw new SampException( "Unknown private key" ); } else { connection.unregister(); } } public void ping( String privateKey ) throws SampException { getConnection( privateKey ).ping(); } public void setXmlrpcCallback( String privateKey, String surl ) throws SampException { SampXmlRpcClient xClient; try { xClient = xClientFactory_.createClient( new URL( surl ) ); } catch ( MalformedURLException e ) { throw new SampException( "Bad URL: " + surl, e ); } catch ( IOException e ) { throw new SampException( "No connection: " + e.getMessage(), e ); } getConnection( privateKey ) .setCallable( new XmlRpcCallableClient( xClient, privateKey ) ); } public void declareMetadata( String privateKey, Map metadata ) throws SampException { getConnection( privateKey ).declareMetadata( metadata ); } public Map getMetadata( String privateKey, String clientId ) throws SampException { return getConnection( privateKey ).getMetadata( clientId ); } public void declareSubscriptions( String privateKey, Map subs ) throws SampException { getConnection( privateKey ).declareSubscriptions( subs ); } public Map getSubscriptions( String privateKey, String clientId ) throws SampException { return getConnection( privateKey ).getSubscriptions( clientId ); } public List getRegisteredClients( String privateKey ) throws SampException { return Arrays.asList( getConnection( privateKey ) .getRegisteredClients() ); } public Map getSubscribedClients( String privateKey, String mtype ) throws SampException { return getConnection( privateKey ).getSubscribedClients( mtype ); } public void notify( String privateKey, String recipientId, Map msg ) throws SampException { getConnection( privateKey ).notify( recipientId, msg ); } public List notifyAll( String privateKey, Map msg ) throws SampException { return getConnection( privateKey ).notifyAll( msg ); } public String call( String privateKey, String recipientId, String msgTag, Map msg ) throws SampException { return getConnection( privateKey ).call( recipientId, msgTag, msg ); } public Map callAll( String privateKey, String msgTag, Map msg ) throws SampException { return getConnection( privateKey ).callAll( msgTag, msg ); } public Map callAndWait( String privateKey, String recipientId, Map msg, String timeoutStr ) throws SampException { int timeout; try { timeout = SampUtils.decodeInt( timeoutStr ); } catch ( Exception e ) { throw new SampException( "Bad timeout format" + " (should be SAMP int)", e ); } return getConnection( privateKey ) .callAndWait( recipientId, msg, timeout ); } public void reply( String privateKey, String msgId, Map response ) throws SampException { getConnection( privateKey ).reply( msgId, response ); } public void ping() throws SampException { if ( ! profile_.isHubRunning() ) { throw new SampException( "Hub is stopped" ); } } /** * Returns the HubConnection associated with a private key used * by this hub actor. * * @param privateKey private key * @return connection for privateKey */ private HubConnection getConnection( String privateKey ) throws SampException { HubConnection connection = (HubConnection) clientMap_.get( privateKey ); if ( connection == null ) { throw new SampException( "Unknown private key" ); } else { return connection; } } } } jsamp/src/java/org/astrogrid/samp/xmlrpc/HubRunner.java0000664000175000017500000006432112730747754023033 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.InetAddress; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.Iterator; import java.util.List; import java.util.Random; import java.util.Vector; import java.util.logging.Level; import java.util.logging.Logger; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.DefaultClientProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.hub.Hub; import org.astrogrid.samp.hub.HubService; import org.astrogrid.samp.hub.KeyGenerator; import org.astrogrid.samp.hub.LockWriter; import org.astrogrid.samp.hub.MessageRestriction; import org.astrogrid.samp.hub.ProfileToken; import org.astrogrid.samp.httpd.ServerResource; import org.astrogrid.samp.httpd.UtilServer; /** * Runs a SAMP hub using the SAMP Standard Profile. * The {@link #start} method must be called to start it up. * *

      The {@link #main} method can be used to launch a hub from * the command line. Use the -help flag for more information. * * @author Mark Taylor * @since 15 Jul 2008 * @deprecated use {@link org.astrogrid.samp.hub.Hub} instead */ public class HubRunner { private final SampXmlRpcClientFactory xClientFactory_; private final SampXmlRpcServerFactory xServerFactory_; private final HubService hub_; private final File lockfile_; private URL lockUrl_; private LockInfo lockInfo_; private SampXmlRpcServer server_; private HubXmlRpcHandler hubHandler_; private boolean shutdown_; private final static ProfileToken STANDARD_PROFILE = new ProfileToken() { public String getProfileName() { return "Standard"; } public MessageRestriction getMessageRestriction() { return null; } }; private final static Logger logger_ = Logger.getLogger( HubRunner.class.getName() ); private final static Random random_ = KeyGenerator.createRandom(); /** * Constructor. * If the supplied lockfile is null, no lockfile will * be written at hub startup. * * @param xClientFactory XML-RPC client factory implementation * @param xServerFactory XML-RPC server implementation * @param hub object providing hub services * @param lockfile location to use for hub lockfile, or null */ public HubRunner( SampXmlRpcClientFactory xClientFactory, SampXmlRpcServerFactory xServerFactory, HubService hub, File lockfile ) { xClientFactory_ = xClientFactory; xServerFactory_ = xServerFactory; hub_ = hub; lockfile_ = lockfile; logger_.warning( "Class " + HubRunner.class.getName() + " is deprecated; use " + Hub.class.getName() + " instead." ); } /** * Starts the hub and writes the lockfile. * * @throws IOException if a hub is already running or an error occurs */ public void start() throws IOException { // Check for running or moribund hub. if ( lockfile_ != null && lockfile_.exists() ) { if ( isHubAlive( xClientFactory_, lockfile_ ) ) { throw new IOException( "A hub is already running" ); } else { logger_.warning( "Overwriting " + lockfile_ + " lockfile " + "for apparently dead hub" ); lockfile_.delete(); } } // Start up server. try { server_ = xServerFactory_.getServer(); } catch ( IOException e ) { throw e; } catch ( Exception e ) { throw (IOException) new IOException( "Can't start XML-RPC server" ) .initCause( e ); } // Start the hub service. hub_.start(); String secret = createSecret(); ClientProfile connectionFactory = new ClientProfile() { public HubConnection register() throws SampException { return hub_.register( STANDARD_PROFILE ); } public boolean isHubRunning() { return hub_.isHubRunning(); } }; hubHandler_ = new HubXmlRpcHandler( xClientFactory_, connectionFactory, secret, new KeyGenerator( "k:", 16, random_ ) ); server_.addHandler( hubHandler_ ); // Ensure tidy up in case of JVM shutdown. Runtime.getRuntime().addShutdownHook( new Thread( "HubRunner shutdown" ) { public void run() { shutdown(); } } ); // Prepare lockfile information. lockInfo_ = new LockInfo( secret, server_.getEndpoint().toString() ); lockInfo_.put( "hub.impl", hub_.getClass().getName() ); lockInfo_.put( "hub.start.date", new Date().toString() ); // Write lockfile information to file if required. if ( lockfile_ != null ) { logger_.info( "Writing new lockfile " + lockfile_ ); FileOutputStream out = new FileOutputStream( lockfile_ ); try { writeLockInfo( lockInfo_, out ); try { LockWriter.setLockPermissions( lockfile_ ); logger_.info( "Lockfile permissions set to " + "user access only" ); } catch ( IOException e ) { logger_.log( Level.WARNING, "Failed attempt to change " + lockfile_ + " permissions to user access only" + " - possible security implications", e ); } } finally { try { out.close(); } catch ( IOException e ) { logger_.log( Level.WARNING, "Error closing lockfile?", e ); } } } } /** * Shuts down the hub and tidies up. * May harmlessly be called multiple times. */ public synchronized void shutdown() { // Return if we have already done this. if ( shutdown_ ) { return; } shutdown_ = true; // Delete the lockfile if it exists and if it is the one originally // written by this runner. if ( lockfile_ != null ) { if ( lockfile_.exists() ) { try { LockInfo lockInfo = readLockFile( lockfile_ ); if ( lockInfo.getSecret() .equals( lockInfo_.getSecret() ) ) { assert lockInfo.equals( lockInfo_ ); boolean deleted = lockfile_.delete(); logger_.info( "Lockfile " + lockfile_ + " " + ( deleted ? "deleted" : "deletion attempt failed" ) ); } else { logger_.warning( "Lockfile " + lockfile_ + " has been " + " overwritten - not deleting" ); } } catch ( Throwable e ) { logger_.log( Level.WARNING, "Failed to delete lockfile " + lockfile_, e ); } } else { logger_.warning( "Lockfile " + lockfile_ + " has disappeared" ); } } // Withdraw service of the lockfile, if one has been published. if ( lockUrl_ != null ) { try { UtilServer.getInstance().getResourceHandler() .removeResource( lockUrl_ ); } catch ( IOException e ) { logger_.warning( "Failed to withdraw lockfile URL" ); } lockUrl_ = null; } // Shut down the hub service if exists. This sends out shutdown // messages to registered clients. if ( hub_ != null ) { try { hub_.shutdown(); } catch ( Throwable e ) { logger_.log( Level.WARNING, "Hub service shutdown failed", e ); } } // Remove the hub XML-RPC handler from the server. if ( hubHandler_ != null && server_ != null ) { server_.removeHandler( hubHandler_ ); server_ = null; } lockInfo_ = null; } /** * Returns the HubService object used by this runner. * * @return hub service */ public HubService getHub() { return hub_; } /** * Returns the lockfile information associated with this object. * Only present after {@link #start} has been called. * * @return lock info */ public LockInfo getLockInfo() { return lockInfo_; } /** * Returns an HTTP URL at which the lockfile for this hub can be found. * The first call to this method causes the lockfile to be published * in this way; subsequent calls return the same value. * *

      Use this with care; publishing your lockfile means that other people * can connect to your hub and potentially do disruptive things. * * @return lockfile information URL */ public URL publishLockfile() throws IOException { if ( lockUrl_ == null ) { ByteArrayOutputStream infoStrm = new ByteArrayOutputStream(); writeLockInfo( lockInfo_, infoStrm ); infoStrm.close(); final byte[] infoBuf = infoStrm.toByteArray(); URL url = UtilServer.getInstance().getResourceHandler() .addResource( "samplock", new ServerResource() { public long getContentLength() { return infoBuf.length; } public String getContentType() { return "text/plain"; } public void writeBody( OutputStream out ) throws IOException { out.write( infoBuf ); } } ); // Attempt to replace whatever host name is used by the FQDN, // for maximal usefulness to off-host clients. try { url = new URL( url.getProtocol(), InetAddress.getLocalHost() .getCanonicalHostName(), url.getPort(), url.getFile() ); } catch ( IOException e ) { } lockUrl_ = url; } return lockUrl_; } /** * Used to generate the registration password. May be overridden. * * @return pasword */ public String createSecret() { return Long.toHexString( random_.nextLong() ); } /** * Attempts to determine whether a given lockfile corresponds to a hub * which is still alive. * * @param xClientFactory XML-RPC client factory implementation * @param lockfile lockfile location * @return true if the hub described at lockfile appears * to be alive and well */ private static boolean isHubAlive( SampXmlRpcClientFactory xClientFactory, File lockfile ) { LockInfo info; try { info = readLockFile( lockfile ); } catch ( Exception e ) { logger_.log( Level.WARNING, "Failed to read lockfile", e ); return false; } if ( info == null ) { return false; } URL xurl = info.getXmlrpcUrl(); if ( xurl != null ) { try { xClientFactory.createClient( xurl ) .callAndWait( "samp.hub.ping", new ArrayList() ); return true; } catch ( Exception e ) { logger_.log( Level.WARNING, "Hub ping method failed", e ); return false; } } else { logger_.warning( "No XMLRPC URL in lockfile" ); return false; } } /** * Reads lockinfo from a file. * * @param lockFile file * @return info from file */ private static LockInfo readLockFile( File lockFile ) throws IOException { return LockInfo.readLockFile( new FileInputStream( lockFile ) ); } /** * Writes lockfile information to a given output stream. * The stream is not closed. * * @param info lock info to write * @param out destination stream */ private static void writeLockInfo( LockInfo info, OutputStream out ) throws IOException { LockWriter writer = new LockWriter( out ); writer.writeComment( "SAMP Standard Profile lockfile written " + new Date() ); writer.writeComment( "Note contact URL hostname may be " + "configured using " + SampUtils.LOCALHOST_PROP + " property" ); writer.writeAssignments( info ); out.flush(); } /** * Main method. Starts a hub. * Use "-help" flag for more information. * * @param args command-line arguments */ public static void main( String[] args ) throws IOException { int status = runMain( args ); if ( status != 0 ) { System.exit( status ); } } /** * Does the work for running the {@link #main} method. * System.exit() is not called from this method. * Use "-help" flag for more information. * * @param args command-line arguments * @return 0 means success, non-zero means error status */ public static int runMain( String[] args ) throws IOException { StringBuffer ubuf = new StringBuffer(); ubuf.append( "\n Usage:" ) .append( "\n " ) .append( HubRunner.class.getName() ) .append( "\n " ) .append( " [-help]" ) .append( " [-/+verbose]" ) .append( "\n " ) .append( " [-mode " ); HubMode[] modes = HubMode.getAvailableModes(); for ( int im = 0; im < modes.length; im++ ) { if ( im > 0 ) { ubuf.append( '|' ); } ubuf.append( modes[ im ].getName() ); } ubuf.append( ']' ) .append( " [-secret ]" ) .append( " [-httplock]" ) .append( "\n" ); String usage = ubuf.toString(); List argList = new ArrayList( Arrays.asList( args ) ); HubMode hubMode = HubMode.MESSAGE_GUI; if ( ! Arrays.asList( HubMode.getAvailableModes() ) .contains( hubMode ) ) { hubMode = HubMode.NO_GUI; } int verbAdjust = 0; XmlRpcKit xmlrpc = null; String secret = null; boolean httplock = false; for ( Iterator it = argList.iterator(); it.hasNext(); ) { String arg = (String) it.next(); if ( arg.equals( "-mode" ) && it.hasNext() ) { it.remove(); String mode = (String) it.next(); it.remove(); hubMode = HubMode.getModeFromName( mode ); if ( hubMode == null ) { System.err.println( "Unknown mode " + mode ); System.err.println( usage ); return 1; } } else if ( arg.equals( "-secret" ) && it.hasNext() ) { it.remove(); secret = (String) it.next(); it.remove(); } else if ( arg.equals( "-httplock" ) ) { it.remove(); httplock = true; } else if ( arg.startsWith( "-v" ) ) { it.remove(); verbAdjust--; } else if ( arg.startsWith( "+v" ) ) { it.remove(); verbAdjust++; } else if ( arg.startsWith( "-h" ) ) { it.remove(); System.out.println( usage ); return 0; } else { System.err.println( usage ); return 1; } } assert argList.isEmpty(); // Adjust logging in accordance with verboseness flags. int logLevel = Level.WARNING.intValue() + 100 * verbAdjust; Logger.getLogger( "org.astrogrid.samp" ) .setLevel( Level.parse( Integer.toString( logLevel ) ) ); // Get the location of the lockfile to write, if any. final File lockfile; if ( httplock ) { lockfile = null; } else { URL lockUrl = StandardClientProfile.getLockUrl(); File f = SampUtils.urlToFile( lockUrl ); if ( f == null ) { System.err.println( "Can't write lockfile to " + lockUrl ); System.err.println( "Try resetting " + DefaultClientProfile.HUBLOC_ENV + " environment variable." ); return 1; } else { lockfile = f; } } // Start the hub. HubRunner runner = runHub( hubMode, xmlrpc, secret, lockfile ); // If the lockfile is not the default one, write a message through // the logging system. URL lockfileUrl = httplock ? runner.publishLockfile() : SampUtils.fileToUrl( lockfile ); boolean isDflt = StandardClientProfile.getDefaultLockUrl().toString() .equals( lockfileUrl.toString() ); String hubassign = DefaultClientProfile.HUBLOC_ENV + "=" + StandardClientProfile.STDPROFILE_HUB_PREFIX + lockfileUrl; logger_.log( isDflt ? Level.INFO : Level.WARNING, hubassign ); // For non-GUI case block indefinitely otherwise the hub (which uses // a daemon thread) will not just exit immediately. if ( hubMode.isDaemon() ) { Object lock = new String( "Indefinite" ); synchronized ( lock ) { try { lock.wait(); } catch ( InterruptedException e ) { } } } // Success return. return 0; } /** * Static method which may be used to start a SAMP hub programmatically. * The returned hub is running (start has been called). * *

      If the hub mode corresponds to one of the GUI options, * one of two things will happen. An attempt will be made to install * an icon in the "system tray"; if this is successful, the attached * popup menu will provide options for displaying the hub window and * for shutting it down. If no system tray is available, the hub window * will be posted directly, and the hub will shut down when this window * is closed. System tray functionality is only available when running * under Java 1.6 or later, and when using a suitable display manager. * * @param hubMode hub mode * @param xmlrpc XML-RPC implementation; * automatically determined if null * @return running hub */ public static HubRunner runHub( HubMode hubMode, XmlRpcKit xmlrpc ) throws IOException { return runHub( hubMode, xmlrpc, null, SampUtils .urlToFile( StandardClientProfile.getLockUrl() ) ); } /** * Static method which may be used to start a SAMP hub programmatically, * with a supplied samp.secret string. * The returned hub is running (start has been called). * *

      If the hub mode corresponds to one of the GUI options, * one of two things will happen. An attempt will be made to install * an icon in the "system tray"; if this is successful, the attached * popup menu will provide options for displaying the hub window and * for shutting it down. If no system tray is available, the hub window * will be posted directly, and the hub will shut down when this window * is closed. System tray functionality is only available when running * under Java 1.6 or later, and when using a suitable display manager. * * @param hubMode hub mode * @param xmlrpc XML-RPC implementation; * automatically determined if null * @param secret samp.secret string to be used for hub connection; * chosen at random if null * @param lockfile location of lockfile to write, * or null for lock to be provided by HTTP * @return running hub */ public static HubRunner runHub( HubMode hubMode, XmlRpcKit xmlrpc, final String secret, File lockfile ) throws IOException { if ( xmlrpc == null ) { xmlrpc = XmlRpcKit.getInstance(); } HubRunner[] hubRunners = new HubRunner[ 1 ]; HubRunner runner = new HubRunner( xmlrpc.getClientFactory(), xmlrpc.getServerFactory(), hubMode.createHubService( random_, hubRunners ), lockfile ) { public String createSecret() { return secret == null ? super.createSecret() : secret; } }; hubRunners[ 0 ] = runner; runner.start(); return runner; } /** * Static method which will attempt to start a hub running in * an external JVM. The resulting hub can therefore outlast the * lifetime of the current application. * Because of the OS interaction required, it's hard to make this * bulletproof, and it may fail without an exception, but we do our best. * * @param hubMode hub mode * @see #checkExternalHubAvailability */ public static void runExternalHub( HubMode hubMode ) throws IOException { String classpath = System.getProperty( "java.class.path" ); if ( classpath == null || classpath.trim().length() == 0 ) { throw new IOException( "No classpath available - JNLP context?" ); } File javaHome = new File( System.getProperty( "java.home" ) ); File javaExec = new File( new File( javaHome, "bin" ), "java" ); String javacmd = ( javaExec.exists() && ! javaExec.isDirectory() ) ? javaExec.toString() : "java"; String[] propagateProps = new String[] { XmlRpcKit.IMPL_PROP, UtilServer.PORT_PROP, SampUtils.LOCALHOST_PROP, "java.awt.Window.locationByPlatform", }; List argList = new ArrayList(); argList.add( javacmd ); for ( int ip = 0; ip < propagateProps.length; ip++ ) { String propName = propagateProps[ ip ]; String propVal = System.getProperty( propName ); if ( propVal != null ) { argList.add( "-D" + propName + "=" + propVal ); } } argList.add( "-classpath" ); argList.add( classpath ); argList.add( HubRunner.class.getName() ); argList.add( "-mode" ); argList.add( hubMode.toString() ); String[] args = (String[]) argList.toArray( new String[ 0 ] ); StringBuffer cmdbuf = new StringBuffer(); for ( int iarg = 0; iarg < args.length; iarg++ ) { if ( iarg > 0 ) { cmdbuf.append( ' ' ); } cmdbuf.append( args[ iarg ] ); } logger_.info( "Starting external hub" ); logger_.info( cmdbuf.toString() ); execBackground( args ); } /** * Attempts to determine whether an external hub can be started using * {@link #runExternalHub}. If it can be determined that such an * attempt would fail, this method will throw an exception with * an informative message. This method succeeding is not a guarantee * that an external hub can be started successfullly. * The behaviour of this method is not expected to change over the * lifetime of a given JVM. */ public static void checkExternalHubAvailability() throws IOException { String classpath = System.getProperty( "java.class.path" ); if ( classpath == null || classpath.trim().length() == 0 ) { throw new IOException( "No classpath available - JNLP context?" ); } if ( System.getProperty( "jnlpx.jvm" ) != null ) { throw new IOException( "Running under WebStart" + " - external hub not likely to work" ); } } /** * Executes a command in a separate process, and discards any stdout * or stderr output generated by it. * Simply calling Runtime.exec can block the process * until its output is consumed. * * @param cmdarray array containing the command to call and its args */ private static void execBackground( String[] cmdarray ) throws IOException { Process process = Runtime.getRuntime().exec( cmdarray ); discardBytes( process.getInputStream() ); discardBytes( process.getErrorStream() ); } /** * Ensures that any bytes from a given input stream are discarded. * * @param in input stream */ private static void discardBytes( final InputStream in ) { Thread eater = new Thread( "StreamEater" ) { public void run() { try { while ( in.read() >= 0 ) {} in.close(); } catch ( IOException e ) { } } }; eater.setDaemon( true ); eater.start(); } } jsamp/src/java/org/astrogrid/samp/xmlrpc/ClientActor.java0000664000175000017500000000307712730747754023333 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.util.Map; /** * Defines the methods which an XML-RPC callable client must implement. * * @author Mark Taylor * @since 16 Jul 2008 */ interface ClientActor { /** * Receives a message for which no response is required. * * @param privateKey private key for hub-client communication * @param senderId public ID of sending client * @param msg message */ void receiveNotification( String privateKey, String senderId, Map msg ) throws Exception; /** * Receives a message for which a response is required. * The implementation must take care to call the hub's reply * method at some future point. * * @param privateKey private key for hub-client communication * @param senderId public ID of sending client * @param msgId message identifier for later use with reply * @param msg message */ void receiveCall( String privateKey, String senderId, String msgId, Map msg ) throws Exception; /** * Receives a response to a message previously sent by this client. * * @param privateKey private key for hub-client communication * @param responderId public ID of responding client * @param msgTag client-defined tag labelling previously-sent message * @param response returned response object */ void receiveResponse( String privateKey, String responderId, String msgTag, Map response ) throws Exception; } jsamp/src/java/org/astrogrid/samp/xmlrpc/SampXmlRpcHandler.java0000664000175000017500000000252112730747754024441 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.util.List; /** * Interface for an object which can process certain XML-RPC requests. * Used by {@link SampXmlRpcServer}. * * @author Mark Taylor * @since 22 Aug 2008 */ public interface SampXmlRpcHandler { /** * Returns true if this handler should be able to process * given XML-RPC method. * * @param method method name */ boolean canHandleCall( String method ); /** * Processes an XML-RPC call. This method should only be called if * {@link #canHandleCall canHandleCall(method)} returns true. * The params list and the return value must be * SAMP-compatible, that is only Strings, Lists and String-keyed Maps * are allowed in the data structures. * The reqInfo parameter may be used to provide additional * information about the XML-RPC request, for instance the originating * host; this is implementation specific, and may be null. * * @param method XML-RPC method name * @param params XML-RPC parameter list (SAMP-compatible) * @param reqInfo optional additional request information; may be null * @return return value (SAMP-compatible) */ Object handleCall( String method, List params, Object reqInfo ) throws Exception; } jsamp/src/java/org/astrogrid/samp/xmlrpc/XmlRpcKit.java0000664000175000017500000003165712730747754023006 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.util.logging.Logger; /** * Encapsulates the provision of XML-RPC client and server capabilities. * Two implementations are provided in the JSAMP package; * the pluggable architecture allows others to be provided. * * @author Mark Taylor * @since 27 Aug 2008 */ public abstract class XmlRpcKit { /** Implementation based on Apache XML-RPC. */ public static final XmlRpcKit APACHE; /** Implementation which requires no external libraries. */ public static final XmlRpcKit INTERNAL; /** Internal implementation variant with verbose logging of XML I/O. */ public static final XmlRpcKit XML_LOGGING; /** Internal implementation variant with verbose logging of RPC calls. */ public static final XmlRpcKit RPC_LOGGING; /** Array of available known implementations of this class. */ public static XmlRpcKit[] KNOWN_IMPLS = { INTERNAL = createReflectionKit( "internal", "org.astrogrid.samp.xmlrpc.internal.InternalClientFactory", "org.astrogrid.samp.xmlrpc.internal.InternalServerFactory" ), XML_LOGGING = createReflectionKit( "xml-log", "org.astrogrid.samp.xmlrpc.internal" + ".XmlLoggingInternalClientFactory", "org.astrogrid.samp.xmlrpc.internal" + ".XmlLoggingInternalServerFactory" ), RPC_LOGGING = createReflectionKit( "rpc-log", "org.astrogrid.samp.xmlrpc.internal" + ".RpcLoggingInternalClientFactory", "org.astrogrid.samp.xmlrpc.internal" + ".RpcLoggingInternalServerFactory" ), APACHE = createApacheKit( "apache" ), }; /** * Property which is examined to determine which implementation to use * by default. Property values may be one of the elements of * {@link #KNOWN_IMPLS}, currently: *

        *
      • internal
      • *
      • xml-log
      • *
      • rpc-log
      • *
      • apache
      • *
      * Alternatively, it may be the classname of a class which implements * {@link org.astrogrid.samp.xmlrpc.XmlRpcKit} * and has a no-arg constructor. * The property name is {@value}. */ public static final String IMPL_PROP = "jsamp.xmlrpc.impl"; private static XmlRpcKit defaultInstance_; private static Logger logger_ = Logger.getLogger( XmlRpcKit.class.getName() ); /** * Returns an XML-RPC client factory. * * @return client factory */ public abstract SampXmlRpcClientFactory getClientFactory(); /** * Returns an XML-RPC server factory. * * @return server factory */ public abstract SampXmlRpcServerFactory getServerFactory(); /** * Indicates whether this object is ready for use. * If it returns false (perhaps because some classes are unavailable * at runtime) then {@link #getClientFactory} and {@link #getServerFactory} * may throw exceptions rather than behaving as documented. * * @return true if this object works */ public abstract boolean isAvailable(); /** * Returns the name of this kit. * * @return implementation name */ public abstract String getName(); /** * Returns the default instance of this class. * What implementation this is normally depends on what classes * are present at runtime. * However, if the system property {@link #IMPL_PROP} is set this * will determine the implementation used. It may be one of: *
        *
      • apache: implementation based on the * Apache XML-RPC library
      • *
      • internal: implementation which requires no libraries * beyond JSAMP itself
      • *
      • the classname of an implementation of this class which has a * no-arg constructor
      • *
      * * @return default instance of this class */ public static XmlRpcKit getInstance() { if ( defaultInstance_ == null ) { defaultInstance_ = createDefaultInstance(); logger_.info( "Default XmlRpcInstance is " + defaultInstance_ ); } return defaultInstance_; } /** * Returns an XmlRpcKit instance given its name. * * @param name name of one of the known implementations, or classname * of an XmlRpcKit implementatation with a no-arg * constructor * @return named implementation object * @throws IllegalArgumentException if none by that name can be found */ public static XmlRpcKit getInstanceByName( String name ) { // Implementation specified by system property - // try to find one with a matching name in the known list. XmlRpcKit[] impls = KNOWN_IMPLS; for ( int i = 0; i < impls.length; i++ ) { if ( name.equalsIgnoreCase( impls[ i ].getName() ) ) { return impls[ i ]; } } // Still not got one - // try to interpret system property as class name. Class clazz; try { clazz = Class.forName( name ); } catch ( ClassNotFoundException e ) { throw new IllegalArgumentException( "No such XML-RPC " + "implementation \"" + name + "\"" ); } try { return (XmlRpcKit) clazz.newInstance(); } catch ( Throwable e ) { throw (RuntimeException) new IllegalArgumentException( "Error instantiating custom " + "XmlRpcKit " + clazz.getName() ) .initCause( e ); } } /** * Constructs the default instance of this class based on system property * and class availability. * * @return XmlRpcKit object * @see #getInstance */ private static XmlRpcKit createDefaultInstance() { XmlRpcKit[] impls = KNOWN_IMPLS; String implName = System.getProperty( IMPL_PROP ); logger_.info( "Creating default XmlRpcInstance: " + IMPL_PROP + "=" + implName ); // No implementation specified by system property - // use the first one in the list that works. if ( implName == null ) { for ( int i = 0; i < impls.length; i++ ) { if ( impls[ i ].isAvailable() ) { return impls[ i ]; } } return impls[ 0 ]; } // Implementation specified by system property - // try to find one with a matching name in the known list. else { return getInstanceByName( implName ); } } /** * Returns a new XmlRpcKit given classnames for the client and server * factory classes. If the classes are not available, a kit which * returns {@link #isAvailable}()=false will be returned. * * @param name kit name * @param clientFactoryClassName name of class implementing * SampXmlRpcClientFactory which has a no-arg constructor * @param serverFactoryClassName name of class implementing * SampXmlRpcServerFactory which has a no-arg constructor * @return new XmlRpcKit constructed using reflection */ public static XmlRpcKit createReflectionKit( String name, String clientFactoryClassName, String serverFactoryClassName ) { SampXmlRpcClientFactory clientFactory = null; SampXmlRpcServerFactory serverFactory = null; Throwable error = null; try { clientFactory = (SampXmlRpcClientFactory) Class.forName( clientFactoryClassName ) .newInstance(); serverFactory = (SampXmlRpcServerFactory) Class.forName( serverFactoryClassName ) .newInstance(); } catch ( ClassNotFoundException e ) { error = e; } catch ( LinkageError e ) { error = e; } catch ( InstantiationException e ) { error = e; } catch ( IllegalAccessException e ) { error = e; } if ( clientFactory != null && serverFactory != null ) { assert error == null; return new AvailableKit( name, clientFactory, serverFactory ); } else { assert error != null; return new UnavailableKit( name, error ); } } /** * XmlRpcKit implementation which is available. */ private static class AvailableKit extends XmlRpcKit { private final String name_; private final SampXmlRpcClientFactory clientFactory_; private final SampXmlRpcServerFactory serverFactory_; /** * Constructor. * * @param name implementation name * @param clientFactory SampXmlRpcClientFactory instance * @param serverFactory SampXmlRpcServerFactory instance */ AvailableKit( String name, SampXmlRpcClientFactory clientFactory, SampXmlRpcServerFactory serverFactory ) { name_ = name; clientFactory_ = clientFactory; serverFactory_ = serverFactory; } public SampXmlRpcClientFactory getClientFactory() { return clientFactory_; } public SampXmlRpcServerFactory getServerFactory() { return serverFactory_; } public String getName() { return name_; } public boolean isAvailable() { return true; } public String toString() { return name_; } } /** * XmlRpcKit implementation which always returns false from isAvailable * and throws exceptions from getServer/Client factory methods. */ private static class UnavailableKit extends XmlRpcKit { private final String name_; private final Throwable error_; /** * Constructor. * * @param kit name * @param error the reason the kit is unavailable */ UnavailableKit( String name, Throwable error ) { name_ = name; error_ = error; } public SampXmlRpcClientFactory getClientFactory() { throw (RuntimeException) new UnsupportedOperationException( name_ + " implementation not" + " available" ) .initCause( error_ ); } public SampXmlRpcServerFactory getServerFactory() { throw (RuntimeException) new UnsupportedOperationException( name_ + " implementation not" + " available" ) .initCause( error_ ); } public String getName() { return name_; } public boolean isAvailable() { return false; } public String toString() { return name_; } } /** * Returns an available or unavailable XmlRpcKit based on Apache XML-RPC * version 1.2. * * @param name kit name * @return new kit */ private static XmlRpcKit createApacheKit( String name ) { XmlRpcKit kit = createReflectionKit( name, "org.astrogrid.samp.xmlrpc.apache.ApacheClientFactory", "org.astrogrid.samp.xmlrpc.apache.ApacheServerFactory" ); if ( kit.isAvailable() ) { try { Class xClazz = Class.forName( "org.apache.xmlrpc.XmlRpc" ); Field vField = xClazz.getField( "version" ); Object version = Modifier.isStatic( vField.getModifiers() ) ? vField.get( null ) : null; if ( version instanceof String && ((String) version) .startsWith( "Apache XML-RPC 1.2" ) ) { return kit; } else { String msg = "Wrong Apache XML-RPC version: " + version + " not 1.2"; return new UnavailableKit( name, new ClassNotFoundException( msg ) ); } } catch ( Throwable e ) { return new UnavailableKit( name, e ); } } else { return kit; } } } jsamp/src/java/org/astrogrid/samp/xmlrpc/HubMode.java0000664000175000017500000003010512730747754022437 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.awt.AWTException; import java.awt.Image; import java.awt.MenuItem; import java.awt.PopupMenu; import java.awt.Toolkit; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.util.ArrayList; import java.util.List; import java.util.Random; import java.util.logging.Logger; import javax.swing.JFrame; import org.astrogrid.samp.Client; import org.astrogrid.samp.gui.GuiHubService; import org.astrogrid.samp.gui.MessageTrackerHubService; import org.astrogrid.samp.gui.SysTray; import org.astrogrid.samp.hub.HubService; import org.astrogrid.samp.hub.BasicHubService; /** * Specifies a particular hub implementation for use with {@link HubRunner}. * * @author Mark Taylor * @since 20 Nov 2008 * @deprecated use {@link org.astrogrid.samp.hub.HubServiceMode} with * {@link org.astrogrid.samp.hub.Hub} instead */ public abstract class HubMode { // This class looks like an enumeration-type class to external users. // It is actually a HubService factory. private final String name_; private final boolean isDaemon_; private static final Logger logger_ = Logger.getLogger( HubMode.class.getName() ); /** Hub mode with no GUI representation of hub operations. */ public static final HubMode NO_GUI; /** Hub mode with a GUI representation of connected clients. */ public static final HubMode CLIENT_GUI; /** Hub mode with a GUI representation of clients and messages. */ public static HubMode MESSAGE_GUI; /** Array of available hub modes. */ private static final HubMode[] KNOWN_MODES = new HubMode[] { NO_GUI = createBasicHubMode( "no-gui" ), CLIENT_GUI = createGuiHubMode( "client-gui" ), MESSAGE_GUI = createMessageTrackerHubMode( "msg-gui" ), }; /** * Constructor. * * @param name mode name * @param isDaemon true if the hub will start only daemon threads */ HubMode( String name, boolean isDaemon ) { name_ = name; isDaemon_ = isDaemon; } /** * Returns a new HubService object. * * @param random random number generator * @param runners 1-element array of HubRunners - this should be * populated with the runner once it has been constructed */ abstract HubService createHubService( Random random, HubRunner[] runners ); /** * Indicates whether the hub service will start only daemon threads. * If it returns true, the caller may need to make sure that the * JVM doesn't stop too early. * * @return true iff no non-daemon threads will be started by the service */ boolean isDaemon() { return isDaemon_; } /** * Returns this mode's name. * * @return mode name */ String getName() { return name_; } public String toString() { return name_; } /** * Returns one of the known modes which has a name as given. * * @param name mode name (case-insensitive) * @return mode with given name, or null if none known */ public static HubMode getModeFromName( String name ) { HubMode[] modes = KNOWN_MODES; for ( int im = 0; im < modes.length; im++ ) { HubMode mode = modes[ im ]; if ( mode.name_.equalsIgnoreCase( name ) ) { return mode; } } return null; } /** * Returns an array of the hub modes which can actually be used. * * @return available mode list */ public static HubMode[] getAvailableModes() { List modeList = new ArrayList(); for ( int i = 0; i < KNOWN_MODES.length; i++ ) { HubMode mode = KNOWN_MODES[ i ]; if ( ! ( mode instanceof BrokenHubMode ) ) { modeList.add( mode ); } } return (HubMode[]) modeList.toArray( new HubMode[ 0 ] ); } /** * Used to perform common configuration of hub display windows * for GUI-type hub modes. * * @param frame hub window * @param runners 1-element array which will contain an associated * hub runner object if one exists */ static void configureHubWindow( JFrame frame, HubRunner[] runners ) { SysTray sysTray = SysTray.getInstance(); if ( sysTray.isSupported() ) { try { configureWindowForSysTray( frame, runners, sysTray ); } catch ( AWTException e ) { logger_.warning( "Failed to install in system tray: " + e ); configureWindowBasic( frame, runners ); } logger_.info( "Hub started in system tray" ); } else { logger_.info( "System tray not supported: displaying hub window" ); configureWindowBasic( frame, runners ); } } /** * Performs common configuration of hub display window without * system tray functionality. * @param frame hub window * @param runners 1-element array which will contain an associated * hub runner object if one exists */ private static void configureWindowBasic( JFrame frame, final HubRunner[] runners ) { frame.setDefaultCloseOperation( JFrame.DISPOSE_ON_CLOSE ); frame.addWindowListener( new WindowAdapter() { public void windowClosed( WindowEvent evt ) { HubRunner runner = runners[ 0 ]; if ( runner != null ) { runner.shutdown(); } } } ); frame.setVisible( true ); } /** * Performs common configuration of hub display window with * system tray functionality. * * @param frame hub window * @param runners 1-element array which will contain an associated * hub runner object if one exists * @param sysTray system tray facade object */ private static void configureWindowForSysTray( final JFrame frame, final HubRunner[] runners, final SysTray sysTray ) throws AWTException { /* Prepare the items for display in the tray icon popup menu. */ final MenuItem showItem; final MenuItem hideItem; final MenuItem stopItem; MenuItem[] items = new MenuItem[] { showItem = new MenuItem( "Show Hub Window" ), hideItem = new MenuItem( "Hide Hub Window" ), stopItem = new MenuItem( "Stop Hub" ), }; ActionListener iconListener = new ActionListener() { public void actionPerformed( ActionEvent evt ) { frame.setVisible( true ); showItem.setEnabled( false ); hideItem.setEnabled( true ); } }; /* Construct and install the tray icon. */ Image im = Toolkit.getDefaultToolkit() .createImage( Client.class.getResource( "images/hub.png" ) ); String tooltip = "SAMP Hub"; PopupMenu popup = new PopupMenu(); final Object trayIcon = sysTray.addIcon( im, tooltip, popup, iconListener ); /* Arrange for the menu items to do something appropriate when * triggered. */ ActionListener popListener = new ActionListener() { public void actionPerformed( ActionEvent evt ) { String cmd = evt.getActionCommand(); if ( cmd.equals( showItem.getActionCommand() ) || cmd.equals( hideItem.getActionCommand() ) ) { boolean visible = cmd.equals( showItem.getActionCommand() ); frame.setVisible( visible ); showItem.setEnabled( ! visible ); hideItem.setEnabled( visible ); } else if ( cmd.equals( stopItem.getActionCommand() ) ) { HubRunner runner = runners[ 0 ]; if ( runner != null ) { runner.shutdown(); } try { sysTray.removeIcon( trayIcon ); } catch ( AWTException e ) { logger_.warning( e.toString() ); } frame.dispose(); } } }; for ( int i = 0; i < items.length; i++ ) { items[ i ].addActionListener( popListener ); popup.add( items[ i ] ); } /* Arrange that a manual window close will set the action states * correctly. */ frame.setDefaultCloseOperation( JFrame.DISPOSE_ON_CLOSE ); frame.addWindowListener( new WindowAdapter() { public void windowClosed( WindowEvent evt ) { showItem.setEnabled( true ); hideItem.setEnabled( false ); } } ); /* Set to initial state. */ popListener.actionPerformed( new ActionEvent( frame, 0, hideItem.getActionCommand() ) ); } /** * Constructs a mode for BasicHubService. * * @return non-gui mode */ private static HubMode createBasicHubMode( String name ) { try { return new HubMode( name, true ) { HubService createHubService( Random random, HubRunner[] runners ) { return new BasicHubService( random ); } }; } catch ( Throwable e ) { return new BrokenHubMode( name, e ); } } /** * Constructs a mode for GuiHubService. * * @return mode without message tracking */ private static HubMode createGuiHubMode( String name ) { try { GuiHubService.class.getName(); return new HubMode( name, false ) { HubService createHubService( Random random, final HubRunner[] runners ) { return new GuiHubService( random ) { public void start() { super.start(); configureHubWindow( createHubWindow(), runners ); } }; } }; } catch ( Throwable e ) { return new BrokenHubMode( name, e ); } } /** * Constructs a mode for MessageTrackerHubService. * * @return mode with message tracking */ private static HubMode createMessageTrackerHubMode( String name ) { try { MessageTrackerHubService.class.getName(); return new HubMode( name, false ) { HubService createHubService( Random random, final HubRunner[] runners ) { return new MessageTrackerHubService( random ) { public void start() { super.start(); configureHubWindow( createHubWindow(), runners ); } }; } }; } catch ( Throwable e ) { return new BrokenHubMode( name, e ); } } /** * HubMode implemenetation for modes which cannot be used because they * rely on classes unavailable at runtime. */ private static class BrokenHubMode extends HubMode { private final Throwable error_; /** * Constructor. * * @param name mode name * @param error error explaining why mode is unavailable for use */ BrokenHubMode( String name, Throwable error ) { super( name, false ); error_ = error; } HubService createHubService( Random random, HubRunner[] runners ) { throw new RuntimeException( "Hub mode " + getName() + " unavailable", error_ ); } } } jsamp/src/java/org/astrogrid/samp/xmlrpc/XmlRpcCallableClient.java0000664000175000017500000000710212730747754025101 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.io.IOException; import java.util.ArrayList; import java.util.List; import org.astrogrid.samp.Message; import org.astrogrid.samp.Response; import org.astrogrid.samp.ShutdownManager; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.SampException; /** * CallableClient implementation used to communicate with XML-RPC-based * callable clients. * * @author Mark Taylor * @since 28 Jan 2011 */ class XmlRpcCallableClient implements CallableClient { private final SampXmlRpcClient xClient_; private final String privateKey_; private static volatile boolean isShutdown_; static { ShutdownManager.getInstance() .registerHook( XmlRpcCallableClient.class, ShutdownManager.PREPARE_SEQUENCE, new Runnable() { public void run() { isShutdown_ = true; } } ); } /** * Constructor. * * @param xClient XML-RPC client implementation * @param SAMP client's private key */ public XmlRpcCallableClient( SampXmlRpcClient xClient, String privateKey ) { xClient_ = xClient; privateKey_ = privateKey; } public void receiveCall( String senderId, String msgId, Message msg ) throws SampException { exec( "receiveCall", new Object[] { senderId, msgId, msg, } ); } public void receiveNotification( String senderId, Message msg ) throws SampException { exec( "receiveNotification", new Object[] { senderId, msg, } ); } public void receiveResponse( String responderId, String msgTag, Response response ) throws SampException { exec( "receiveResponse", new Object[] { responderId, msgTag, response, } ); } /** * Makes an XML-RPC call to the SAMP callable client represented * by this receiver. * * @param methodName unqualified SAMP callable client API method name * @param params array of method parameters */ private void exec( String methodName, Object[] params ) throws SampException { List paramList = new ArrayList(); paramList.add( privateKey_ ); for ( int ip = 0; ip < params.length; ip++ ) { paramList.add( params[ ip ] ); } try { rawExec( "samp.client." + methodName, paramList ); } catch ( IOException e ) { throw new SampException( e.getMessage(), e ); } } /** * Actually makes an XML-RPC call to the SAMP callable client * represented by this receiver. * * @param fqName fully qualified SAMP callable client API method name * @param paramList list of method parameters */ private void rawExec( String fqName, List paramList ) throws IOException { // In most cases, callAndForget is adequate. // However, if the JVM is in the process of shutting down, the // hub shutdown that this triggers will attempt to message clients // to tell them so, and the threads that callAndForget uses to // handle those calls will die during the communications, meaning // that the clients are not properly informed of shutdown. // So in the case of shutdown, do it synchronously. if ( isShutdown_ ) { xClient_.callAndWait( fqName, paramList ); } else { xClient_.callAndForget( fqName, paramList ); } } } jsamp/src/java/org/astrogrid/samp/xmlrpc/XmlRpcHubConnection.java0000664000175000017500000002137512730747754025011 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.logging.Logger; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.RegInfo; import org.astrogrid.samp.Response; import org.astrogrid.samp.ShutdownManager; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; /** * Partial HubConnection implementation based on XML-RPC. * No implementation is provided for the {@link #setCallable} method. * This is a useful base class for XML-RPC-based profile implementations, * but it is not perfectly general: some assumptions, compatible * with the Standard Profile, are made about the way that XML-RPC * calls are mapped on to SAMP hub interface calls. * * @author Mark Taylor * @author Sylvain Lafrasse * @since 16 Jul 2008 */ public abstract class XmlRpcHubConnection implements HubConnection { private final SampXmlRpcClient xClient_; private final String prefix_; private final RegInfo regInfo_; private boolean unregistered_; private static final Logger logger_ = Logger.getLogger( XmlRpcHubConnection.class.getName() ); /** * Constructor. * * @param xClient XML-RPC client * @param prefix string prepended to all hub interface method names * to turn them into XML-RPC method names * @param registerArgs arguments to the profile-specific "register" * method to initiate this connection */ public XmlRpcHubConnection( SampXmlRpcClient xClient, String prefix, List registerArgs ) throws SampException { xClient_ = xClient; prefix_ = prefix; Object regInfo = rawExec( prefix_ + "register", registerArgs ); if ( regInfo instanceof Map ) { regInfo_ = RegInfo.asRegInfo( Collections .unmodifiableMap( asMap( regInfo ) ) ); } else { throw new SampException( "Bad return value from hub register method" + " - not a map" ); } ShutdownManager.getInstance() .registerHook( this, ShutdownManager.CLIENT_SEQUENCE, new Runnable() { public void run() { finish(); } } ); } public RegInfo getRegInfo() { return regInfo_; } public void ping() throws SampException { rawExec( prefix_ + "ping", new ArrayList() ); } public void unregister() throws SampException { exec( "unregister", new Object[ 0 ] ); ShutdownManager.getInstance().unregisterHook( this ); unregistered_ = true; } public void declareMetadata( Map meta ) throws SampException { exec( "declareMetadata", new Object[] { meta } ); } public Metadata getMetadata( String clientId ) throws SampException { return Metadata .asMetadata( asMap( exec( "getMetadata", new Object[] { clientId } ) ) ); } public void declareSubscriptions( Map subs ) throws SampException { exec( "declareSubscriptions", new Object[] { subs } ); } public Subscriptions getSubscriptions( String clientId ) throws SampException { return Subscriptions .asSubscriptions( asMap( exec( "getSubscriptions", new Object[] { clientId } ) ) ); } public String[] getRegisteredClients() throws SampException { return (String[]) asList( exec( "getRegisteredClients", new Object[ 0 ] ) ) .toArray( new String[ 0 ] ); } public Map getSubscribedClients( String mtype ) throws SampException { return asMap( exec( "getSubscribedClients", new Object[] { mtype } ) ); } public void notify( String recipientId, Map msg ) throws SampException { exec( "notify", new Object[] { recipientId, msg } ); } public List notifyAll( Map msg ) throws SampException { return asList( exec( "notifyAll", new Object[] { msg } ) ); } public String call( String recipientId, String msgTag, Map msg ) throws SampException { return asString( exec( "call", new Object[] { recipientId, msgTag, msg } ) ); } public Map callAll( String msgTag, Map msg ) throws SampException { return asMap( exec( "callAll", new Object[] { msgTag, msg } ) ); } public Response callAndWait( String recipientId, Map msg, int timeout ) throws SampException { return Response .asResponse( asMap( exec( "callAndWait", new Object[] { recipientId, msg, SampUtils .encodeInt( timeout ) } ) ) ); } public void reply( String msgId, Map response ) throws SampException { exec( "reply", new Object[] { msgId, response } ); } /** * Returns an object which is used as the first argument of most * XML-RPC calls to the hub. * * @return SAMP-friendly object to identify this client */ public abstract Object getClientKey(); /** * Makes an XML-RPC call to the SAMP hub represented by this connection. * The result of {@link #getClientKey} is passed as the first argument * of the XML-RPC call. * * @param methodName unqualified SAMP hub API method name * @param params array of method parameters * @return XML-RPC call return value */ public Object exec( String methodName, Object[] params ) throws SampException { List paramList = new ArrayList(); paramList.add( getClientKey() ); for ( int ip = 0; ip < params.length; ip++ ) { paramList.add( params[ ip ] ); } return rawExec( prefix_ + methodName, paramList ); } /** * Actually makes an XML-RPC call to the SAMP hub represented by this * connection. * * @param fqName fully qualified SAMP hub API method name * @param paramList list of method parameters * @return XML-RPC call return value */ public Object rawExec( String fqName, List paramList ) throws SampException { try { return xClient_.callAndWait( fqName, paramList ); } catch ( IOException e ) { throw new SampException( e.getMessage(), e ); } } /** * Unregisters if not already unregistered. * May harmlessly be called multiple times. */ private void finish() { if ( ! unregistered_ ) { try { unregister(); } catch ( SampException e ) { } } } /** * Unregisters if not already unregistered. */ public void finalize() throws Throwable { try { finish(); } finally { super.finalize(); } } /** * Utility method to cast an object to a given SAMP-like type. * * @param obj object to cast * @param clazz class to cast to * @param name SAMP name of type * @return obj * @throws SampException if cast attempt failed */ private static Object asType( Object obj, Class clazz, String name ) throws SampException { if ( clazz.isAssignableFrom( obj.getClass() ) ) { return obj; } else { throw new SampException( "Hub returned unexpected type (" + obj.getClass().getName() + " not " + name ); } } /** * Utility method to cast an object to a string. * * @param obj object * @return object as string * @throws SampException if cast attempt failed */ private String asString( Object obj ) throws SampException { return (String) asType( obj, String.class, "string" ); } /** * Utility method to cast an object to a list. * * @param obj object * @return object as list * @throws SampException if cast attempt failed */ private List asList( Object obj ) throws SampException { return (List) asType( obj, List.class, "list" ); } /** * Utility method to cast an object to a map. * * @param obj object * @return object as map * @throws SampException if cast attempt failed */ private Map asMap( Object obj ) throws SampException { return (Map) asType( obj, Map.class, "map" ); } } jsamp/src/java/org/astrogrid/samp/xmlrpc/CallableClientServer.java0000664000175000017500000001040412730747754025141 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.io.IOException; import java.net.URL; import java.util.Map; import java.util.HashMap; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.HubConnection; /** * XML-RPC server which can host {@link CallableClient} instances. * There should usually be only one instance of this class for each * SampXmlRpcServer - see {@link #getInstance}. * * @author Mark Taylor * @since 16 Jul 2008 */ class CallableClientServer { private final URL url_; private SampXmlRpcServer server_; private ClientXmlRpcHandler clientHandler_; private static final Map serverMap_ = new HashMap(); /** * Constructor. Note that a {@link #getInstance} method exists as well. * * @param server XML-RPC server hosting this client server */ public CallableClientServer( SampXmlRpcServer server ) throws IOException { server_ = server; clientHandler_ = new ClientXmlRpcHandler(); server_.addHandler( clientHandler_ ); url_ = server_.getEndpoint(); } /** * Returns the XML-RPC endpoint for this server. * * @return endpoint url */ public URL getUrl() { return url_; } /** * Adds a CallableClient object to this server. * * @param connection hub connection for the registered client on behalf * of which the client will operate * @param callable callable client object */ public void addClient( HubConnection connection, CallableClient callable ) { if ( clientHandler_ == null ) { throw new IllegalStateException( "Closed" ); } clientHandler_.addClient( connection, callable ); } /** * Removes a CallableClient object from this server. * * @param privateKey hub connection for which this client was added */ public void removeClient( HubConnection connection ) { if ( clientHandler_ != null ) { clientHandler_.removeClient( connection ); } } /** * Tidies up resources. Following a call to this method, no further * clients can be added. */ public void close() { if ( server_ != null ) { server_.removeHandler( clientHandler_ ); } server_ = null; clientHandler_ = null; } /** * Indicates whether this server currently has any clients. * * @return true iff there are clients */ boolean hasClients() { return clientHandler_ != null && clientHandler_.getClientCount() > 0; } /** * Returns an instance of CallableClientServer for use with a given * XML-RPC server. Because of the implementation, only one * CallableClientServer is permitted per XML-RPC server, so if one * has already been installed for the given server, * that one will be returned. Otherwise a new one will be constructed, * installed and returned. * *

      To prevent memory leaks, once any clients added to the returned * server have been removed (the client count drops to zero), the * server will be closed and cannot be re-used. * * @param server XML-RPC server * @return new or re-used CallableClientServer which is installed on * server */ public static synchronized CallableClientServer getInstance( SampXmlRpcServerFactory serverFact ) throws IOException { final SampXmlRpcServer server = serverFact.getServer(); if ( ! serverMap_.containsKey( server ) ) { CallableClientServer clientServer = new CallableClientServer( server ) { public void removeClient( HubConnection connection ) { super.removeClient( connection ); if ( ! hasClients() ) { close(); synchronized ( CallableClientServer.class ) { serverMap_.remove( server ); } } } }; serverMap_.put( server, clientServer ); } return (CallableClientServer) serverMap_.get( server ); } } jsamp/src/java/org/astrogrid/samp/xmlrpc/StandardHubProfile.java0000664000175000017500000003330512730747754024641 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.net.InetAddress; import java.net.URL; import java.util.ArrayList; import java.util.Date; import java.util.Random; import java.util.logging.Level; import java.util.logging.Logger; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.DefaultClientProfile; import org.astrogrid.samp.httpd.ServerResource; import org.astrogrid.samp.httpd.UtilServer; import org.astrogrid.samp.hub.HubProfile; import org.astrogrid.samp.hub.KeyGenerator; import org.astrogrid.samp.hub.LockWriter; import org.astrogrid.samp.hub.MessageRestriction; /** * HubProfile implementation for the SAMP Standard Profile. * * @author Mark Taylor * @since 31 Jan 2011 */ public class StandardHubProfile implements HubProfile { private final SampXmlRpcClientFactory xClientFactory_; private final SampXmlRpcServerFactory xServerFactory_; private final File lockfile_; private final String secret_; private URL lockUrl_; private SampXmlRpcServer server_; private volatile HubXmlRpcHandler hubHandler_; private LockInfo lockInfo_; private static final Logger logger_ = Logger.getLogger( StandardHubProfile.class.getName() ); private static final Random random_ = KeyGenerator.createRandom(); /** * Constructs a hub profile with given configuration information. * If the supplied lockfile is null, no lockfile will * be written at hub startup. * * @param xClientFactory XML-RPC client factory implementation * @param xServerFactory XML-RPC server implementation * @param lockfile location to use for hub lockfile, or null * @param secret value for samp.secret lockfile key */ public StandardHubProfile( SampXmlRpcClientFactory xClientFactory, SampXmlRpcServerFactory xServerFactory, File lockfile, String secret ) { xClientFactory_ = xClientFactory; xServerFactory_ = xServerFactory; lockfile_ = lockfile; secret_ = secret; } /** * Constructs a hub profile with default configuration. */ public StandardHubProfile() throws IOException { this( XmlRpcKit.getInstance().getClientFactory(), XmlRpcKit.getInstance().getServerFactory(), SampUtils.urlToFile( StandardClientProfile.getLockUrl() ), createSecret() ); } public String getProfileName() { return "Standard"; } public MessageRestriction getMessageRestriction() { return null; } public synchronized void start( ClientProfile profile ) throws IOException { // Check state. if ( isRunning() ) { logger_.info( "Profile already started" ); return; } // Check for running hub. If there is a running hub, bail out. // If there is a lockfile but apparently no running hub, // continue preparations to start; the hub reference by the lockfile // may either be moribund or in the process of starting up. // To deal with the latter case, make all our preparations to give // it more time to get going before preempting it. if ( lockfile_ != null && lockfile_.exists() ) { if ( isHubAlive( xClientFactory_, lockfile_ ) ) { throw new IOException( "A hub is already running" ); } } // Start up server. try { server_ = xServerFactory_.getServer(); } catch ( IOException e ) { throw e; } catch ( Exception e ) { throw (IOException) new IOException( "Can't start XML-RPC server" ) .initCause( e ); } hubHandler_ = new HubXmlRpcHandler( xClientFactory_, profile, secret_, new KeyGenerator( "k:", 16, random_ ) ); server_.addHandler( hubHandler_ ); // Prepare lockfile information. lockInfo_ = new LockInfo( secret_, server_.getEndpoint().toString() ); lockInfo_.put( "hub.impl", profile.getClass().getName() ); lockInfo_.put( "profile.impl", this.getClass().getName() ); lockInfo_.put( "profile.start.date", new Date().toString() ); // Write lockfile information to file if required. if ( lockfile_ != null ) { // If the lockfile already exists, wait a little while in case // a hub is in the process of waking up. If it's still not // present, overwrite the lockfile with a warning. if ( ! lockfile_.createNewFile() ) { try { Thread.sleep( 500 ); } catch ( InterruptedException e ) { } if ( isHubAlive( xClientFactory_, lockfile_ ) ) { server_.removeHandler( hubHandler_ ); hubHandler_ = null; throw new IOException( "A hub is already running" ); } else { logger_.warning( "Overwriting " + lockfile_ + " lockfile " + "for apparently dead hub" ); } } FileOutputStream out = new FileOutputStream( lockfile_ ); try { writeLockInfo( lockInfo_, out ); logger_.info( "Wrote new lockfile " + lockfile_ ); try { LockWriter.setLockPermissions( lockfile_ ); logger_.info( "Lockfile permissions set to " + "user access only" ); } catch ( IOException e ) { logger_.log( Level.WARNING, "Failed attempt to change " + lockfile_ + " permissions to user access only" + " - possible security implications", e ); } } finally { try { out.close(); } catch ( IOException e ) { logger_.log( Level.WARNING, "Error closing lockfile?", e ); } } } // If the lockfile is not the default one, write a message through // the logging system. URL lockfileUrl = lockfile_ == null ? publishLockfile() : SampUtils.fileToUrl( lockfile_ ); boolean isDflt = StandardClientProfile.getDefaultLockUrl().toString() .equals( lockfileUrl.toString() ); String hubassign = DefaultClientProfile.HUBLOC_ENV + "=" + StandardClientProfile.STDPROFILE_HUB_PREFIX + lockfileUrl; logger_.log( isDflt ? Level.INFO : Level.WARNING, hubassign ); } public synchronized boolean isRunning() { return hubHandler_ != null; } public synchronized void stop() { if ( ! isRunning() ) { logger_.info( "Profile already stopped" ); return; } // Delete the lockfile if it exists and if it is the one originally // written by this runner. if ( lockInfo_ != null && lockfile_ != null ) { if ( lockfile_.exists() ) { try { LockInfo lockInfo = readLockFile( lockfile_ ); if ( lockInfo_.getSecret() .equals( lockInfo.getSecret() ) ) { assert lockInfo.equals( lockInfo_ ); boolean deleted = lockfile_.delete(); logger_.info( "Lockfile " + lockfile_ + " " + ( deleted ? "deleted" : "deletion attempt failed" ) ); } else { logger_.warning( "Lockfile " + lockfile_ + " has been " + " overwritten - not deleting" ); } } catch ( Throwable e ) { logger_.log( Level.WARNING, "Failed to delete lockfile " + lockfile_, e ); } } else { logger_.warning( "Lockfile " + lockfile_ + " has disappeared" ); } } // Withdraw service of the lockfile, if one has been published. if ( lockUrl_ != null ) { try { UtilServer.getInstance().getResourceHandler() .removeResource( lockUrl_ ); } catch ( IOException e ) { logger_.warning( "Failed to withdraw lockfile URL" ); } lockUrl_ = null; } // Remove the hub XML-RPC handler from the server. if ( hubHandler_ != null && server_ != null ) { server_.removeHandler( hubHandler_ ); } server_ = null; hubHandler_ = null; lockInfo_ = null; } /** * Returns the lockfile information associated with this object. * Only present when running. * * @return lock info */ public LockInfo getLockInfo() { return lockInfo_; } /** * Returns an HTTP URL at which the lockfile for this hub can be found. * The first call to this method causes the lockfile to be published * in this way; subsequent calls return the same value. * *

      Use this with care; publishing your lockfile means that other people * can connect to your hub and potentially do disruptive things. * * @return lockfile information URL */ public URL publishLockfile() throws IOException { if ( lockUrl_ == null ) { ByteArrayOutputStream infoStrm = new ByteArrayOutputStream(); writeLockInfo( lockInfo_, infoStrm ); infoStrm.close(); final byte[] infoBuf = infoStrm.toByteArray(); URL url = UtilServer.getInstance().getResourceHandler() .addResource( "samplock", new ServerResource() { public long getContentLength() { return infoBuf.length; } public String getContentType() { return "text/plain"; } public void writeBody( OutputStream out ) throws IOException { out.write( infoBuf ); } } ); // Attempt to replace whatever host name is used by the FQDN, // for maximal usefulness to off-host clients. try { url = new URL( url.getProtocol(), InetAddress.getLocalHost() .getCanonicalHostName(), url.getPort(), url.getFile() ); } catch ( IOException e ) { } lockUrl_ = url; } return lockUrl_; } /** * Returns a string suitable for use as a Standard Profile Secret. * * @return new secret */ public static synchronized String createSecret() { return Long.toHexString( random_.nextLong() ); } /** * Attempts to determine whether a given lockfile corresponds to a hub * which is still alive. * * @param xClientFactory XML-RPC client factory implementation * @param lockfile lockfile location * @return true if the hub described at lockfile appears * to be alive and well */ private static boolean isHubAlive( SampXmlRpcClientFactory xClientFactory, File lockfile ) { LockInfo info; try { info = readLockFile( lockfile ); } catch ( Exception e ) { logger_.log( Level.WARNING, "Failed to read lockfile", e ); return false; } if ( info == null ) { return false; } URL xurl = info.getXmlrpcUrl(); if ( xurl != null ) { try { xClientFactory.createClient( xurl ) .callAndWait( "samp.hub.ping", new ArrayList() ); return true; } catch ( Exception e ) { logger_.log( Level.WARNING, "Hub ping method failed", e ); return false; } } else { logger_.warning( "No XMLRPC URL in lockfile" ); return false; } } /** * Reads lockinfo from a file. * * @param lockFile file * @return info from file */ private static LockInfo readLockFile( File lockFile ) throws IOException { return LockInfo.readLockFile( new FileInputStream( lockFile ) ); } /** * Writes lockfile information to a given output stream. * The stream is not closed. * * @param info lock info to write * @param out destination stream */ private static void writeLockInfo( LockInfo info, OutputStream out ) throws IOException { LockWriter writer = new LockWriter( out ); writer.writeComment( "SAMP Standard Profile lockfile written " + new Date() ); writer.writeComment( "Note contact URL hostname may be " + "configured using " + SampUtils.LOCALHOST_PROP + " property" ); writer.writeAssignments( info ); out.flush(); } } jsamp/src/java/org/astrogrid/samp/xmlrpc/SampXmlRpcClientFactory.java0000664000175000017500000000104112730747754025626 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.io.IOException; import java.net.URL; /** * Defines a factory which can create clients for communication with * XML-RPC servers. * * @author Mark Taylor * @since 16 Sep 2008 */ public interface SampXmlRpcClientFactory { /** * Returns an XML-RPC client implementation. * * @param endpoint XML-RPC server endpoint * @return client which can communicate with the given endpoint */ SampXmlRpcClient createClient( URL endpoint ) throws IOException; } jsamp/src/java/org/astrogrid/samp/xmlrpc/internal/0000775000175000017500000000000012730747754022066 5ustar sladensladenjsamp/src/java/org/astrogrid/samp/xmlrpc/internal/RpcLoggingInternalClient.java0000664000175000017500000000361012730747754027620 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc.internal; import java.io.IOException; import java.io.InputStream; import java.io.PrintStream; import java.net.URL; import java.util.List; import java.util.Map; import org.astrogrid.samp.SampUtils; /** * InternalClient subclass which additionally logs all XML-RPC calls/responses * to an output stream. * * @author Mark Taylor * @since 2 Dec 2008 */ public class RpcLoggingInternalClient extends InternalClient { private final PrintStream out_; /** * Constructor. * * @param endpoint endpoint * @param out logging output stream */ public RpcLoggingInternalClient( URL endpoint, PrintStream out ) { super( endpoint ); out_ = out; } protected byte[] serializeCall( String method, List paramList ) throws IOException { String paramString = SampUtils.formatObject( paramList, 2 ); synchronized ( out_ ) { out_.println( "CLIENT OUT:" ); out_.println( method ); out_.println( paramString ); out_.println(); } return super.serializeCall( method, paramList ); } protected Object deserializeResponse( InputStream in ) throws IOException { Object response = super.deserializeResponse( in ); if ( response == null || response instanceof String && ((String) response).length() == 0 || response instanceof Map && ((Map) response).isEmpty() || response instanceof List && ((List) response).isEmpty() ) { // treat as no response } else { String responseString = SampUtils.formatObject( response, 2 ); synchronized ( out_ ) { out_.println( "CLIENT IN:" ); out_.println( responseString ); out_.println(); } } return response; } } jsamp/src/java/org/astrogrid/samp/xmlrpc/internal/RpcLoggingInternalClientFactory.java0000664000175000017500000000115012730747754031145 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc.internal; import java.io.IOException; import java.net.URL; import org.astrogrid.samp.xmlrpc.SampXmlRpcClient; import org.astrogrid.samp.xmlrpc.SampXmlRpcClientFactory; /** * Freestanding ClientFactory implementation which logs all XML-RPC * calls/responses to standard output. * * @author Mark Taylor * @since 2 Dec 2008 */ public class RpcLoggingInternalClientFactory implements SampXmlRpcClientFactory { public SampXmlRpcClient createClient( URL endpoint ) throws IOException { return new RpcLoggingInternalClient( endpoint, System.out ); } } jsamp/src/java/org/astrogrid/samp/xmlrpc/internal/InternalServer.java0000664000175000017500000003210412730747754025674 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc.internal; import java.io.BufferedOutputStream; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.net.ServerSocket; import java.net.URL; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.httpd.HttpServer; import org.astrogrid.samp.httpd.UtilServer; import org.astrogrid.samp.xmlrpc.SampXmlRpcHandler; import org.astrogrid.samp.xmlrpc.SampXmlRpcServer; import org.w3c.dom.Document; import org.w3c.dom.Element; /** * SampXmlRpcServer implementation without external dependencies. * The reqInfo argument passed to the * {@link SampXmlRpcHandler#handleCall handleCall} method of registered * SampXmlRpcHandlers is the associated * {@link org.astrogrid.samp.httpd.HttpServer.Request}. * * @author Mark Taylor * @since 27 Aug 2008 */ public class InternalServer implements SampXmlRpcServer { private final HttpServer server_; private final URL endpoint_; private final List handlerList_; private final HttpServer.Handler serverHandler_; private static final HttpServer.Response GET_RESPONSE = createInfoResponse( true ); private static final HttpServer.Response HEAD_RESPONSE = createInfoResponse( false ); private static final Logger logger_ = Logger.getLogger( InternalServer.class.getName() ); /** * Constructor based on a given HTTP server. * It is the caller's responsibility to configure and start the HttpServer. * * @param httpServer server for processing HTTP requests * @param path path part of server endpoint (starts with "/"); */ public InternalServer( HttpServer httpServer, final String path ) throws IOException { server_ = httpServer; endpoint_ = new URL( server_.getBaseUrl(), path ); handlerList_ = Collections.synchronizedList( new ArrayList() ); serverHandler_ = new HttpServer.Handler() { public HttpServer.Response serveRequest( HttpServer.Request req ) { if ( req.getUrl().equals( path ) ) { String method = req.getMethod(); if ( "POST".equals( method ) ) { return getXmlRpcResponse( req ); } else if ( "GET".equals( method ) ) { return GET_RESPONSE; } else if ( "HEAD".equals( method ) ) { return HEAD_RESPONSE; } else { return HttpServer .create405Response( new String[] { "POST", "GET", "HEAD", } ); } } else { return null; } } }; } /** * Constructs a server running with default characteristics. * Currently, the default server * {@link org.astrogrid.samp.httpd.UtilServer#getInstance} is used. */ public InternalServer() throws IOException { this( UtilServer.getInstance().getServer(), UtilServer.getInstance().getBasePath( "/xmlrpc" ) ); } public URL getEndpoint() { return endpoint_; } /** * Returns the HTTP server hosting this XML-RPC server. * * @return http server */ public HttpServer getHttpServer() { return server_; } public void addHandler( SampXmlRpcHandler handler ) { synchronized ( handlerList_ ) { if ( handlerList_.isEmpty() ) { server_.addHandler( serverHandler_ ); } handlerList_.add( handler ); } } public void removeHandler( SampXmlRpcHandler handler ) { synchronized ( handlerList_ ) { handlerList_.remove( handler ); if ( handlerList_.isEmpty() ) { server_.removeHandler( serverHandler_ ); } } } /** * Returns the HTTP response object given an incoming XML-RPC POST request. * Any error should be handled by returning a fault-type methodResponse * element rather than by throwing an exception. * * @param request POSTed HTTP request * @return XML-RPC response (possibly fault) */ protected HttpServer.Response getXmlRpcResponse( HttpServer.Request request ) { byte[] rbuf; try { rbuf = getResultBytes( getXmlRpcResult( request ) ); } catch ( Throwable e ) { boolean isSerious = e instanceof Error; logger_.log( isSerious ? Level.WARNING : Level.INFO, "XML-RPC fault return", e ); try { rbuf = getFaultBytes( e ); } catch ( IOException e2 ) { return HttpServer.createErrorResponse( 500, "Server error", e2 ); } } final byte[] replyBuf = rbuf; Map hdrMap = new LinkedHashMap(); hdrMap.put( "Content-Length", Integer.toString( replyBuf.length ) ); hdrMap.put( "Content-Type", "text/xml" ); return new HttpServer.Response( 200, "OK", hdrMap ) { public void writeBody( OutputStream out ) throws IOException { out.write( replyBuf ); } }; } /** * Returns the SAMP-friendly (string, list and map only) object representing * the reply to an XML-RPC request given by a request. * * @param request POSTed HTTP request * @return SAMP-friendly object * @throws Exception in case of error (will become XML-RPC fault) */ private Object getXmlRpcResult( HttpServer.Request request ) throws Exception { byte[] body = request.getBody(); // Parse body as XML document. if ( body == null || body.length == 0 ) { throw new XmlRpcFormatException( "No body in POSTed request" ); } Document doc = XmlUtils.createDocumentBuilder() .parse( new ByteArrayInputStream( body ) ); // Extract basic XML-RPC information from DOM. Element call = XmlUtils.getChild( doc, "methodCall" ); String methodName = null; Element paramsEl = null; Element[] methodChildren = XmlUtils.getChildren( call ); for ( int i = 0; i < methodChildren.length; i++ ) { Element el = methodChildren[ i ]; String tagName = el.getTagName(); if ( tagName.equals( "methodName" ) ) { methodName = XmlUtils.getTextContent( el ); } else if ( tagName.equals( "params" ) ) { paramsEl = el; } } if ( methodName == null ) { throw new XmlRpcFormatException( "No methodName element" ); } // Find one of the registered handlers to handle this request. SampXmlRpcHandler handler = null; SampXmlRpcHandler[] handlers = (SampXmlRpcHandler[]) handlerList_.toArray( new SampXmlRpcHandler[ 0 ] ); for ( int ih = 0; ih < handlers.length && handler == null; ih++ ) { SampXmlRpcHandler h = handlers[ ih ]; if ( h.canHandleCall( methodName ) ) { handler = h; } } if ( handler == null ) { throw new XmlRpcFormatException( "Unknown XML-RPC method " + methodName ); } // Extract parameter values from DOM. Element[] paramEls = paramsEl == null ? new Element[ 0 ] : XmlUtils.getChildren( paramsEl ); int np = paramEls.length; List paramList = new ArrayList( np ); for ( int i = 0; i < np; i++ ) { Element paramEl = paramEls[ i ]; if ( ! "param".equals( paramEl.getTagName() ) ) { throw new XmlRpcFormatException( "Non-param child of params" ); } else { Element valueEl = XmlUtils.getChild( paramEl, "value" ); paramList.add( XmlUtils.parseSampValue( valueEl ) ); } } // Pass the call to the handler and return the result. return handleCall( handler, methodName, paramList, request ); } /** * Actually passes the XML-RPC method name and parameter list to one * of the registered servers for processing. * * @param handler handler which has declared it can handle the * named method * @param methodName XML-RPC method name * @param paramList list of parameters to XML-RPC call * @param request HTTP request from which this call originated */ protected Object handleCall( SampXmlRpcHandler handler, String methodName, List paramList, HttpServer.Request request ) throws Exception { return handler.handleCall( methodName, paramList, request ); } /** * Turns a SAMP-friendly (string, list, map only) object into an array * of bytes giving an XML-RPC methodResponse document. * * @param result SAMP-friendly object * @return XML methodResponse document as byte array */ private byte[] getResultBytes( Object result ) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); BufferedOutputStream bout = new BufferedOutputStream( out ); XmlWriter xout = new XmlWriter( bout, 2 ); xout.start( "methodResponse" ); xout.start( "params" ); xout.start( "param" ); xout.sampValue( result ); xout.end( "param" ); xout.end( "params" ); xout.end( "methodResponse" ); xout.close(); return out.toByteArray(); } /** * Turns an exception into an array of bytes giving an XML-RPC * methodResponse (fault) document. * * @param error throwable * @return XML methodResponse document as byte array */ private byte[] getFaultBytes( Throwable error ) throws IOException { int faultCode = 1; String faultString = error.toString(); // Write the method response element. We can't use the XmlWriter // sampValue method to do the grunt-work here since the faultCode // contains an , which is not a known SAMP type. ByteArrayOutputStream out = new ByteArrayOutputStream(); BufferedOutputStream bout = new BufferedOutputStream( out ); XmlWriter xout = new XmlWriter( bout, 2 ); xout.start( "methodResponse" ); xout.start( "fault" ); xout.start( "value" ); xout.start( "struct" ); xout.start( "member" ); xout.inline( "name", "faultCode" ); xout.start( "value" ); xout.inline( "int", Integer.toString( faultCode ) ); xout.end( "value" ); xout.end( "member" ); xout.start( "member" ); xout.inline( "name", "faultString" ); xout.inline( "value", faultString ); xout.end( "member" ); xout.end( "struct" ); xout.end( "value" ); xout.end( "fault" ); xout.end( "methodResponse" ); xout.close(); return out.toByteArray(); } /** * Returns a simple response suitable for GET/HEAD at the XML-RPC * server's endpoint. * * @param withData true for text (GET), false for no text (HEAD) * @return HTTP response */ private static HttpServer.Response createInfoResponse( final boolean withData ) { String text = new StringBuffer() .append( "\n" ) .append( "XML-RPC\n" ) .append( "\n" ) .append( "

      XML-RPC Server

      \n" ) .append( "

      This is an " ) .append( "XML-RPC server.\n" ) .append( "

      \n" ) .append( "

      Try POSTing.

      \n" ) .append( "\n" ) .append( "\n" ) .toString(); byte[] buf1; try { buf1 = text.getBytes( "utf-8" ); } catch ( UnsupportedEncodingException e ) { assert false : "no UTF-8??"; buf1 = new byte[ 0 ]; } final byte[] buf = buf1; Map hdrMap = new LinkedHashMap(); hdrMap.put( "Content-Type", "text/html" ); hdrMap.put( "Content-Length", Integer.toString( buf.length ) ); return new HttpServer.Response( 200, "OK", hdrMap ) { public void writeBody( OutputStream out ) throws IOException { if ( withData ) { out.write( buf ); } } }; } } jsamp/src/java/org/astrogrid/samp/xmlrpc/internal/RpcLoggingInternalServer.java0000664000175000017500000000524112730747754027652 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc.internal; import java.io.IOException; import java.io.PrintStream; import java.util.List; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.httpd.HttpServer; import org.astrogrid.samp.xmlrpc.SampXmlRpcHandler; /** * InternalServer subclass which additionally logs all XML-RPC calls/responses * to an output stream. * * @author Mark Taylor * @since 2 Dec 2008 */ public class RpcLoggingInternalServer extends InternalServer { private final PrintStream out_; /** * Constructor based on a given HTTP server. * It is the caller's responsibility to configure and start the HttpServer. * * @param server server for processing HTTP requests * @param path path part of server endpoint (starts with "/"); * @param out output stream for logging */ public RpcLoggingInternalServer( HttpServer server, String path, PrintStream out ) throws IOException { super( server, path ); out_ = out; } /** * Constructs a server running with default characteristics * on any free port. The server is started as a daemon thread. * * @param out output stream for logging */ public RpcLoggingInternalServer( PrintStream out ) throws IOException { super(); out_ = out; } protected Object handleCall( SampXmlRpcHandler handler, String methodName, List paramList, HttpServer.Request request ) throws Exception { String paramString = SampUtils.formatObject( paramList, 2 ); synchronized ( out_ ) { out_.println( "SERVER IN:" ); out_.println( methodName ); out_.println( paramString ); out_.println(); } final Object result; try { result = super.handleCall( handler, methodName, paramList, request ); } catch ( Throwable e ) { synchronized ( out_ ) { out_.println( "SERVER ERROR:" ); out_.println( methodName ); e.printStackTrace( out_ ); out_.println(); } if ( e instanceof Error ) { throw (Error) e; } else { throw (Exception) e; } } String resultString = SampUtils.formatObject( result, 2 ); synchronized ( out_ ) { out_.println( "SERVER OUT:" ); out_.println( methodName ); out_.println( resultString ); out_.println(); } return result; } } jsamp/src/java/org/astrogrid/samp/xmlrpc/internal/RpcLoggingInternalServerFactory.java0000664000175000017500000000127712730747754031207 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc.internal; import java.io.IOException; import org.astrogrid.samp.xmlrpc.SampXmlRpcServer; import org.astrogrid.samp.xmlrpc.SampXmlRpcServerFactory; /** * Freestanding ServerFactory implementation which logs all XML-RPC * calls/responses to standard output. * * @author Mark Taylor * @since 2 Dec 2008 */ public class RpcLoggingInternalServerFactory implements SampXmlRpcServerFactory { private RpcLoggingInternalServer server_; public synchronized SampXmlRpcServer getServer() throws IOException { if ( server_ == null ) { server_ = new RpcLoggingInternalServer( System.out ); } return server_; } } jsamp/src/java/org/astrogrid/samp/xmlrpc/internal/XmlLoggingInternalClient.java0000664000175000017500000000333612730747754027641 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc.internal; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.PrintStream; import java.util.List; import java.net.URL; /** * InternalClient subclass which additionally logs all XML-RPC calls/responses * to an output stream. * * @author Mark Taylor * @since 2 Dec 2008 */ public class XmlLoggingInternalClient extends InternalClient { private final PrintStream out_; /** * Constructor. * * @param endpoint endpoint * @param out output stream for logging */ public XmlLoggingInternalClient( URL endpoint, PrintStream out ) { super( endpoint ); out_ = out; } protected byte[] serializeCall( String method, List paramList ) throws IOException { byte[] buf = super.serializeCall( method, paramList ); synchronized ( out_ ) { out_.println( "CLIENT OUT:" ); out_.write( buf ); out_.println(); } return buf; } protected Object deserializeResponse( InputStream in ) throws IOException { ByteArrayOutputStream bout = new ByteArrayOutputStream(); byte[] buf = new byte[ 1024 ]; for ( int nb; ( nb = in.read( buf ) ) >= 0; ) { bout.write( buf, 0, nb ); } byte[] obuf = bout.toByteArray(); synchronized ( out_ ) { out_.println( "CLIENT IN:" ); out_.write( obuf ); out_.println(); } InputStream copyIn = new ByteArrayInputStream( bout.toByteArray() ); return super.deserializeResponse( copyIn ); } } jsamp/src/java/org/astrogrid/samp/xmlrpc/internal/XmlLoggingInternalServer.java0000664000175000017500000000470612730747754027673 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc.internal; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.io.PrintStream; import org.astrogrid.samp.httpd.HttpServer; /** * Freestanding InternalServer implementation which logs all incoming * and outgoing HTTP data. * * @author Mark Taylor * @since 2 Dec 2008 */ public class XmlLoggingInternalServer extends InternalServer { private final PrintStream out_; /** * Constructor based on a given HTTP server. * It is the caller's responsibility to configure and start the HttpServer. * * @param server server for processing HTTP requests * @param path path part of server endpoint (starts with "/"); * @param out output stream for loggging */ public XmlLoggingInternalServer( HttpServer server, String path, PrintStream out ) throws IOException { super( server, path ); out_ = out; } /** * Constructs a server running with default characteristics * on any free port. The server is started as a daemon thread. * * @param out output stream for loggging */ public XmlLoggingInternalServer( PrintStream out ) throws IOException { super(); out_ = out; } protected HttpServer.Response getXmlRpcResponse( HttpServer.Request request ) { synchronized ( out_ ) { out_.println( "SERVER IN:" ); try { out_.write( request.getBody() ); } catch ( IOException e ) { } out_.println(); } return new LoggingResponse( super.getXmlRpcResponse( request ) ); } private class LoggingResponse extends HttpServer.Response { final HttpServer.Response base_; LoggingResponse( HttpServer.Response base ) { super( base.getStatusCode(), base.getStatusPhrase(), base.getHeaderMap() ); base_ = base; } public void writeBody( OutputStream out ) throws IOException { ByteArrayOutputStream bout = new ByteArrayOutputStream(); base_.writeBody( bout ); byte[] bbuf = bout.toByteArray(); synchronized ( out_ ) { out_.println( "SERVER OUT:" ); out_.write( bbuf ); out_.println(); } out.write( bbuf ); } } } jsamp/src/java/org/astrogrid/samp/xmlrpc/internal/XmlLoggingInternalClientFactory.java0000664000175000017500000000113712730747754031166 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc.internal; import java.io.IOException; import java.net.URL; import org.astrogrid.samp.xmlrpc.SampXmlRpcClient; import org.astrogrid.samp.xmlrpc.SampXmlRpcClientFactory; /** * Freestanding ClientFactory implementation which logs all incoming * and outgoing HTTP data. * * @author Mark Taylor * @since 2 Dec 2008 */ public class XmlLoggingInternalClientFactory implements SampXmlRpcClientFactory { public SampXmlRpcClient createClient( URL endpoint ) throws IOException { return new XmlLoggingInternalClient( endpoint, System.out ); } } jsamp/src/java/org/astrogrid/samp/xmlrpc/internal/XmlWriter.java0000664000175000017500000001157612730747754024700 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc.internal; import java.io.IOException; import java.io.BufferedWriter; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Writer; import java.util.Iterator; import java.util.List; import java.util.Map; /** * Utility class for writing XML. * * @author Mark Taylor * @since 26 Aug 2008 */ class XmlWriter { private final Writer out_; private final int indent_; private int iLevel_; private static final String ENCODING = "UTF-8"; /** * Constructor. * * @param out destination stream * @param indent number of spaces to indent each element level */ public XmlWriter( OutputStream out, int indent ) throws IOException { out_ = new BufferedWriter( new OutputStreamWriter( out, ENCODING ) ); indent_ = indent; literal( "" ); newline(); } /** * Start an element. * * @param element tag name */ public void start( String element ) throws IOException { pad( iLevel_++ ); out_.write( '<' ); out_.write( element ); out_.write( '>' ); newline(); } /** * End an element. * * @param element tag name */ public void end( String element ) throws IOException { pad( --iLevel_ ); out_.write( "' ); newline(); } /** * Write an element and its text content. * * @param element tag name * @param content element text content */ public void inline( String element, String content ) throws IOException { pad( iLevel_ ); out_.write( '<' ); out_.write( element ); out_.write( '>' ); text( content ); out_.write( "' ); newline(); } /** * Writes text. Any escaping required for XML output will be * taken care of. * * @param txt text to output */ public void text( String txt ) throws IOException { int leng = txt.length(); for ( int i = 0; i < leng; i++ ) { char c = txt.charAt( i ); switch ( c ) { case '&': out_.write( "&" ); break; case '<': out_.write( "<" ); break; case '>': out_.write( ">" ); break; default: out_.write( c ); } } } /** * Writes text with no escaping of XML special characters etc. * * @param txt raw text to output */ public void literal( String txt ) throws IOException { out_.write( txt ); } /** * Writes a new line character. */ public void newline() throws IOException { out_.write( '\n' ); } /** * Writes a SAMP-friendly object in XML-RPC form. * * @param value object to serialize; must be a string, list or map */ public void sampValue( Object value ) throws IOException { if ( value instanceof String ) { inline( "value", (String) value ); } else if ( value instanceof List ) { start( "value" ); start( "array" ); start( "data" ); for ( Iterator it = ((List) value).iterator(); it.hasNext(); ) { sampValue( it.next() ); } end( "data" ); end( "array" ); end( "value" ); } else if ( value instanceof Map ) { start( "value" ); start( "struct" ); for ( Iterator it = ((Map) value).entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); start( "member" ); inline( "name", entry.getKey().toString() ); sampValue( entry.getValue() ); end( "member" ); } end( "struct" ); end( "value" ); } else if ( value == null ) { throw new XmlRpcFormatException( "Null value not permitted" ); } else { throw new XmlRpcFormatException( "Unsupported object type " + value.getClass().getName() ); } } /** * Closes the stream. */ public void close() throws IOException { out_.close(); } /** * Outputs start-of-line padding for a given level of indentation. * * @param level level of XML element ancestry */ private void pad( int level ) throws IOException { int npad = level * indent_; for ( int i = 0; i < npad; i++ ) { out_.write( ' ' ); } } } jsamp/src/java/org/astrogrid/samp/xmlrpc/internal/InternalServerFactory.java0000664000175000017500000000112112730747754027217 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc.internal; import java.io.IOException; import org.astrogrid.samp.xmlrpc.SampXmlRpcServer; import org.astrogrid.samp.xmlrpc.SampXmlRpcServerFactory; /** * Freestanding SampXmlRpcServerFactory implementation. * A new server object is returned each time, but this does not * mean a new port opened each time. * * @author Mark Taylor * @since 26 Aug 2008 */ public class InternalServerFactory implements SampXmlRpcServerFactory { public synchronized SampXmlRpcServer getServer() throws IOException { return new InternalServer(); } } jsamp/src/java/org/astrogrid/samp/xmlrpc/internal/InternalClient.java0000664000175000017500000001727312730747754025656 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc.internal; import java.io.BufferedInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.logging.Logger; import javax.xml.parsers.ParserConfigurationException; import org.w3c.dom.DOMException; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.xml.sax.SAXException; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.xmlrpc.SampXmlRpcClient; /** * XML-RPC client implementation suitable for use with SAMP. * This implementation is completely freestanding and requires no other * libraries. * * @author Mark Taylor * @since 26 Aug 2008 */ public class InternalClient implements SampXmlRpcClient { private final URL endpoint_; private final String userAgent_; private static final Logger logger_ = Logger.getLogger( InternalClient.class.getName() ); /** * Constructor. * * @param endpoint endpoint */ public InternalClient( URL endpoint ) { endpoint_ = endpoint; userAgent_ = "JSAMP/" + SampUtils.getSoftwareVersion(); } public Object callAndWait( String method, List params ) throws IOException { HttpURLConnection connection = (HttpURLConnection) endpoint_.openConnection(); byte[] callBuf = serializeCall( method, params ); connection.setDoOutput( true ); connection.setDoInput( true ); connection.setRequestMethod( "POST" ); connection.setRequestProperty( "Content-Type", "text/xml" ); connection.setRequestProperty( "Content-Length", Integer.toString( callBuf.length ) ); connection.setRequestProperty( "User-Agent", userAgent_ ); connection.connect(); OutputStream out = connection.getOutputStream(); out.write( callBuf ); out.flush(); out.close(); int responseCode = connection.getResponseCode(); if ( responseCode != HttpURLConnection.HTTP_OK ) { throw new IOException( responseCode + " " + connection.getResponseMessage() ); } InputStream in = new BufferedInputStream( connection.getInputStream() ); Object result = deserializeResponse( in ); connection.disconnect(); return result; } // NOTE: if this method is invoked from a shutdownHook thread, // the call may not complete because it is completed from a new thread. public void callAndForget( String method, List params ) throws IOException { final HttpURLConnection connection = (HttpURLConnection) endpoint_.openConnection(); byte[] callBuf = serializeCall( method, params ); connection.setDoOutput( true ); connection.setDoInput( true ); connection.setRequestMethod( "POST" ); connection.setRequestProperty( "Content-Type", "text/xml" ); connection.setRequestProperty( "Content-Length", Integer.toString( callBuf.length ) ); connection.setRequestProperty( "User-Agent", userAgent_ ); connection.connect(); OutputStream out = connection.getOutputStream(); out.write( callBuf ); out.flush(); out.close(); // It would be nice to just not read the input stream at all. // However, connection.setDoInput(false) and doing no reads causes // trouble - probably the call doesn't complete at the other end or // something. So read it to the end asynchronously. new Thread() { public void run() { try { InputStream in = new BufferedInputStream( connection.getInputStream() ); while ( in.read() >= 0 ) {} int responseCode = connection.getResponseCode(); if ( responseCode != HttpURLConnection.HTTP_OK ) { logger_.warning( responseCode + " " + connection.getResponseMessage() ); } } catch ( IOException e ) { } finally { connection.disconnect(); } } }.start(); } /** * Generates the XML methodCall document corresponding * to an XML-RPC method call. * * @param method methodName string * @param paramList list of XML-RPC parameters * @return XML document as byte array */ protected byte[] serializeCall( String method, List paramList ) throws IOException { ByteArrayOutputStream bos = new ByteArrayOutputStream(); XmlWriter xout = new XmlWriter( bos, 2 ); xout.start( "methodCall" ); xout.inline( "methodName", method ); if ( ! paramList.isEmpty() ) { xout.start( "params" ); for ( Iterator it = paramList.iterator(); it.hasNext(); ) { xout.start( "param" ); xout.sampValue( it.next() ); xout.end( "param" ); } xout.end( "params" ); } xout.end( "methodCall" ); xout.close(); return bos.toByteArray(); } /** * Deserializes an XML-RPC methodResponse document to a * Java object. * * @param in input stream containing response document */ protected Object deserializeResponse( InputStream in ) throws IOException { try { Document doc = XmlUtils.createDocumentBuilder().parse( in ); Element top = XmlUtils.getChild( XmlUtils.getChild( doc, "methodResponse" ) ); String topName = top.getTagName(); if ( "fault".equals( topName ) ) { Element value = XmlUtils.getChild( top, "value" ); XmlUtils.getChild( value, "struct" ); Map faultMap = (Map) XmlUtils.parseSampValue( value ); Object fcode = faultMap.get( "faultCode" ); Object fmsg = faultMap.get( "faultString" ); int code = fcode instanceof Integer ? ((Integer) fcode).intValue() : -9999; final String msg = String.valueOf( fmsg ); throw new XmlRpcFault( code, msg ); } else if ( "params".equals( topName ) ) { Element value = XmlUtils.getChild( XmlUtils.getChild( top, "param" ), "value" ); return XmlUtils.parseSampValue( value ); } else { throw new XmlRpcFormatException( "Not or ?" ); } } catch ( ParserConfigurationException e ) { throw (IOException) new IOException( "Trouble with XML parsing" ) .initCause( e ); } catch ( SAXException e ) { throw (IOException) new IOException( "Trouble with XML parsing" ) .initCause( e ); } catch ( DOMException e ) { throw (IOException) new IOException( "Trouble with XML parsing" ) .initCause( e ); } } /** * IOException representing an incoming XML-RPC fault. */ private static class XmlRpcFault extends IOException { public XmlRpcFault( int code, String msg ) { super( "XML-RPC Fault (" + code + ": " + msg + ")" ); } } } jsamp/src/java/org/astrogrid/samp/xmlrpc/internal/XmlLoggingInternalServerFactory.java0000664000175000017500000000126412730747754031217 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc.internal; import java.io.IOException; import org.astrogrid.samp.xmlrpc.SampXmlRpcServer; import org.astrogrid.samp.xmlrpc.SampXmlRpcServerFactory; /** * Freestanding ServerFactory implementation which logs all incoming * and outgoing HTTP data. * * @author Mark Taylor * @since 2 Dec 2008 */ public class XmlLoggingInternalServerFactory implements SampXmlRpcServerFactory { private XmlLoggingInternalServer server_; public synchronized SampXmlRpcServer getServer() throws IOException { if ( server_ == null ) { server_ = new XmlLoggingInternalServer( System.out ); } return server_; } } jsamp/src/java/org/astrogrid/samp/xmlrpc/internal/XmlUtils.java0000664000175000017500000002111612730747754024513 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc.internal; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.logging.Logger; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.w3c.dom.Text; /** * Utilities for XML manipulations required by SAMP/XML-RPC. * * @author Mark Taylor * @since 26 Aug 2008 */ public class XmlUtils { private static Logger logger_ = Logger.getLogger( XmlUtils.class.getName() ); private static DocumentBuilderFactory dbFact_; /** * Private constructor prevents instantiation. */ private XmlUtils() { } /** * Returns an array of all the Element children of a DOM node. * * @param parent parent node * @return children array */ public static Element[] getChildren( Node parent ) { NodeList nodeList = parent.getChildNodes(); int nnode = nodeList.getLength(); List elList = new ArrayList( nnode ); for ( int i = 0; i < nnode; i++ ) { Node node = nodeList.item( i ); if ( node instanceof Element ) { elList.add( (Element) node ); } } return (Element[]) elList.toArray( new Element[ 0 ] ); } /** * Returns the single element child of a DOM node. * * @param parent parent node * @return sole child element * @throws XmlRpcFormatException if there is not exactly one child * per element */ public static Element getChild( Node parent ) throws XmlRpcFormatException { Element[] els = getChildren( parent ); if ( els.length == 1 ) { return els[ 0 ]; } else if ( els.length == 0 ) { throw new XmlRpcFormatException( "No child element of " + ((Element) parent).getTagName() ); } else { throw new XmlRpcFormatException( "Multiple children of " + ((Element) parent).getTagName() ); } } /** * Returns the single child element of a DOM node, which has a given * known name. * * @param parent parent node * @param tagName child node name * @return sole child element with name tagName * @throws XmlRpcFormatException if there is not exactly one child * element or if it does not have name tagName */ public static Element getChild( Node parent, String tagName ) throws XmlRpcFormatException { Element child = getChild( parent ); if ( ! tagName.equals( child.getTagName() ) ) { throw new XmlRpcFormatException( "Unexpected child of " + ((Element) parent).getTagName() + ": " + child.getTagName() + " is not " + tagName ); } return child; } /** * Returns the text content of an element as a string. * * @param el parent node * @return text content * @throws XmlRpcFormatException if content is not just text */ public static String getTextContent( Element el ) throws XmlRpcFormatException { StringBuffer sbuf = new StringBuffer(); for ( Node node = el.getFirstChild(); node != null; node = node.getNextSibling() ) { if ( node instanceof Text ) { sbuf.append( ((Text) node).getData() ); } else if ( node instanceof Element ) { throw new XmlRpcFormatException( "Unexpected node " + node + " in " + el.getTagName() + " content" ); } } return sbuf.toString(); } /** * Returns the content of a DOM element representing a value * element of an XML-RPC document. * Note that some content which would be legal in XML-RPC, but is not * legal in SAMP, may result in an exception. * * @param valueEl value element * @return SAMP-friendly object (string, list or map) */ public static Object parseSampValue( Element valueEl ) throws XmlRpcFormatException { if ( getChildren( valueEl ).length == 0 ) { return getTextContent( valueEl ); } Element el = getChild( valueEl ); String name = el.getTagName(); if ( "array".equals( name ) ) { Element[] valueEls = getChildren( getChild( el, "data" ) ); int nel = valueEls.length; List list = new ArrayList( nel ); for ( int i = 0; i < nel; i++ ) { list.add( parseSampValue( valueEls[ i ] ) ); } return list; } else if ( "struct".equals( name ) ) { Element[] memberEls = getChildren( el ); Map map = new HashMap(); for ( int i = 0; i < memberEls.length; i++ ) { Element member = memberEls[ i ]; if ( ! "member".equals( member.getTagName() ) ) { throw new XmlRpcFormatException( "Non- child of : " + member.getTagName() ); } Element[] memberChildren = getChildren( member ); String key = null; Object value = null; for ( int j = 0; j < memberChildren.length; j++ ) { Element memberChild = memberChildren[ j ]; String memberName = memberChild.getTagName(); if ( "name".equals( memberName ) ) { key = getTextContent( memberChild ); } else if ( "value".equals( memberName ) ) { value = parseSampValue( memberChild ); } } if ( key == null ) { throw new XmlRpcFormatException( " missing" + " in struct member" ); } if ( value == null ) { throw new XmlRpcFormatException( " missing" + " in struct member" ); } if ( map.containsKey( key ) ) { logger_.warning( "Re-used key " + key + " in map" ); } map.put( key, value ); } return map; } else if ( "string".equals( name ) ) { return getTextContent( el ); } else if ( "i4".equals( name ) || "int".equals( name ) ) { String text = getTextContent( el ); try { return Integer.valueOf( text ); } catch ( NumberFormatException e ) { throw new XmlRpcFormatException( "Bad int " + text ); } } else if ( "boolean".equals( name ) ) { String text = getTextContent( el ); if ( "0".equals( text ) ) { return Boolean.FALSE; } else if ( "1".equals( text ) ) { return Boolean.TRUE; } else { throw new XmlRpcFormatException( "Bad boolean " + text ); } } else if ( "double".equals( name ) ) { String text = getTextContent( el ); try { return Double.valueOf( text ); } catch ( NumberFormatException e ) { throw new XmlRpcFormatException( "Bad double " + text ); } } else if ( "dateTime.iso8601".equals( name ) || "base64".equals( name ) ) { throw new XmlRpcFormatException( name + " not used in SAMP" ); } else { throw new XmlRpcFormatException( "Unknown XML-RPC element " + "<" + name + ">" ); } } /** * Returns a new DocumentBuilder with default characteristics. * * @return new document builder instance */ static DocumentBuilder createDocumentBuilder() throws ParserConfigurationException { if ( dbFact_ == null ) { dbFact_ = DocumentBuilderFactory.newInstance(); } return dbFact_.newDocumentBuilder(); } } jsamp/src/java/org/astrogrid/samp/xmlrpc/internal/XmlRpcFormatException.java0000664000175000017500000000116412730747754027170 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc.internal; import java.io.IOException; /** * Exception thrown when an XML document which is intended for XML-RPC * processing has the wrong format, for instance violates the XML-RPC spec. * * @author Mark Taylor * @since 26 Aug 2008 */ class XmlRpcFormatException extends IOException { /** * No-arg constructor. */ public XmlRpcFormatException() { this( "Badly-formed XML-RPC request/response" ); } /** * Constructor. * * @param msg message */ public XmlRpcFormatException( String msg ) { super( msg ); } } jsamp/src/java/org/astrogrid/samp/xmlrpc/internal/InternalClientFactory.java0000664000175000017500000000110512730747754027171 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc.internal; import java.io.IOException; import java.net.URL; import org.astrogrid.samp.xmlrpc.SampXmlRpcClient; import org.astrogrid.samp.xmlrpc.SampXmlRpcClientFactory; /** * Freestanding SampXmlRpcClientFactory implementation. * This implementation requires no external libraries. * * @author Mark Taylor * @since 16 Sep 2008 */ public class InternalClientFactory implements SampXmlRpcClientFactory { public SampXmlRpcClient createClient( URL endpoint ) throws IOException { return new InternalClient( endpoint ); } } jsamp/src/java/org/astrogrid/samp/xmlrpc/internal/package.html0000664000175000017500000000014212730747754024344 0ustar sladensladen Implementation of pluggable XML-RPC layer which requires no external dependencies. jsamp/src/java/org/astrogrid/samp/xmlrpc/StandardClientProfile.java0000664000175000017500000001666212730747754025350 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.io.File; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; import java.util.logging.Logger; import org.astrogrid.samp.DataException; import org.astrogrid.samp.Platform; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.DefaultClientProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; /** * Standard Profile implementation of ClientProfile. * It is normally appropriate to use one of the static methods * to obtain an instance based on a particular XML-RPC implementation. * * @author Mark Taylor * @since 15 Jul 2008 */ public class StandardClientProfile implements ClientProfile { private final SampXmlRpcClientFactory xClientFactory_; private final SampXmlRpcServerFactory xServerFactory_; private static StandardClientProfile defaultInstance_; private static URL dfltLockUrl_; private static URL lockUrl_; private static final Logger logger_ = Logger.getLogger( StandardClientProfile.class.getName() ); /** Filename used for lockfile in home directory by default ({@value}). */ public static final String LOCKFILE_NAME = ".samp"; /** Prefix in SAMP_HUB value indicating lockfile URL ({@value}). */ public static final String STDPROFILE_HUB_PREFIX = "std-lockurl:"; /** * Constructs a profile given client and server factory implementations. * * @param xClientFactory XML-RPC client factory implementation * @param xServerFactory XML-RPC server factory implementation */ public StandardClientProfile( SampXmlRpcClientFactory xClientFactory, SampXmlRpcServerFactory xServerFactory ) { xClientFactory_ = xClientFactory; xServerFactory_ = xServerFactory; } /** * Constructs a profile given an XmlRpcKit object. * * @param xmlrpc XML-RPC implementation */ public StandardClientProfile( XmlRpcKit xmlrpc ) { this( xmlrpc.getClientFactory(), xmlrpc.getServerFactory() ); } public boolean isHubRunning() { try { LockInfo lockInfo = getLockInfo(); if ( lockInfo == null ) { return false; } URL xurl = lockInfo.getXmlrpcUrl(); if ( xurl == null ) { return false; } SampXmlRpcClient xClient = xClientFactory_.createClient( xurl ); xClient.callAndWait( "samp.hub.ping", new ArrayList() ); return true; } catch ( IOException e ) { return false; } } public HubConnection register() throws SampException { LockInfo lockInfo; try { lockInfo = getLockInfo(); } catch ( SampException e ) { throw (SampException) e; } catch ( IOException e ) { throw new SampException( "Error reading lockfile", e ); } if ( lockInfo == null ) { return null; } else { try { lockInfo.check(); } catch ( DataException e ) { String msg = "Incomplete/broken lock file"; try { File lockFile = SampUtils.urlToFile( getLockUrl() ); if ( lockFile != null ) { msg += " - try deleting " + lockFile; } } catch ( IOException e2 ) { // never mind } throw new SampException( msg, e ); } SampXmlRpcClient xClient; URL xurl = lockInfo.getXmlrpcUrl(); try { xClient = xClientFactory_.createClient( xurl ); } catch ( IOException e ) { throw new SampException( "Can't connect to " + xurl, e ); } return new StandardHubConnection( xClient, xServerFactory_, lockInfo.getSecret() ); } } /** * Returns the LockInfo which indicates how to locate the hub. * If no lockfile exists (probably becuause no appropriate hub * is running), null is returned. * The default implementation returns * LockInfo.readLockFile(getLockUrl()); * it may be overridden to provide a non-standard client profiles. * * @return hub location information * @throws IOException if the lockfile exists but cannot be read for * some reason */ public LockInfo getLockInfo() throws IOException { return LockInfo.readLockFile( getLockUrl() ); } /** * Returns the location of the Standard Profile lockfile. * By default this is the file .samp in the user's "home" * directory, unless overridden by a value of the SAMP_HUB environment * variable starting with "std-lockurl". * * @return lockfile URL */ public static URL getLockUrl() throws IOException { if ( lockUrl_ == null ) { String hublocEnv = DefaultClientProfile.HUBLOC_ENV; String hubloc = Platform.getPlatform().getEnv( hublocEnv ); final URL lockUrl; if ( hubloc != null && hubloc.startsWith( STDPROFILE_HUB_PREFIX ) ) { lockUrl = new URL( hubloc.substring( STDPROFILE_HUB_PREFIX .length() ) ); logger_.info( "Lockfile as set by env var: " + hublocEnv + "=" + hubloc ); } else if ( hubloc != null && hubloc.trim().length() > 0 ) { logger_.warning( "Ignoring non-Standard " + hublocEnv + "=" + hubloc ); lockUrl = getDefaultLockUrl(); } else { lockUrl = getDefaultLockUrl(); logger_.info( "Using default Standard Profile lockfile: " + SampUtils.urlToFile( lockUrl ) ); } lockUrl_ = lockUrl; } return lockUrl_; } /** * Returns the lockfile URL which will be used in absence of any * SAMP_HUB environment variable. * * @return URL for file .samp in user's home directory */ public static URL getDefaultLockUrl() throws IOException { if ( dfltLockUrl_ == null ) { dfltLockUrl_ = SampUtils.fileToUrl( new File( Platform.getPlatform() .getHomeDirectory(), LOCKFILE_NAME ) ); } return dfltLockUrl_; } /** * Returns an instance based on the default XML-RPC implementation. * This can be configured using system properties. * * @see XmlRpcKit#getInstance * @see org.astrogrid.samp.client.DefaultClientProfile#getProfile * @return a client profile instance */ public static StandardClientProfile getInstance() { if ( defaultInstance_ == null ) { XmlRpcKit xmlrpc = XmlRpcKit.getInstance(); defaultInstance_ = new StandardClientProfile( xmlrpc.getClientFactory(), xmlrpc.getServerFactory() ); } return defaultInstance_; } } jsamp/src/java/org/astrogrid/samp/xmlrpc/SampXmlRpcClient.java0000664000175000017500000000260612730747754024306 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.io.IOException; import java.net.URL; import java.util.List; /** * Interface for a client which can make XML-RPC calls for SAMP. * The method parameters and return values must be of SAMP-compatible types, * that is only Strings, Lists, and String-keyed Maps are allowed in * the data structures. * * @author Mark Taylor * @since 22 Aug 2008 */ public interface SampXmlRpcClient { /** * Makes a synchronous call, waiting for the response and returning * the result. * * @param method XML-RPC method name * @param params parameters for XML-RPC call (SAMP-compatible) * @return XML-RPC call return value (SAMP-compatible) */ Object callAndWait( String method, List params ) throws IOException; /** * Sends a call, but does not wait around for the response. * If possible, this method should complete quickly. * *

      NOTE: it seems to be difficult to implement this method in a * way which is faster than {@link #callAndWait} but does not cause * problems elsewhere (incomplete HTTP responses). It is probably * a good idea to avoid using it if possible. * * @param method XML-RPC method name * @param params parameters for XML-RPC call (SAMP-compatible) */ void callAndForget( String method, List params ) throws IOException; } jsamp/src/java/org/astrogrid/samp/xmlrpc/SampXmlRpcServer.java0000664000175000017500000000156012730747754024334 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.net.URL; /** * Interface for a server which can respond to XML-RPC calls for SAMP. * The method parameters and return values must be of SAMP-compatible types, * that is only Strings, Lists, and String-keyed Maps are allowed in * the data structures. * * @author Mark Taylor * @since 22 Aug 2008 */ public interface SampXmlRpcServer { /** * Returns the server's endpoint. * * @return URL to which XML-RPC requests are POSTed */ URL getEndpoint(); /** * Adds a handler which can service certain XML-RPC methods. * * @param handler handler to add */ void addHandler( SampXmlRpcHandler handler ); /** * Removes a previously-added handler. * * @param handler handler to remove */ void removeHandler( SampXmlRpcHandler handler ); } jsamp/src/java/org/astrogrid/samp/xmlrpc/StandardHubConnection.java0000664000175000017500000000370112730747754025335 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.io.IOException; import java.util.Collections; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.SampException; /** * HubConnection implementation for the Standard Profile. * * @author Mark Taylor * @since 27 Oct 2010 */ class StandardHubConnection extends XmlRpcHubConnection { private final SampXmlRpcServerFactory serverFactory_; private final String clientKey_; private CallableClientServer callableServer_; /** * Constructor. * * @param xClient XML-RPC client * @param serverFactory XML-RPC server factory implementation * @param secret samp.secret registration password */ public StandardHubConnection( SampXmlRpcClient xClient, SampXmlRpcServerFactory serverFactory, String secret ) throws SampException { super( xClient, "samp.hub.", Collections.singletonList( secret ) ); clientKey_ = getRegInfo().getPrivateKey(); serverFactory_ = serverFactory; } public Object getClientKey() { return clientKey_; } public void setCallable( CallableClient callable ) throws SampException { if ( callableServer_ == null ) { try { callableServer_ = CallableClientServer.getInstance( serverFactory_ ); } catch ( IOException e ) { throw new SampException( "Can't start client XML-RPC server", e ); } } callableServer_.addClient( this, callable ); exec( "setXmlrpcCallback", new Object[] { callableServer_.getUrl().toString() } ); } public void unregister() throws SampException { if ( callableServer_ != null ) { callableServer_.removeClient( this ); } super.unregister(); } } jsamp/src/java/org/astrogrid/samp/xmlrpc/SampXmlRpcServerFactory.java0000664000175000017500000000210112730747754025654 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.io.IOException; /** * Defines a factory for SampXmlRpcServer instances. * In most cases it will make sense to implement this class so that * a single server instance is constructed lazily, and the same instance * is always returned from the {@link #getServer} method. * This means that the same server can be used for everything that requires * an XML-RPC server, thus keeping resource usage down. * Users of this class must keep this implementation model in mind, * so must not assume that a new instance is returned each time. * But if an implementation wants to return a new instance each time for * some reason, that is permissible. * * @author Mark Taylor * @since 22 Aug 2008 */ public interface SampXmlRpcServerFactory { /** * Returns an XML-RPC server implementation. * Implementations are permitted, but not required, to return the same * object from different calls of this method. * * @return new or re-used server */ SampXmlRpcServer getServer() throws IOException; } jsamp/src/java/org/astrogrid/samp/xmlrpc/LockInfo.java0000664000175000017500000001703512730747754022627 0ustar sladensladenpackage org.astrogrid.samp.xmlrpc; import java.io.BufferedInputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.net.URL; import java.util.Iterator; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.logging.Logger; import org.astrogrid.samp.DataException; import org.astrogrid.samp.SampMap; import org.astrogrid.samp.SampUtils; /** * Represents the information read from a SAMP Standard Profile Lockfile. * This contains a key-value entry for each assignment read from the file. * Any non-assignment lines are not represented by this object. * * @author Mark Taylor * @since 14 Jul 2008 */ public class LockInfo extends SampMap { private static final Logger logger_ = Logger.getLogger( LockInfo.class.getName() ); /** Key for opaque text string required by the hub for registration. */ public static final String SECRET_KEY = "samp.secret"; /** Key for XML-RPC endpoint for communication with the hub. */ public static final String XMLRPCURL_KEY = "samp.hub.xmlrpc.url"; /** Key for the SAMP Standard Profile version implemented by the hub. */ public static final String VERSION_KEY = "samp.profile.version"; private static final String[] KNOWN_KEYS = new String[] { SECRET_KEY, XMLRPCURL_KEY, VERSION_KEY, }; /** SAMP Standard Profile version for this toolkit implementation. */ public static final String DEFAULT_VERSION_VALUE = "1.3"; private static final Pattern TOKEN_REGEX = Pattern.compile( "[a-zA-Z0-9\\-_\\.]+" ); private static final Pattern ASSIGNMENT_REGEX = Pattern.compile( "(" + TOKEN_REGEX.pattern() + ")=(.*)" ); private static final Pattern COMMENT_REGEX = Pattern.compile( "#[\u0020-\u007f]*" ); /** * Constructs an empty LockInfo. */ public LockInfo() { super( KNOWN_KEYS ); } /** * Constructs a LockInfo based on an existing map. * * @param map map containing initial data for this object */ public LockInfo( Map map ) { this(); putAll( map ); } /** * Constructs a LockInfo from a given SAMP secret and XML-RPC URL. * The version string is set to the default for this toolkit. * * @param secret value for {@link #SECRET_KEY} key * @param xmlrpcurl value for {@link #XMLRPCURL_KEY} key */ public LockInfo( String secret, String xmlrpcurl ) { this(); put( SECRET_KEY, secret ); put( XMLRPCURL_KEY, xmlrpcurl ); put( VERSION_KEY, DEFAULT_VERSION_VALUE ); } /** * Returns the value of the {@link #XMLRPCURL_KEY} key. * * @return hub XML-RPC connection URL */ public URL getXmlrpcUrl() { return getUrl( XMLRPCURL_KEY ); } /** * Returns the value of the {@link #VERSION_KEY} key. * * @return version of the SAMP standard profile implemented */ public String getVersion() { return getString( VERSION_KEY ); } /** * Returns the value of the {@link #SECRET_KEY} key. * * @return password for hub connection */ public String getSecret() { return getString( SECRET_KEY ); } public void check() { super.check(); checkHasKeys( new String[] { SECRET_KEY, XMLRPCURL_KEY, } ); for ( Iterator it = entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); Object key = entry.getKey(); if ( key instanceof String ) { if ( ! TOKEN_REGEX.matcher( key.toString() ).matches() ) { throw new DataException( "Bad key syntax: " + key + " does not match " + TOKEN_REGEX.pattern() ); } } else { throw new DataException( "Map key " + entry.getKey() + " is not a string" ); } Object value = entry.getValue(); if ( value instanceof String ) { String sval = (String) value; for ( int i = 0; i < sval.length(); i++ ) { int c = sval.charAt( i ); if ( c < 0x20 || c > 0x7f ) { throw new DataException( "Value contains illegal " + "character 0x" + Integer.toHexString( c ) ); } } } else { throw new DataException( "Map value " + value + " is not a string" ); } } } /** * Returns a LockInfo as read from a lockfile at a given location. * If the lockfile does not exist, null is returned. * An exception may be thrown if it exists but is cannot be read. * * @param url lockfile location * @return lockfile contents, or null if it is absent */ public static LockInfo readLockFile( URL url ) throws IOException { final InputStream in; File file = SampUtils.urlToFile( url ); if ( file != null ) { if ( file.exists() ) { in = new FileInputStream( file ); } else { return null; } } else { try { in = url.openStream(); } catch ( IOException e ) { return null; } } return readLockFile( in ); } /** * Returns the LockInfo read from a given stream. * The stream is closed if the read is successful. * * @param in input stream to read * @return lockfile information */ public static LockInfo readLockFile( InputStream in ) throws IOException { LockInfo info = new LockInfo(); in = new BufferedInputStream( in ); for ( String line; ( line = readLine( in ) ) != null; ) { Matcher assigner = ASSIGNMENT_REGEX.matcher( line ); if ( assigner.matches() ) { info.put( assigner.group( 1 ), assigner.group( 2 ) ); } else if ( COMMENT_REGEX.matcher( line ).matches() ) { } else if ( line.length() == 0 ) { } else { logger_.warning( "Ignoring lockfile line with bad syntax" ); logger_.info( "Bad line: " + line ); } } in.close(); return info; } /** * Returns a given map as a LockInfo object. * * @param map map * @return lock info */ public static LockInfo asLockInfo( Map map ) { return map instanceof LockInfo ? (LockInfo) map : new LockInfo( map ); } /** * Returns a line from a lockfile-type input stream. * * @param in input stream * @return next line */ private static String readLine( InputStream in ) throws IOException { StringBuffer sbuf = new StringBuffer(); while ( true ) { int c = in.read(); switch ( c ) { case '\r': case '\n': return sbuf.toString(); case -1: return sbuf.length() > 0 ? sbuf.toString() : null; default: sbuf.append( (char) c ); } } } } jsamp/src/java/org/astrogrid/samp/xmlrpc/package.html0000664000175000017500000000014012730747754022526 0ustar sladensladen Classes relating to Standard Profile and XML-RPC pluggable implementation layer. jsamp/src/java/org/astrogrid/samp/JSamp.java0000664000175000017500000002475012730747754020632 0ustar sladensladenpackage org.astrogrid.samp; import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Arrays; import java.util.Iterator; import java.util.List; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.DefaultClientProfile; import org.astrogrid.samp.hub.Hub; import org.astrogrid.samp.web.WebClientProfile; import org.astrogrid.samp.xmlrpc.StandardClientProfile; import org.astrogrid.samp.xmlrpc.XmlRpcKit; /** * Convenience class for invoking JSAMP command-line applications. * * @author Mark Taylor * @since 23 Jul 2008 */ public class JSamp { /** Known command class names. */ static final String[] COMMAND_CLASSES = new String[] { "org.astrogrid.samp.hub.Hub", "org.astrogrid.samp.gui.HubMonitor", "org.astrogrid.samp.test.Snooper", "org.astrogrid.samp.test.MessageSender", "org.astrogrid.samp.test.HubTester", "org.astrogrid.samp.test.CalcStorm", "org.astrogrid.samp.bridge.Bridge", }; /** * Private sole constructor prevents instantiation. */ private JSamp() { } /** * Does the work for the main method. */ public static int runMain( String[] args ) { // Assemble usage message. StringBuffer ubuf = new StringBuffer() .append( "\n Usage:" ) .append( "\n " ) .append( JSamp.class.getName() ) .append( " [-help]" ) .append( " [-version]" ) .append( " " ) .append( " [-help]" ) .append( " " ) .append( "\n " ) .append( "" ) .append( " [-help]" ) .append( " " ) .append( "\n" ) .append( "\n Commands (command-classes) are:" ); for ( int ic = 0; ic < COMMAND_CLASSES.length; ic++ ) { String className = COMMAND_CLASSES[ ic ]; ubuf.append( "\n " ) .append( abbrev( className ) ); int pad = 14 - abbrev( className ).length(); for ( int i = 0; i < pad; i++ ) { ubuf.append( ' ' ); } ubuf.append( " (" ) .append( className ) .append( ")" ); } ubuf.append( "\n" ) .append( "\n " ) .append( "Environment Variable:" ) .append( "\n " ) .append( DefaultClientProfile.HUBLOC_ENV ) .append( " = " ) .append( StandardClientProfile.STDPROFILE_HUB_PREFIX ) .append( "" ) .append( "|" ) .append( WebClientProfile.WEBPROFILE_HUB_PREFIX ) .append( "" ) .append( "\n " ) .append( "|" ) .append( DefaultClientProfile.HUBLOC_CLASS_PREFIX ) .append( "" ) .append( "\n" ) .append( "\n " ) .append( "System Properties:" ) .append( "\n " ) .append( "jsamp.hub.profiles = " ) .append( "std|web|[,...]" ) .append( "\n " ) .append( "jsamp.localhost = " ) .append( "\"[hostname]\"|\"[hostnumber]\"|" ) .append( "\n " ) .append( "jsamp.server.port = " ) .append( "" ) .append( "\n " ) .append( "jsamp.web.extrahosts = " ) .append( "[,...]" ) .append( "\n " ) .append( "jsamp.xmlrpc.impl = " ) .append( formatImpls( XmlRpcKit.KNOWN_IMPLS, XmlRpcKit.class ) ) .append( "\n" ); String usage = ubuf.toString(); // Perform general tweaks. setDefaultProperty( "java.awt.Window.locationByPlatform", "true" ); // Process command line arguments. List argList = new ArrayList( Arrays.asList( args ) ); for ( Iterator it = argList.iterator(); it.hasNext(); ) { String arg = (String) it.next(); if ( arg.toLowerCase().equals( "hubrunner" ) ) { System.err.println( "\"hubrunner\" command is deprecated. " + "Use \"hub\" instead." ); return 1; } for ( int ic = 0; ic < COMMAND_CLASSES.length; ic++ ) { String className = COMMAND_CLASSES[ ic ]; if ( arg.toLowerCase() .equals( abbrev( className ).toLowerCase() ) ) { it.remove(); return runCommand( className, (String[]) argList.toArray( new String[ 0 ] ) ); } } if ( arg.startsWith( "-h" ) ) { System.out.println( usage ); return 0; } else if ( arg.startsWith( "-vers" ) ) { System.out.println(); System.out.println( getVersionText() ); System.out.println(); return 0; } else { System.err.println( usage ); return 1; } } // No arguments. assert argList.isEmpty(); System.err.println( JSamp.class.getName() + " invoked with no arguments" + " - running hub" ); System.err.println( "Use \"-help\" flag for more options" ); System.err.println( "Use \"hub\" argument" + " to suppress this message" ); return runCommand( Hub.class.getName(), new String[ 0 ] ); } /** * Runs a command. * * @param className name of a class with a main(String[]) * method * @param args arguments as if passed from the command line */ private static int runCommand( String className, String[] args ) { Class clazz; try { clazz = Class.forName( className ); } catch ( ClassNotFoundException e ) { System.err.println( "Class " + className + " not available" ); return 1; } Object statusObj; try { getMainMethod( clazz ).invoke( null, new Object[] { args } ); return 0; } catch ( InvocationTargetException e ) { e.getCause().printStackTrace(); return 1; } catch ( Exception e ) { e.printStackTrace(); return 1; } } /** * Returns the main(String[]) method for a given class. */ static Method getMainMethod( Class clazz ) { Method method; try { method = clazz.getMethod( "main", new Class[] { String[].class } ); } catch ( NoSuchMethodException e ) { throw (IllegalArgumentException) new AssertionError( "main() method missing for " + clazz.getName() ) .initCause( e ); } int mods = method.getModifiers(); if ( Modifier.isStatic( mods ) && Modifier.isPublic( mods ) && method.getReturnType() == void.class ) { return method; } else { throw new IllegalArgumentException( "Wrong main() method signature" + " for " + clazz.getName() ); } } /** * Returns the abbreviated form of a given class name. * * @param className class name * @return abbreviation */ private static String abbrev( String className ) { return className.substring( className.lastIndexOf( "." ) + 1 ) .toLowerCase(); } private static String formatImpls( Object[] options, Class clazz ) { StringBuffer sbuf = new StringBuffer(); if ( options != null ) { for ( int i = 0; i < options.length; i++ ) { if ( sbuf.length() > 0 ) { sbuf.append( '|' ); } sbuf.append( options[ i ] ); } } if ( clazz != null ) { if ( sbuf.length() > 0 ) { sbuf.append( '|' ); } sbuf.append( '<' ) .append( clazz.getName().replaceFirst( "^.*\\.", "" ) .toLowerCase() ) .append( "-class" ) .append( '>' ); } return sbuf.toString(); } /** * Returns a string giving version details for this package. * * @return version string */ private static String getVersionText() { return new StringBuffer() .append( " This is JSAMP.\n" ) .append( "\n " ) .append( "JSAMP toolkit version:" ) .append( "\n " ) .append( SampUtils.getSoftwareVersion() ) .append( "\n " ) .append( "SAMP standard version:" ) .append( "\n " ) .append( SampUtils.getSampVersion() ) .append( "\n " ) .append( "Author:" ) .append( "\n " ) .append( "Mark Taylor (m.b.taylor@bristol.ac.uk)" ) .append( "\n " ) .append( "WWW:" ) .append( "\n " ) .append( "http://www.star.bristol.ac.uk/~mbt/jsamp" ) .toString(); } /** * Sets a system property to a given value unless it has already been set. * If it has a prior value, that is undisturbed. * Potential security exceptions are caught and dealt with. * * @param key property name * @param value suggested property value */ private static void setDefaultProperty( String key, String value ) { String existingVal = System.getProperty( key ); if ( existingVal == null || existingVal.trim().length() == 0 ) { try { System.setProperty( key, value ); } catch ( SecurityException e ) { // never mind. } } } /** * Main method. * Use -help flag for documentation. */ public static void main( String[] args ) { int status = runMain( args ); if ( status != 0 ) { System.exit( status ); } } } jsamp/src/java/org/astrogrid/samp/test/0000775000175000017500000000000012730747754017724 5ustar sladensladenjsamp/src/java/org/astrogrid/samp/test/TestXmlrpcClient.java0000664000175000017500000001011212730747754024026 0ustar sladensladenpackage org.astrogrid.samp.test; import java.io.InputStream; import java.io.IOException; import java.net.URL; import java.util.List; import java.util.Map; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.xmlrpc.internal.InternalClient; import org.astrogrid.samp.xmlrpc.internal.XmlUtils; import org.w3c.dom.DOMException; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.xml.sax.SAXException; /** * SampXmlrpcClient implementation for testing success or failure of * XML-RPC method invocations. * Note the return value of the {@link #callAndWait} method is either * {@link #SUCCESS} or {@link #FAILURE}, rather than the actual result * of the call. * * Methods may throw {@link TestException}s to indicate assertion failures. * * @author Mark Taylor * @since 28 Aug 2009 */ class TestXmlrpcClient extends InternalClient { public static final Object SUCCESS = "Success"; public static final Object FAILURE = "Failure"; /** * Constructor. * * @param endpoint hub HTTP endpoint */ TestXmlrpcClient( URL endpoint ) { super( endpoint ); } /** * Returns {@link #SUCCESS} or {@link #FAILURE}. */ protected Object deserializeResponse( InputStream in ) throws IOException { try { Document doc = DocumentBuilderFactory.newInstance() .newDocumentBuilder().parse( in ); Element topEl = XmlUtils.getChild( doc, "methodResponse" ); Element contentEl = XmlUtils.getChild( topEl ); String contentTag = contentEl.getTagName(); if ( "params".equals( contentTag ) ) { Element paramEl = XmlUtils.getChild( contentEl, "param" ); Element valueEl = XmlUtils.getChild( paramEl, "value" ); Object value = XmlUtils.parseSampValue( valueEl ); SampUtils.checkObject( value ); return SUCCESS; } else if ( "fault".equals( contentTag ) ) { Element valueEl = XmlUtils.getChild( contentEl, "value" ); Map value = (Map) XmlUtils.parseSampValue( valueEl ); String faultString = (String) value.get( "faultString" ); int faultCode = ((Integer) value.get( "faultCode" )).intValue(); Tester.assertEquals( 2, value.size() ); return FAILURE; } else { throw new TestException( "Unknown child: " + contentTag ); } } catch ( ParserConfigurationException e ) { throw (IOException) new IOException( "Trouble with XML parsing" ) .initCause( e ); } catch ( SAXException e ) { throw (IOException) new IOException( "Trouble with XML parsing" ) .initCause( e ); } catch ( DOMException e ) { throw (IOException) new IOException( "Trouble with XML parsing" ) .initCause( e ); } } /** * Makes a call, and asserts that the result is a normal XML-RPC * response. * * @param method XML-RPC method name * @param params parameters for XML-RPC call (SAMP-compatible) * @throws TestException if the response is not a success */ public void checkSuccessCall( String method, List params ) throws IOException { Tester.assertEquals( SUCCESS, callAndWait( method, params ) ); } /** * Makes a call, and asserts that the result is an XML-RPC fault. * * @param method XML-RPC method name * @param params parameters for XML-RPC call (SAMP-compatible) * @throws TestException if the response is not a fault */ public void checkFailureCall( String method, List params ) throws IOException { Tester.assertEquals( FAILURE, callAndWait( method, params ) ); } } jsamp/src/java/org/astrogrid/samp/test/TestException.java0000664000175000017500000000074312730747754023371 0ustar sladensladenpackage org.astrogrid.samp.test; /** * Exception thrown by a failed test. * * @author Mark Taylor * @since 18 Jul 2008 */ public class TestException extends RuntimeException { public TestException() { super(); } public TestException( String msg ) { super( msg ); } public TestException( String msg, Throwable cause ) { super( msg, cause ); } public TestException( Throwable cause ) { super( cause ); } } jsamp/src/java/org/astrogrid/samp/test/ReplyCollector.java0000664000175000017500000001665112730747754023542 0ustar sladensladenpackage org.astrogrid.samp.test; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; import org.astrogrid.samp.Response; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; /** * Partial implementation of {@link CallableClient} which handles the * receiveReply method. * This takes care of matching up replies with calls and is intended for * use with test classes. Some assertions are made within this class * to check that replies match with messages sent. Call-type messages * must be sent using this object's call and callAll * methods, rather than directly on the HubConnection, * to ensure that the internal state stays correct. * * @author Mark Taylor * @since 18 Jul 2008 */ abstract class ReplyCollector implements CallableClient { private final HubConnection connection_; private final Set sentSet_; private final Map replyMap_; private boolean allowTagReuse_; /** * Constructor. * * @param connection hub connection * @param allowTagReuse if true clients may reuse tags; * if false any such attempt generates an exception */ public ReplyCollector( HubConnection connection ) { connection_ = connection; sentSet_ = Collections.synchronizedSet( new HashSet() ); replyMap_ = Collections.synchronizedMap( new HashMap() ); } /** * Determines whether clients are permitted to reuse tags for different * messages. If true, any such attempt generates an exception. * * @param allow whether to allow tag reuse */ public void setAllowTagReuse( boolean allow ) { allowTagReuse_ = allow; } /** * Performs a call method on this collector's hub connection. * Additional internal state is updated. * Although it is legal as far as SAMP goes, the msgTag * must not be one which was used earlier for the same recipient. * * @param recipientId public-id of client to receive message * @param msgTag arbitrary string tagging this message for caller's * benefit * @param msg {@link org.astrogrid.samp.Message}-like map * @return message ID */ public String call( String recipientId, String msgTag, Map msg ) throws SampException { Object key = createKey( recipientId, msgTag ); if ( ! allowTagReuse_ && sentSet_.contains( key ) ) { throw new IllegalArgumentException( "Key " + key + " reused" ); } sentSet_.add( key ); return connection_.call( recipientId, msgTag, msg ); } /** * Performs a callAll method on this collector's * hub connection. * Additional internal state is updated. * Although it is legal as far as SAMP goes, the msgTag * must not be one which was used for an earlier broadcast. * * @param msgTag arbitrary string tagging this message for caller's * benefit * @param msg {@link org.astrogrid.samp.Message}-like map * @return message ID */ public Map callAll( String msgTag, Map msg ) throws SampException { Object key = createKey( null, msgTag ); if ( ! allowTagReuse_ && sentSet_.contains( key ) ) { throw new IllegalArgumentException( "Key " + key + " reused" ); } sentSet_.add( key ); return connection_.callAll( msgTag, msg ); } public void receiveResponse( String responderId, String msgTag, Response response ) { Object key = createKey( responderId, msgTag ); Object result; try { if ( ! allowTagReuse_ && replyMap_.containsKey( key ) ) { throw new TestException( "Response for " + key + " already received" ); } else if ( ! sentSet_.contains( key ) && ! sentSet_.contains( createKey( null, msgTag ) ) ) { throw new TestException( "Message " + key + " never sent" ); } result = response; } catch ( TestException e ) { result = e; } synchronized ( replyMap_ ) { if ( ! replyMap_.containsKey( key ) ) { replyMap_.put( key, new ArrayList() ); } ((List) replyMap_.get( key )).add( result ); replyMap_.notifyAll(); } } /** * Returns the total number of unretrieved replies so far collected by * this object. * * @return reply count */ public int getReplyCount() { int count = 0; synchronized ( replyMap_ ) { for ( Iterator it = replyMap_.values().iterator(); it.hasNext(); ) { count += ((List) it.next()).size(); } } return count; } /** * Waits for a reply to a message sent earlier * using call or callAll. * Blocks until such a response is received. * * @param responderId client ID of client providing response * @param msgTag tag which was used to send the message * @return response */ public Response waitForReply( String responderId, String msgTag ) { Object key = createKey( responderId, msgTag ); try { synchronized ( replyMap_ ) { while ( ! replyMap_.containsKey( key ) || ((List) replyMap_.get( key )).isEmpty() ) { replyMap_.wait(); } } } catch ( InterruptedException e ) { throw new Error( "Interrupted", e ); } return getReply( responderId, msgTag ); } /** * Gets the reply to a message sent earlier * using call or callAll. * Does not block; if no such response has been received so far, * returns null. * * @param responderId client ID of client providing response * @param msgTag tag which was used to send the message * @return response */ public Response getReply( String responderId, String msgTag ) { Object key = createKey( responderId, msgTag ); synchronized ( replyMap_ ) { List list = (List) replyMap_.get( key ); Object result = list == null || list.isEmpty() ? null : list.remove( 0 ); if ( result == null ) { return null; } else if ( result instanceof Response ) { return (Response) result; } else if ( result instanceof Throwable ) { throw new TestException( (Throwable) result ); } else { throw new AssertionError(); } } } /** * Returns an opaque object suitable for use as a map key * based on a recipient ID and message tag. * * @param recipientId recipient ID * @param msgTag message tag */ private static Object createKey( String recipientId, String msgTag ) { return Arrays.asList( new String[] { recipientId, msgTag } ); } } jsamp/src/java/org/astrogrid/samp/test/MessageSender.java0000664000175000017500000005523212730747754023323 0ustar sladensladenpackage org.astrogrid.samp.test; import java.io.IOException; import java.io.PrintStream; import java.util.AbstractMap; import java.util.AbstractSet; import java.util.ArrayList; import java.util.Arrays; import java.util.BitSet; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import org.astrogrid.samp.Client; import org.astrogrid.samp.Message; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.Response; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.DefaultClientProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; /** * Sends a message to one or more other SAMP clients. * Intended for use from the command line. * * @author Mark Taylor * @since 23 Jul 2008 */ public abstract class MessageSender { private static Logger logger_ = Logger.getLogger( MessageSender.class.getName() ); /** * Sends a message to a given list of recipients. * If recipientIds is null, then will be sent to all * subscribed clients. * * @param connection hub connection * @param msg message to send * @param recipientIds array of recipients to target, or null * @return responder Client -> Response map */ abstract Map getResponses( HubConnection connection, Message msg, String[] recipientIds ) throws IOException; /** * Sends a message to a list of recipients and displays the results * on an output stream. * * @param connection hub connection * @param msg message to send * @param recipientIds array of recipients to target, or null * @param destination print stream */ void showResults( HubConnection connection, Message msg, String[] recipientIds, PrintStream out ) throws IOException { Map responses = getResponses( connection, msg, recipientIds ); for ( Iterator it = responses.entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); String responderId = (String) entry.getKey(); Client responder = new MetaClient( responderId, connection ); Object response = entry.getValue(); out.println(); out.println( responder ); if ( response instanceof Throwable ) { ((Throwable) response).printStackTrace( out ); } else { out.println( SampUtils.formatObject( response, 3 ) ); } } } /** * Translates an array of client names to client IDs. * If some or all cannot be identified, an exception is thrown. * * @param conn hub connection * @param names array of client names, interpreted case-insensitively * @return array of client ids corresponding to names */ private static String[] namesToIds( HubConnection conn, String[] names ) throws SampException { int count = names.length; if ( count == 0 ) { return new String[ 0 ]; } String[] ids = new String[ count ]; BitSet flags = new BitSet( count ); String[] allIds = conn.getRegisteredClients(); for ( int ic = 0; ic < allIds.length; ic++ ) { String id = allIds[ ic ]; Metadata meta = conn.getMetadata( id ); String name = meta.getName(); for ( int in = 0; in < count; in++ ) { if ( names[ in ].equalsIgnoreCase( name ) ) { ids[ in ] = id; flags.set( in ); } } if ( flags.cardinality() == count ) { return ids; } } assert flags.cardinality() < count; List unknownList = new ArrayList(); for ( int in = 0; in < count; in++ ) { if ( ids[ in ] == null ) { unknownList.add( names[ in ] ); } } throw new SampException( "Unknown client " + ( unknownList.size() == 1 ? ( "name " + unknownList.get( 0 ) ) : ( "names " + unknownList ) ) ); } /** * Main method. * Use -help flag for documentation. */ public static void main( String[] args ) throws IOException { int status = runMain( args ); if ( status != 0 ) { System.exit( status ); } } /** * Does the work for the main method. */ public static int runMain( String[] args ) throws IOException { // Assemble usage string. String usage = new StringBuffer() .append( "\n Usage:" ) .append( "\n " + MessageSender.class.getName() ) .append( "\n " ) .append( " [-help]" ) .append( " [-/+verbose]" ) .append( "\n " ) .append( " -mtype " ) .append( " [-param ...]" ) .append( " [-mode sync|async|notify]" ) .append( "\n " ) .append( " [-targetid ...]" ) .append( " [-targetname ...]" ) .append( "\n " ) .append( " [-sendername ]" ) .append( " [-sendermeta ]" ) .append( "\n" ) .toString(); // Set up variables which can be set or changed by the argument list. String mtype = null; List targetIdList = new ArrayList(); List targetNameList = new ArrayList(); Map paramMap = new HashMap(); String mode = "sync"; Metadata meta = new Metadata(); int timeout = 0; int verbAdjust = 0; // Parse the argument list. List argList = new ArrayList( Arrays.asList( args ) ); for ( Iterator it = argList.iterator(); it.hasNext(); ) { String arg = (String) it.next(); if ( arg.equals( "-mtype" ) && it.hasNext() ) { it.remove(); if ( mtype != null ) { System.err.println( usage ); return 1; } mtype = (String) it.next(); it.remove(); } else if ( arg.equals( "-targetid" ) && it.hasNext() ) { it.remove(); targetIdList.add( (String) it.next() ); it.remove(); } else if ( arg.equals( "-targetname" ) && it.hasNext() ) { it.remove(); targetNameList.add( (String) it.next() ); it.remove(); } else if ( arg.equals( "-param" ) && it.hasNext() ) { it.remove(); String pName = (String) it.next(); it.remove(); String pValue; if ( it.hasNext() ) { pValue = (String) it.next(); it.remove(); } else { System.err.println( usage ); return 1; } paramMap.put( pName, SampUtils.parseValue( pValue ) ); } else if ( arg.equals( "-mode" ) && it.hasNext() ) { it.remove(); mode = (String) it.next(); it.remove(); } else if ( arg.equals( "-sendername" ) && it.hasNext() ) { it.remove(); meta.setName( (String) it.next() ); it.remove(); } else if ( arg.equals( "-sendermeta" ) && it.hasNext() ) { it.remove(); String mName = (String) it.next(); it.remove(); String mValue; if ( it.hasNext() ) { mValue = (String) it.next(); it.remove(); } else { System.err.println( usage ); return 1; } meta.put( mName, SampUtils.parseValue( mValue ) ); } else if ( arg.equals( "-timeout" ) && it.hasNext() ) { it.remove(); String stimeout = (String) it.next(); it.remove(); try { timeout = Integer.parseInt( stimeout ); } catch ( NumberFormatException e ) { System.err.println( "Not numeric: " + stimeout ); System.err.println( usage ); return 1; } } else if ( arg.startsWith( "-v" ) ) { it.remove(); verbAdjust--; } else if ( arg.startsWith( "+v" ) ) { it.remove(); verbAdjust++; } else if ( arg.startsWith( "-h" ) ) { it.remove(); System.out.println( usage ); return 0; } else { it.remove(); System.err.println( usage ); return 1; } } if ( ! argList.isEmpty() ) { System.err.println( usage ); return 1; } if ( mtype == null ) { System.err.println( usage ); return 1; } // Set logging levels in accordance with flags. int logLevel = Level.WARNING.intValue() + 100 * verbAdjust; Logger.getLogger( "org.astrogrid.samp" ) .setLevel( Level.parse( Integer.toString( logLevel ) ) ); // Get profile. ClientProfile profile = DefaultClientProfile.getProfile(); // Create a message sender object. final MessageSender sender; if ( mode.toLowerCase().startsWith( "async" ) ) { sender = new AsynchSender(); } else if ( mode.toLowerCase().startsWith( "sync" ) ) { sender = new SynchSender( timeout ); } else if ( mode.toLowerCase().startsWith( "notif" ) ) { sender = new NotifySender(); } else { System.err.println( usage ); return 1; } // Prepare to send the message. Message msg = new Message( mtype, paramMap ); // Register. HubConnection connection = profile.register(); if ( connection == null ) { System.err.println( "No hub is running" ); return 1; } connection.declareMetadata( meta ); // Assemble target list. String[] targetNames = (String[]) targetNameList.toArray( new String[ 0 ] ); targetIdList.addAll( Arrays.asList( namesToIds( connection, targetNames ) ) ); String[] targets = targetIdList.isEmpty() ? null : (String[]) targetIdList.toArray( new String[ 0 ] ); // Send the message, displaying the results on System.out. sender.showResults( connection, msg, targets, System.out ); // Tidy up and exit. connection.unregister(); return 0; } /** * MessageSender implementation which uses the Notify pattern. */ private static class NotifySender extends MessageSender { public Map getResponses( HubConnection connection, Message msg, String[] recipientIds ) throws IOException { final List recipientList; if ( recipientIds == null ) { recipientList = connection.notifyAll( msg ); if ( recipientList.size() == 0 ) { logger_.warning( "No clients subscribed to " + msg.getMType() ); } } else { for ( int ir = 0; ir < recipientIds.length; ir++ ) { connection.notify( recipientIds[ ir ], msg ); } recipientList = Arrays.asList( recipientIds ); } Map responseMap = new HashMap(); for ( Iterator it = recipientList.iterator(); it.hasNext(); ) { responseMap.put( it.next(), "" ); } return responseMap; } } /** * MessageSender implementation which uses the Synchronous Call/Response * pattern. */ private static class SynchSender extends MessageSender { private final int timeout_; /** * Constructor. * * @param timeout in seconds */ SynchSender( int timeout ) { timeout_ = timeout; } public Map getResponses( final HubConnection connection, final Message msg, String[] recIds ) throws IOException { final String[] recipientIds = recIds == null ? (String[]) connection.getSubscribedClients( msg.getMType() ) .keySet().toArray( new String[ 0 ] ) : recIds; if ( recipientIds.length == 0 ) { logger_.warning( "No clients subscribed to " + msg.getMType() ); return new HashMap(); } else { int nsend = recipientIds.length; logger_.log( nsend == 0 ? Level.WARNING : Level.INFO, "Waiting for " + nsend + " responses" ); } final BlockingMap map = new BlockingMap(); for ( int ir = 0; ir < recipientIds.length; ir++ ) { final String id = recipientIds[ ir ]; new Thread() { public void run() { Object result; try { result = connection.callAndWait( id, msg, timeout_ ); } catch ( Throwable e ) { result = e; } map.put( id, result ); if ( map.size() >= recipientIds.length ) { map.done(); } } }.start(); } return map; } } /** * MessageSender implementation which uses the Asynchronous Call/Response * pattern. */ private static class AsynchSender extends MessageSender { private int iseq_; public Map getResponses( HubConnection connection, Message msg, String[] recipientIds ) throws IOException { String msgTag = "tag-" + ++iseq_; Collector collector = new Collector(); // Sets the connection's callable client to a new object. // Since the Standard Profile doesn't say it's OK to do this // more than once, this means that this it is not really safe // to call getResponses more than once for this object. connection.setCallable( collector ); if ( recipientIds == null ) { Set recipientSet = connection.callAll( msgTag, msg ).keySet(); collector.setRecipients( recipientSet ); } else { Set recipientSet = new HashSet( Arrays.asList( recipientIds ) ); for ( Iterator it = recipientSet.iterator(); it.hasNext(); ) { String recipientId = (String) it.next(); connection.call( recipientId, msgTag, msg ); } collector.setRecipients( recipientSet ); } return collector.map_; } /** * CallableClient implementation which collects asynchronous message * responses. */ private static class Collector implements CallableClient { final BlockingMap map_; Collection recipients_; /** * Constructor. * * @param nExpected number of responses expected by this collector */ Collector() { map_ = new BlockingMap(); } /** * Notifies this object which clients it should expect a response * from. Must be called at some point, or the returned map's * iterator will block indefinitely. * * @param recipients set of client ids for expected responders */ public void setRecipients( Collection recipients ) { int nsend = recipients.size(); logger_.log( nsend == 0 ? Level.WARNING : Level.INFO, "Waiting for " + nsend + " responses" ); recipients_ = recipients; if ( map_.keySet().containsAll( recipients_ ) ) { map_.done(); } } public void receiveCall( String senderId, String msgId, Message msg ) { throw new UnsupportedOperationException(); } public void receiveNotification( String senderId, Message msg ) { throw new UnsupportedOperationException(); } public void receiveResponse( String responderId, String msgTag, Response response ) { map_.put( responderId, response ); if ( recipients_ != null && map_.keySet().containsAll( recipients_ ) ) { map_.done(); } } } } /** * Client implementation which may know about metadata. */ private static class MetaClient implements Client { private final String id_; private final Metadata meta_; /** * Constructor which attempts to acquire metadata from a given * hub connection. * * @param client id * @param connection hub connection */ public MetaClient( String id, HubConnection connection ) throws SampException { this( id, connection.getMetadata( id ) ); } /** * Constructor which uses supplied metadata. * * @param id client id * @param meta metadata (may be null) */ public MetaClient( String id, Metadata meta ) { id_ = id; meta_ = meta; } public String getId() { return id_; } public Metadata getMetadata() { return meta_; } public Subscriptions getSubscriptions() { return null; } public String toString() { StringBuffer sbuf = new StringBuffer(); sbuf.append( getId() ); String name = meta_ == null ? null : meta_.getName(); if ( name != null ) { sbuf.append( " (" ) .append( name ) .append( ')' ); } return sbuf.toString(); } } /** * Map implementation which dispenses its contents via an iterator * which will block until all the results are in. This makes it * suitable for use from other threads. */ private static class BlockingMap extends AbstractMap { private final BlockingSet entrySet_; /** * Constructor. */ BlockingMap() { entrySet_ = new BlockingSet(); } public Set entrySet() { return entrySet_; } public synchronized Object put( final Object key, final Object value ) { entrySet_.add( new Map.Entry() { public Object getKey() { return key; } public Object getValue() { return value; } public Object setValue( Object value ) { throw new UnsupportedOperationException(); } } ); return null; } /** * Indicates that no more entries will be added to this map. * Must be called by populator or entry set iterator will block * indefinitely. */ synchronized void done() { entrySet_.done(); } } /** * Set implementation which dispenses its contents via an iterator * which will block until all results are in. */ private static class BlockingSet extends AbstractSet { private final List list_; private boolean done_; /** * Constructor. */ BlockingSet() { list_ = Collections.synchronizedList( new ArrayList() ); } public boolean add( Object o ) { assert ! list_.contains( o ); synchronized ( list_ ) { list_.add( o ); list_.notifyAll(); } return true; } /** * Indicates that no more items will be added to this set. * Must be called by populator or iterator will block * indefinitely. */ public void done() { done_ = true; synchronized ( list_ ) { list_.notifyAll(); } } public int size() { return list_.size(); } public Iterator iterator() { return new Iterator() { int index_; public void remove() { throw new UnsupportedOperationException(); } public Object next() { return list_.get( index_++ ); } public boolean hasNext() { synchronized ( list_ ) { while ( index_ >= list_.size() && ! done_ ) { try { list_.wait(); } catch ( InterruptedException e ) { throw new RuntimeException( "Interrupted", e ); } } return index_ < list_.size(); } } }; } } } jsamp/src/java/org/astrogrid/samp/test/Tester.java0000664000175000017500000000321312730747754022034 0ustar sladensladenpackage org.astrogrid.samp.test; /** * No-frills test case superclass. * * @author Mark Taylor * @since 18 Jul 2008 */ public class Tester { /** * Fails a test. * * @throws TextException always */ public static void fail() throws TestException { throw new TestException( "Test failed" ); } /** * Tests an assertion. * * @param test asserted condition * @throws TestException if test is false */ public static void assertTrue( boolean test ) throws TestException { if ( ! test ) { throw new TestException( "Test failed" ); } } /** * Tests object equality. * * @param o1 object 1 * @param o2 object 2 * @throws TestException unless o1 and o2 * are both null or are equal in the sense of * {@link java.lang.Object#equals} */ public static void assertEquals( Object o1, Object o2 ) throws TestException { if ( o1 == null && o2 == null ) { } else if ( o1 == null || ! o1.equals( o2 ) ) { throw new TestException( "Test failed: " + o1 + " != " + o2 ); } } /** * Tests integer equality. * * @param i1 integer 1 * @param i2 integer 2 * @throws TestException iff i1 != i2 */ public static void assertEquals( int i1, int i2 ) throws TestException { if ( i1 != i2 ) { throw new TestException( "Test failed: " + i1 + " != " + i2 ); } } } jsamp/src/java/org/astrogrid/samp/test/CalcStorm.java0000664000175000017500000002537512730747754022472 0ustar sladensladenpackage org.astrogrid.samp.test; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Iterator; import java.util.List; import java.util.Random; import java.util.logging.Level; import java.util.logging.Logger; import javax.swing.JFrame; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.DefaultClientProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.gui.HubMonitor; /** * Runs a load of Calculator clients at once all sending messages to each other. * Suitable for load testing or benchmarking a hub. * * @author Mark Taylor * @since 22 Jul 2008 */ public class CalcStorm { private final ClientProfile profile_; private final Random random_; private final int nClient_; private final int nQuery_; private final Calculator.SendMode sendMode_; private static final Logger logger_ = Logger.getLogger( CalcStorm.class.getName() ); /** * Constructor. * * @param profile hub connection factory * @param random random number generator * @param nClient number of clients to run * @param nQuery number of messages each client will send * @param sendMode delivery pattern for messages */ public CalcStorm( ClientProfile profile, Random random, int nClient, int nQuery, Calculator.SendMode sendMode ) { profile_ = profile; random_ = random; nClient_ = nClient; nQuery_ = nQuery; sendMode_ = sendMode; } /** * Runs a lot of calculators at once all talking to each other. * * @throws TestException if any tests fail */ public void run() throws IOException { // Set up clients. final Calculator[] calcs = new Calculator[ nClient_ ]; final String[] ids = new String[ nClient_ ]; final Random[] randoms = new Random[ nClient_ ]; for ( int ic = 0; ic < nClient_; ic++ ) { HubConnection conn = profile_.register(); if ( conn == null ) { throw new IOException( "No hub is running" ); } randoms[ ic ] = new Random( random_.nextLong() ); ids[ ic ] = conn.getRegInfo().getSelfId(); calcs[ ic ] = new Calculator( conn, randoms[ ic ] ); } // Set up one thread per client to do the message sending. Thread[] calcThreads = new Thread[ nClient_ ]; final Throwable[] errors = new Throwable[ 1 ]; for ( int ic = 0; ic < nClient_; ic++ ) { final Calculator calc = calcs[ ic ]; final Random random = randoms[ ic ]; calcThreads[ ic ] = new Thread( "Calc" + ic ) { public void run() { try { for ( int iq = 0; iq < nQuery_ && errors[ 0 ] == null; iq++ ) { calc.sendMessage( ids[ random.nextInt( nClient_ ) ], sendMode_ ); } calc.flush(); } catch ( Throwable e ) { errors[ 0 ] = e; } } }; } // Start the threads running. for ( int ic = 0; ic < nClient_; ic++ ) { calcThreads[ ic ].start(); } // Wait for all the threads to finish. try { for ( int ic = 0; ic < nClient_; ic++ ) { calcThreads[ ic ].join(); } } catch ( InterruptedException e ) { throw new TestException( "Interrupted", e ); } // If we are using the notification delivery pattern, wait until // all the clients have received all the messages they are expecting. // In the case of call/response this is not necessary, since the // message sender threads will only complete their run() methods // when the responses have come back, which must mean that the // messages arrived at their recipients. if ( sendMode_ == Calculator.NOTIFY_MODE || sendMode_ == Calculator.RANDOM_MODE ) { for ( boolean done = false; ! done; ) { int totCalc = 0; for ( int ic = 0; ic < nClient_; ic++ ) { totCalc += calcs[ ic ].getReceiveCount(); } done = totCalc >= nClient_ * nQuery_; if ( ! done ) { Thread.yield(); } } } // Unregister the clients. for ( int ic = 0; ic < nClient_; ic++ ) { calcs[ ic ].getConnection().unregister(); } // If any errors occurred on the sending thread, rethrow one of them // here. if ( errors[ 0 ] != null ) { throw new TestException( "Error in calculator thread: " + errors[ 0 ].getMessage(), errors[ 0 ] ); } // Check that the number of messages sent and the number received // was what it should have been. int totCalc = 0; for ( int ic = 0; ic < nClient_; ic++ ) { Calculator calc = calcs[ ic ]; Tester.assertEquals( nQuery_, calc.getSendCount() ); totCalc += calc.getReceiveCount(); } Tester.assertEquals( totCalc, nClient_ * nQuery_ ); } /** * Does the work for the main method. * Use -help flag for documentation. * * @param args command-line arguments * @return 0 means success */ public static int runMain( String[] args ) throws IOException { // Set up usage message. String usage = new StringBuffer() .append( "\n Usage:" ) .append( "\n " ) .append( CalcStorm.class.getName() ) .append( "\n " ) .append( " [-help]" ) .append( " [-/+verbose]" ) .append( "\n " ) .append( " [-gui]" ) .append( " [-nclient ]" ) .append( " [-nquery ]" ) .append( "\n " ) .append( " [-mode sync|async|notify|random]" ) .append( "\n" ) .toString(); // Prepare default values for test. Random random = new Random( 2333333 ); int nClient = 20; int nQuery = 100; Calculator.SendMode sendMode = Calculator.RANDOM_MODE; int verbAdjust = 0; boolean gui = false; // Parse arguments, modifying test parameters as appropriate. List argList = new ArrayList( Arrays.asList( args ) ); try { for ( Iterator it = argList.iterator(); it.hasNext(); ) { String arg = (String) it.next(); if ( arg.startsWith( "-nc" ) && it.hasNext() ) { it.remove(); String snc = (String) it.next(); it.remove(); nClient = Integer.parseInt( snc ); } else if ( arg.startsWith( "-nq" ) && it.hasNext() ) { it.remove(); String snq = (String) it.next(); it.remove(); nQuery = Integer.parseInt( snq ); } else if ( arg.equals( "-mode" ) && it.hasNext() ) { it.remove(); String smode = (String) it.next(); it.remove(); final Calculator.SendMode sm; if ( smode.toLowerCase().startsWith( "sync" ) ) { sm = Calculator.SYNCH_MODE; } else if ( smode.toLowerCase().startsWith( "async" ) ) { sm = Calculator.ASYNCH_MODE; } else if ( smode.toLowerCase().startsWith( "notif" ) ) { sm = Calculator.NOTIFY_MODE; } else if ( smode.toLowerCase().startsWith( "rand" ) ) { sm = Calculator.RANDOM_MODE; } else { System.err.println( usage ); return 1; } sendMode = sm; } else if ( arg.equals( "-gui" ) ) { it.remove(); gui = true; } else if ( arg.equals( "-nogui" ) ) { it.remove(); gui = false; } else if ( arg.startsWith( "-v" ) ) { it.remove(); verbAdjust--; } else if ( arg.startsWith( "+v" ) ) { it.remove(); verbAdjust++; } else if ( arg.startsWith( "-h" ) ) { System.out.println( usage ); return 0; } else { System.err.println( usage ); return 1; } } } catch ( RuntimeException e ) { System.err.println( usage ); return 1; } if ( ! argList.isEmpty() ) { System.err.println( usage ); return 1; } // Adjust logging in accordance with verboseness flags. int logLevel = Level.WARNING.intValue() + 100 * verbAdjust; Logger.getLogger( "org.astrogrid.samp" ) .setLevel( Level.parse( Integer.toString( logLevel ) ) ); // Prepare profile. ClientProfile profile = DefaultClientProfile.getProfile(); // Set up GUI monitor if required. JFrame frame; if ( gui ) { frame = new JFrame( "CalcStorm Monitor" ); frame.getContentPane().add( new HubMonitor( profile, true, 1 ) ); frame.pack(); frame.setVisible( true ); } else { frame = null; } // Run the test. long start = System.currentTimeMillis(); new CalcStorm( profile, random, nClient, nQuery, sendMode ).run(); long time = System.currentTimeMillis() - start; System.out.println( "Elapsed time: " + time + " ms" + " (" + (int) ( time * 1000. / ( nClient * nQuery ) ) + " us per message)" ); // Tidy up and return. if ( frame != null ) { frame.dispose(); } return 0; } /** * Main method. Use -help flag. */ public static void main( String[] args ) throws IOException { int status = runMain( args ); if ( status != 0 ) { System.exit( status ); } } } jsamp/src/java/org/astrogrid/samp/test/Snooper.java0000664000175000017500000002531412730747754022221 0ustar sladensladenpackage org.astrogrid.samp.test; import java.io.IOException; import java.io.OutputStream; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; import org.astrogrid.samp.Client; import org.astrogrid.samp.ErrInfo; import org.astrogrid.samp.Message; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.Response; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.DefaultClientProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.HubConnector; import org.astrogrid.samp.client.MessageHandler; import org.astrogrid.samp.httpd.UtilServer; /** * Subscribes to SAMP messages and logs any received to an output stream. * The only responses to messages have samp.status=samp.warning. * * @author Mark Taylor * @since 4 Sep 2008 */ public class Snooper { private final OutputStream out_; private final Map clientMap_; private static final byte[] newline_; static { byte[] nl; try { nl = System.getProperty( "line.separator", "\n" ) .getBytes( "UTF-8" ); } catch ( Exception e ) { nl = new byte[] { (byte) '\n' }; } newline_ = nl; } private static final Logger logger_ = Logger.getLogger( Snooper.class.getName() ); /** * Constructor using default metadata. * * @param profile profile * @param subs subscriptions defining which messages are received * and logged * @param out destination stream for logging info * @param autoSec number of seconds between auto connection attempts */ public Snooper( ClientProfile profile, Subscriptions subs, OutputStream out, int autoSec ) { this( profile, subs, createDefaultMetadata(), out, autoSec ); } /** * Constructor using custom metadata. * * @param profile profile * @param subs subscriptions defining which messages are received * and logged * @param meta client metadata * @param out destination stream for logging info * @param autoSec number of seconds between auto connection attempts */ public Snooper( ClientProfile profile, final Subscriptions subs, Metadata meta, OutputStream out, int autoSec ) { HubConnector connector = new HubConnector( profile ); connector.declareMetadata( meta ); out_ = out; // Prepare all-purpose response to logged messages. final Response response = new Response(); response.setStatus( Response.WARNING_STATUS ); response.setResult( new HashMap() ); response.setErrInfo( new ErrInfo( "Message logged, not acted on" ) ); // Add a handler which will handle the subscribed messages. connector.addMessageHandler( new MessageHandler() { public Map getSubscriptions() { return subs; } public void receiveNotification( HubConnection connection, String senderId, Message msg ) throws IOException { log( senderId, msg, null ); } public void receiveCall( HubConnection connection, String senderId, String msgId, Message msg ) throws IOException { log( senderId, msg, msgId ); connection.reply( msgId, response ); } } ); connector.declareSubscriptions( connector.computeSubscriptions() ); clientMap_ = connector.getClientMap(); // Connect and ready to log. connector.setActive( true ); connector.setAutoconnect( autoSec ); } /** * Logs a received message. * * @param senderId message sender public ID * @param msg message object * @param msgId message ID for call/response type messages * (null for notify type messages) */ private void log( String senderId, Message msg, String msgId ) throws IOException { StringBuffer sbuf = new StringBuffer(); sbuf.append( senderId ); Client client = (Client) clientMap_.get( senderId ); if ( client != null ) { Metadata meta = client.getMetadata(); if ( meta != null ) { String name = meta.getName(); if ( name != null ) { sbuf.append( " (" ) .append( name ) .append( ")" ); } } } sbuf.append( " --- " ); if ( msgId == null ) { sbuf.append( "notify" ); } else { sbuf.append( "call" ) .append( " (" ) .append( msgId ) .append( ")" ); } out_.write( newline_ ); out_.write( sbuf.toString().getBytes( "UTF-8" ) ); out_.write( newline_ ); out_.write( SampUtils.formatObject( msg, 3 ).getBytes( "UTF-8" ) ); out_.write( newline_ ); } /** * Returns the default metadata for the Snooper client. * * @return meta */ public static Metadata createDefaultMetadata() { Metadata meta = new Metadata(); meta.setName( "Snooper" ); meta.setDescriptionText( "Listens in to messages" + " for logging purposes" ); try { meta.setIconUrl( UtilServer.getInstance() .exportResource( "/org/astrogrid/samp/images/" + "ears.png" ) .toString() ); } catch ( IOException e ) { logger_.warning( "Can't export icon" ); } meta.put( "Author", "Mark Taylor" ); return meta; } /** * Main method. Runs a snooper. */ public static void main( String[] args ) throws IOException { int status = runMain( args ); if ( status != 0 ) { System.exit( status ); } } /** * Does the work for the main method. * Use -help flag. */ public static int runMain( String[] args ) throws IOException { String usage = new StringBuffer() .append( "\n Usage:" ) .append( "\n " ) .append( Snooper.class.getName() ) .append( "\n " ) .append( " [-help]" ) .append( " [-/+verbose]" ) .append( "\n " ) .append( " [-clientname ]" ) .append( " [-clientmeta ]" ) .append( "\n " ) .append( " [-mtype ]" ) .append( " [-subs ]" ) .append( "\n" ) .toString(); List argList = new ArrayList( Arrays.asList( args ) ); int verbAdjust = 0; Subscriptions subs = new Subscriptions(); Metadata meta = new Metadata(); for ( Iterator it = argList.iterator(); it.hasNext(); ) { String arg = (String) it.next(); if ( arg.startsWith( "-mtype" ) && it.hasNext() ) { it.remove(); String mpat = (String) it.next(); it.remove(); subs.addMType( mpat ); } else if ( arg.equals( "-subs" ) && it.hasNext() ) { it.remove(); String mpat = (String) it.next(); it.remove(); String substr; if ( it.hasNext() ) { substr = (String) it.next(); it.remove(); } else { System.err.println( usage ); return 1; } Object subsInfo = SampUtils.fromJson( substr ); subs.put( mpat, subsInfo ); } else if ( arg.equals( "-clientname" ) && it.hasNext() ) { it.remove(); meta.setName( (String) it.next() ); it.remove(); } else if ( arg.equals( "-clientmeta" ) && it.hasNext() ) { it.remove(); String mName = (String) it.next(); it.remove(); String mValue; if ( it.hasNext() ) { mValue = (String) it.next(); it.remove(); } else { System.err.println( usage ); return 1; } Object mVal = SampUtils.parseValue( mValue ); if ( mVal == null ) { meta.remove( mName ); } else { meta.put( mName, mVal ); } } else if ( arg.startsWith( "-v" ) ) { it.remove(); verbAdjust--; } else if ( arg.startsWith( "+v" ) ) { it.remove(); verbAdjust++; } else if ( arg.startsWith( "-h" ) ) { it.remove(); System.out.println( usage ); return 0; } else { it.remove(); System.err.println( usage ); return 1; } } assert argList.isEmpty(); // Adjust logging in accordance with verboseness flags. int logLevel = Level.WARNING.intValue() + 100 * verbAdjust; Logger.getLogger( "org.astrogrid.samp" ) .setLevel( Level.parse( Integer.toString( logLevel ) ) ); // Combine custom and default metadata. Metadata m2 = createDefaultMetadata(); m2.putAll( meta ); meta = m2; // Set default subscriptions (everything) if none has been specified // explicitly. if ( subs.isEmpty() ) { subs.addMType( "*" ); } // Get profile. ClientProfile profile = DefaultClientProfile.getProfile(); // Start and run snooper. new Snooper( profile, subs, meta, System.out, 2 ); // Wait indefinitely. Object lock = new String( "Forever" ); synchronized( lock ) { try { lock.wait(); } catch ( InterruptedException e ) { } } return 0; } } jsamp/src/java/org/astrogrid/samp/test/HubTester.java0000664000175000017500000013302112730747754022474 0ustar sladensladenpackage org.astrogrid.samp.test; import java.io.IOException; import java.net.URL; import java.util.Arrays; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Random; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import javax.swing.JFrame; import org.astrogrid.samp.Client; import org.astrogrid.samp.ErrInfo; import org.astrogrid.samp.Message; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.RegInfo; import org.astrogrid.samp.Response; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.ClientProfile; import org.astrogrid.samp.client.DefaultClientProfile; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.gui.HubMonitor; import org.astrogrid.samp.xmlrpc.LockInfo; import org.astrogrid.samp.xmlrpc.StandardClientProfile; /** * Tester for a running hub. * Attempts to test as much of the SAMP standard as possible for an existing * hub implementation. * * @author Mark Taylor * @since 18 Jul 2008 */ public class HubTester extends Tester { private final ClientProfile profile_; private final String hubId_; private final Client[] ignoreClients_; private final Set selfIds_; private final Set privateKeys_; private final ClientWatcher clientWatcher_; private final Random random_ = new Random( 233333L ); private static final String WAITMILLIS_KEY = "test.wait"; private static final String MSGIDQUERY_KEY = "test.msgid"; private static final String ECHO_MTYPE = "test.echo"; private static final String PING_MTYPE = "samp.app.ping"; private static final String FAIL_MTYPE = "test.fail"; private static final String REGISTER_MTYPE = "samp.hub.event.register"; private static final String UNREGISTER_MTYPE = "samp.hub.event.unregister"; private static final String METADATA_MTYPE = "samp.hub.event.metadata"; private static final String SUBSCRIPTIONS_MTYPE = "samp.hub.event.subscriptions"; private static final String ERROR_KEY = "test.error"; private static final char[] ALPHA_CHARS = createAlphaCharacters(); private static final char[] GENERAL_CHARS = createGeneralCharacters(); private static Logger logger_ = Logger.getLogger( HubTester.class.getName() ); /** * Constructor. * * @param profile hub discovery object */ public HubTester( ClientProfile profile ) throws IOException { profile_ = profile; selfIds_ = new HashSet(); privateKeys_ = new HashSet(); // Set up basic information about the hub for use by other methods. // Perform some checks at the same time. // First register a probe client to make some basic queries about the // current hub state. HubConnection conn = profile_.register(); if ( conn == null ) { throw new IOException( "No hub is running" ); } conn.ping(); // Set up monitor to receive hub event messages. clientWatcher_ = new ClientWatcher( conn ); conn.setCallable( clientWatcher_ ); conn.declareSubscriptions( clientWatcher_.getSubscriptions() ); conn.declareMetadata( clientWatcher_.getMetadata() ); // Acquire, check and store information about the hub. RegInfo regInfo = conn.getRegInfo(); regInfo.check(); hubId_ = regInfo.getHubId(); // Keep a record of all clients which have registered on behalf of // this class. selfIds_.add( regInfo.getSelfId() ); privateKeys_.add( regInfo.getPrivateKey() ); // Get list of registered clients. String[] clientIds = conn.getRegisteredClients(); // Check that the hub's ID does appear in the list. assertTrue( Arrays.asList( clientIds ).contains( regInfo.getHubId() ) ); // Check that a client's own ID does not appear in the list. assertTrue( ! Arrays.asList( clientIds ) .contains( regInfo.getSelfId() ) ); // But prepare a list which contains the client's ID as well. String[] clientIds1 = new String[ clientIds.length + 1 ]; System.arraycopy( clientIds, 0, clientIds1, 0, clientIds.length ); clientIds1[ clientIds.length ] = regInfo.getSelfId(); clientIds = clientIds1; // Check that the metadata and subscriptions of all the existing // clients is legal. int nc = clientIds.length; Client[] clients = new Client[ nc ]; for ( int ic = 0; ic < nc; ic++ ) { final String id = clientIds[ ic ]; final Metadata meta = conn.getMetadata( id ); meta.check(); final Subscriptions subs = conn.getSubscriptions( id ); subs.check(); clients[ ic ] = new Client() { public String getId() { return id; } public Metadata getMetadata() { return meta; } public Subscriptions getSubscriptions() { return subs; } }; } ignoreClients_ = clients; } /** * Registers with the hub, performing various checks. * * @return new hub connection representing a newly-registered client */ private HubConnection register() throws SampException { HubConnection conn = profile_.register(); RegInfo regInfo = conn.getRegInfo(); regInfo.check(); String selfId = regInfo.getSelfId(); String privateKey = regInfo.getPrivateKey(); // Check that the hub ID is the same for all clients. assertEquals( hubId_, regInfo.getHubId() ); // Check that client IDs are not being reused. assertTrue( ! selfIds_.contains( selfId ) ); selfIds_.add( selfId ); // Check that private keys are not being reused. assertTrue( ! privateKeys_.contains( privateKey ) ); privateKeys_.add( privateKey ); // Check that getRegisteredClients() excludes the caller's ID. assertTrue( ! Arrays.asList( conn.getRegisteredClients() ) .contains( selfId ) ); // Check that metadata and subscriptions for this application are // empty maps (since we have not yet declared their values). assertEquals( new HashMap(), conn.getMetadata( selfId ) ); assertEquals( new HashMap(), conn.getSubscriptions( selfId ) ); return conn; } /** * Perform a wide variety of tests on a running hub. */ public void run() throws IOException { // Profile-specific tests. if ( profile_ == StandardClientProfile.getInstance() ) { testStandardLockfile(); } if ( profile_ instanceof StandardClientProfile ) { testLockInfo( ((StandardClientProfile) profile_).getLockInfo() ); } // General tests. testClients(); testStress(); } /** * Tests the content of the SAMP Standard Profile lockfile. * Does not currently test the permissions on it - that would be nice * but is hard to do from java since it's rather platform-specific. */ private void testStandardLockfile() throws IOException { LockInfo lockInfo = StandardClientProfile.getInstance().getLockInfo(); if ( lockInfo == null ) { throw new TestException( "No lockfile (no hub)" ); } } /** * Does tests on a LockInfo object used by the profile. * This is specific to a Standard-like (though not necessarily Standard) * profile, and will simply be skipped for non-standard profiles. * * @param lockInfo lock info object describing a running hub */ private void testLockInfo( LockInfo lockInfo ) throws IOException { if ( lockInfo == null ) { throw new TestException( "No LockInfo (no hub)" ); } lockInfo.check(); String secret = lockInfo.getSecret(); URL hubUrl = lockInfo.getXmlrpcUrl(); TestXmlrpcClient xclient = new TestXmlrpcClient( hubUrl ); xclient.checkSuccessCall( "samp.hub.ping", new ArrayList() ); xclient.checkFailureCall( "samp.hub.register", Collections.singletonList( secret + "-NOT!" ) ); xclient.checkFailureCall( "samp.hub.not-a-method", Collections.singletonList( secret ) ); } /** * Performs a wide variety of tests on a running hub from a limited * number of clients. */ private void testClients() throws IOException { // Register client0, set metadata and subscriptions, and unregister. HubConnection c0 = register(); String id0 = c0.getRegInfo().getSelfId(); Metadata meta0 = new Metadata(); meta0.setName( "Shorty" ); meta0.setDescriptionText( "Short-lived test client" ); c0.declareMetadata( meta0 ); TestCallableClient callable0 = new TestCallableClient( c0 ); c0.setCallable( callable0 ); Subscriptions subs0 = new Subscriptions(); subs0.put( ECHO_MTYPE, new HashMap() ); subs0.check(); c0.declareSubscriptions( subs0 ); c0.unregister(); // Register client 1 with the hub and declare some metadata. HubConnection c1 = register(); String id1 = c1.getRegInfo().getSelfId(); Metadata meta1 = new Metadata(); meta1.setName( "Test1" ); meta1.setDescriptionText( "HubTester client application" ); meta1.put( "test.drink", "cider" ); c1.declareMetadata( meta1 ); // Check the list of clients known to the hub. assertTestClients( c1, new String[ 0 ] ); // Register client 2 with the hub and declare some metadata. HubConnection c2 = register(); String id2 = c2.getRegInfo().getSelfId(); Metadata meta2 = new Metadata( meta1 ); meta2.put( "test.drink", "ribena" ); c2.declareMetadata( meta2 ); // Check the list of clients known to the hub. assertTestClients( c2, new String[] { id1 } ); assertTestClients( c1, new String[] { id2 } ); // Check retrieved metadata matches declared metadata. assertEquals( meta1, c1.getMetadata( id1 ) ); assertEquals( meta1, c2.getMetadata( id1 ) ); assertEquals( meta2, c1.getMetadata( id2 ) ); assertEquals( meta2, c2.getMetadata( id2 ) ); assertEquals( "cider", c2.getMetadata( id1 ).get( "test.drink" ) ); // Redeclare metadata and check that the update has been noted. meta1.put( "test.drink", "scrumpy" ); c1.declareMetadata( meta1 ); assertEquals( meta1, c1.getMetadata( id1 ) ); assertEquals( meta1, c2.getMetadata( id1 ) ); assertEquals( "scrumpy", c2.getMetadata( id1 ).get( "test.drink" ) ); // Declare subscriptions and check that retrieved subscriptions match // declared ones. TestCallableClient callable1 = new TestCallableClient( c1 ); c1.setCallable( callable1 ); Subscriptions subs1 = new Subscriptions(); subs1.put( "test.dummy.1", new HashMap() ); subs1.put( "test.dummy.2", new HashMap() ); c1.declareSubscriptions( subs1 ); assertEquals( subs1, c1.getSubscriptions( id1 ) ); assertEquals( subs1, c2.getSubscriptions( id1 ) ); Map d3atts = new HashMap(); d3atts.put( "size", "big" ); d3atts.put( "colour", "blue" ); subs1.put( "test.dummy.3", d3atts ); c1.declareSubscriptions( subs1 ); assertEquals( subs1, c2.getSubscriptions( id1 ) ); assertEquals( new HashMap(), ((Map) c2.getSubscriptions( id1 ).get( "test.dummy.1")) ); assertEquals( "big", ((Map) c2.getSubscriptions( id1 ).get( "test.dummy.3" )) .get( "size" ) ); c1.declareSubscriptions( TestCallableClient.SUBS ); TestCallableClient callable2 = new TestCallableClient( c2 ); c2.setCallable( callable2 ); c2.declareSubscriptions( TestCallableClient.SUBS ); // Try to acquire information about non-existent clients. try { c1.getMetadata( "Sir Not-Appearing-in-this-Hub" ); fail(); } catch ( SampException e ) { } try { c1.getSubscriptions( "Sir Not-Appearing-in-this-Hub" ); fail(); } catch ( SampException e ) { } // New client which can re-use the same tag (not advisable, but legal). HubConnection c4 = register(); TestCallableClient callable4 = new TestCallableClient( c4 ); callable4.setAllowTagReuse( true ); c4.setCallable( callable4 ); // Send some concurrent ECHO messages via both notify and call. int necho = 5; Map[] echoParams = new Map[ necho ]; String[] msgIds2 = new String[ necho ]; String[] msgIds4 = new String[ necho ]; for ( int i = 0; i < necho; i++ ) { Message msg = new Message( ECHO_MTYPE ); Object val1 = createRandomObject( 2, false ); Object val2 = createRandomObject( 4, false ); Object val3 = new String( GENERAL_CHARS ); msg.put( WAITMILLIS_KEY, SampUtils.encodeInt( 200 + 100 * i ) ); msg.put( MSGIDQUERY_KEY, SampUtils.encodeBoolean( true ) ); msg.addParam( "val1", val1 ); msg.addParam( "val2", val2 ); msg.addParam( "val3", val3 ); echoParams[ i ] = msg.getParams(); c2.notify( id1, msg ); msgIds2[ i ] = callable2.call( id1, "tag" + i, msg ); msgIds4[ i ] = callable4.call( id1, "sametag", msg ); } // The call messages should complete quickly, so all the sends // are expected to complete before any of the replies are received // (there is a deliberate delay at the receiver end in all cases). // This isn't required, but hubs SHOULD work this way. // Warn if it looks like not. if ( callable2.getReplyCount() > necho / 2 ) { logger_.warning( "Looks like hub call()/notify() methods " + "not completing quickly" + " (" + callable2.getReplyCount() + "/" + necho + ")" ); } // Spin-wait until all the replies are in. while ( callable2.getReplyCount() < necho || callable4.getReplyCount() < necho ) delay( 100 ); assertEquals( necho, callable2.getReplyCount() ); assertEquals( necho, callable4.getReplyCount() ); // Check that the replies are as expected (returned samp.result has // same content as sent samp.params). for ( int i = 0; i < necho; i++ ) { assertEquals( necho - i, callable2.getReplyCount() ); Response r2 = callable2.getReply( id1, "tag" + i ); assertEquals( Response.OK_STATUS, r2.getStatus() ); assertEquals( echoParams[ i ], r2.getResult() ); assertEquals( msgIds2[ i ], r2.get( MSGIDQUERY_KEY ) ); assertEquals( necho - i, callable4.getReplyCount() ); Response r4 = callable4.getReply( id1, "sametag" ); assertEquals( Response.OK_STATUS, r4.getStatus() ); } // Check that no more replies have arrived apart from the ones we // were expecting. assertEquals( 0, callable2.getReplyCount() ); assertEquals( 0, callable4.getReplyCount() ); // c4 no longer needed. c4.unregister(); // Send echo messages synchronously (using callAndWait). // These have deliberate delays at the receiver end, but there is // no timeout. for ( int i = 0; i < necho; i++ ) { Message msg = new Message( ECHO_MTYPE ); Object val1 = createRandomObject( 2, false ); Object val2 = createRandomObject( 4, false ); Object val3 = new String( GENERAL_CHARS ); msg.put( WAITMILLIS_KEY, SampUtils.encodeInt( 100 * i ) ); msg.addParam( "val1", val1 ); msg.addParam( "val2", val2 ); msg.addParam( "val3", val3 ); echoParams[ i ] = msg.getParams(); Response syncR = c2.callAndWait( id1, msg, 0 ); assertEquals( echoParams[ i ], syncR.getResult() ); } // Send some longer messages synchronously. { for ( int ie = 0; ie < 5; ie++ ) { int num = (int) Math.pow( 10, ie ); List list = new ArrayList( num ); for ( int in = 0; in < num; in++ ) { list.add( SampUtils.encodeInt( in + 1 ) ); } Message msg = new Message( ECHO_MTYPE ); msg.addParam( "list", list ); msg.check(); Response response = c2.callAndWait( id1, msg, 0 ); response.check(); assertEquals( Response.OK_STATUS, response.getStatus() ); assertEquals( list, response.getResult().get( "list" ) ); } } // Send an echo message synchronously, with a timeout which is shorter // than the delay which the receiver will introduce. So the hub // SHOULD time this attempt out before the response is received. // However, the standard does not REQUIRE this, so just warn in // case of no time out. { Message msg = new Message( ECHO_MTYPE ); msg.addParam( "text", "copy" ); int delay = 10000; msg.put( WAITMILLIS_KEY, SampUtils.encodeInt( delay ) ); long start = System.currentTimeMillis(); try { c2.callAndWait( id1, msg, 1 ); assert System.currentTimeMillis() - start >= delay; logger_.warning( "callAndWait() did not timeout as requested" ); } catch ( SampException e ) { // timeout exception } } // Register a new client. HubConnection c3 = register(); TestCallableClient callable3 = new TestCallableClient( c3 ); c3.setCallable( callable3 ); // Test callAll and notifyAll. { // Check that getSubscribedClients returns the right list. // Note that it must not include the caller. Set recipientSet = c3.getSubscribedClients( ECHO_MTYPE ).keySet(); try { assertEquals( new HashSet( Arrays.asList( new String[] { id1, id2 } ) ), recipientSet ); } catch ( TestException e ) { throw new TestException( "You may need to shut down other " + "SAMP clients first", e ); } // Send an echo message from client 3 using callAll and notifyAll. // Should go to clients 1 and 2. Message msg = new Message( ECHO_MTYPE ); Object val4 = createRandomObject( 4, false ); msg.addParam( "val4", val4 ); msg.put( WAITMILLIS_KEY, SampUtils.encodeInt( 400 ) ); List notifyList = c3.notifyAll( msg ); assertEquals( recipientSet, new HashSet( notifyList ) ); String tag = "tag99"; msg.put( MSGIDQUERY_KEY, "1" ); Map callMap = callable3.callAll( tag, msg ); if ( callable3.getReplyCount() != 0 ) { logger_.warning( "Looks like hub call()/notify() methods " + "not completing quickly" ); } // Retrieve and check the results from the callAll for each // recipient client. for ( Iterator it = recipientSet.iterator(); it.hasNext(); ) { String rid = (String) it.next(); Response response = callable3.waitForReply( rid, tag ); assertEquals( Response.OK_STATUS, response.getStatus() ); assertEquals( val4, response.getResult().get( "val4" ) ); assertEquals( (String) callMap.get( rid ), response.get( MSGIDQUERY_KEY ) ); } // Check there are no replies beyond the ones we expect. assertEquals( 0, callable3.getReplyCount() ); delay( 500 ); // .. even after a while. assertEquals( 0, callable3.getReplyCount() ); } // Test that notify- and call-type messages are being received by // their intended recipients. { Message pingMsg = new Message( PING_MTYPE ); pingMsg.put( WAITMILLIS_KEY, SampUtils.encodeInt( 100 ) ); int pingsCount = 50; Set recipients = c3.getSubscribedClients( PING_MTYPE ).keySet(); assertTrue( recipients.contains( id1 ) ); assertTrue( recipients.contains( id2 ) ); c3.declareSubscriptions( TestCallableClient.SUBS ); assertEquals( recipients, c3.getSubscribedClients( PING_MTYPE ).keySet() ); // Send a load of messages concurrently using various // asynchronous methods. for ( int i = 0; i < pingsCount; i++ ) { c3.notify( id1, pingMsg ); callable3.call( id1, "abc1-" + i, pingMsg ); List notifyList = c3.notifyAll( pingMsg ); Map callMap = callable3.callAll( "abc2-" + i, pingMsg ); assertEquals( recipients, new HashSet( notifyList ) ); assertEquals( recipients, callMap.keySet() ); } // Spin-wait until all the clients have received all the messages // we have sent. int np1 = pingsCount * 4; int np2 = pingsCount * 2; int np3 = 0; int nr3 = pingsCount * ( 1 + recipients.size() ); while ( callable1.pingCount_ < np1 || callable2.pingCount_ < np2 || callable3.pingCount_ < np3 || callable3.getReplyCount() < nr3 ) delay( 100 ); // And then wait a bit to see if any more come in (hopefully not). delay( 400 ); // Check that the number of messages received is exactly as // expected. assertEquals( np1, callable1.pingCount_ ); assertEquals( np2, callable2.pingCount_ ); assertEquals( np3, callable3.pingCount_ ); assertEquals( nr3, callable3.getReplyCount() ); // Redeclare client 3's subscriptions to the effect that it // will not receive any messages. c3.declareSubscriptions( new Subscriptions() ); } // Test that error data encoded in responses works as it should. { Message failMsg = new Message( FAIL_MTYPE ); Map error = new HashMap(); error.put( "samp.errortxt", "failure" ); error.put( "samp.code", "999" ); error.put( "do.what", "do.that" ); failMsg.addParam( ERROR_KEY, error ); Response reply = c3.callAndWait( id2, failMsg, 0 ); ErrInfo errInfo = reply.getErrInfo(); errInfo.check(); assertEquals( Response.ERROR_STATUS, reply.getStatus() ); assertEquals( "failure", errInfo.getErrortxt() ); assertEquals( "999", errInfo.getCode() ); assertEquals( "do.that", errInfo.get( "do.what" ) ); } // Run tests on MTypes which have not been subscribed to. { String dummyMtype = "not.an.mtype"; Message dummyMsg = new Message( dummyMtype ); // Check the hub does not report clients are subscribed to MTypes // they are not subscribed to. Set subscribed = c3.getSubscribedClients( dummyMtype ).keySet(); assertTrue( ! subscribed.contains( id1 ) ); assertTrue( ! subscribed.contains( id2 ) ); // Send a message using notify to an unsubscribed recipient. // This should result in an error. try { c3.notify( id1, dummyMsg ); fail(); } catch ( SampException e ) { } // Send a message using call to an unsubscribed recipient. // This should result in an error. try { c3.call( id1, "xxx", dummyMsg ); fail(); } catch ( SampException e ) { } // Send a message using callAndWait to an unsubscribed recipient. // This should result in an error. try { c3.callAndWait( id1, dummyMsg, 0 ); fail(); } catch ( SampException e ) { } // Send message using notifyAll and callAll to which nobody is // subscribed. Nobody will receive this, but it is not an error. List notifyList = c3.notifyAll( dummyMsg ); assertEquals( 0, notifyList.size() ); Map callMap = c3.callAll( "yyy", dummyMsg ); assertEquals( 0, callMap.size() ); } // Check that hub event messages arrived concerning client 0 which // we registered and unregistered earlier. Do it here to give // messages enough time to have arrived; SAMP offers no guarantees // of delivery sequence, but if they haven't showed up yet it's // very likely that they never will. Throwable cwError = clientWatcher_.getError(); if ( cwError != null ) { throw new TestException( "Error encountered during hub event " + "processing", cwError ); } WatchedClient client0 = clientWatcher_.getClient( id0 ); assertTrue( client0 != null ); assertTrue( client0.reg_ ); assertTrue( client0.unreg_ ); assertEquals( meta0, client0.meta_ ); assertEquals( subs0, client0.subs_ ); // Check that the client watcher has received hub event messages // concerning itself as well. String cwId = clientWatcher_.getConnection().getRegInfo().getSelfId(); WatchedClient cwClient = clientWatcher_.getClient( cwId ); assertTrue( cwClient != null ); assertTrue( ! cwClient.unreg_ ); assertEquals( clientWatcher_.getMetadata(), cwClient.meta_ ); assertEquals( clientWatcher_.getSubscriptions(), cwClient.subs_ ); // Tidy up. c3.unregister(); assertTestClients( c1, new String[] { id2, } ); assertTestClients( c2, new String[] { id1, } ); c1.unregister(); c2.unregister(); } /** * Runs a lot of clients throwing a lot of messages at each other * simultaneously. */ private void testStress() throws IOException { ClientProfile profile = new ClientProfile() { public boolean isHubRunning() { return HubTester.this.profile_.isHubRunning(); } public HubConnection register() throws SampException { return HubTester.this.register(); } }; new CalcStorm( profile, random_, 10, 20, Calculator.RANDOM_MODE ) .run(); } /** * Assert that the given list of registered clients has a certain content. * * @param conn connection from which to call getRegisteredClients * @param otherIds array of client public IDs that getRegisteredClients * should return - will not contain ID associated with * conn itself */ private void assertTestClients( HubConnection conn, String[] otherIds ) throws IOException { // Call getRegisteredClients. Set knownOtherIds = new HashSet( Arrays.asList( conn.getRegisteredClients() ) ); // Remove from the list any clients which were already registered // before this test instance started up. for ( int ic = 0; ic < ignoreClients_.length; ic++ ) { String id = ignoreClients_[ ic ].getId(); knownOtherIds.remove( ignoreClients_[ ic ].getId() ); } // Assert that the (unordered) set retrieved is the same as that // asked about. assertEquals( knownOtherIds, new HashSet( Arrays.asList( otherIds ) ) ); } /** * Generates an object with random content for transmission using SAMP. * This may be a structure containing strings, lists and maps with * any legal values as defined by the SAMP data encoding rules. * * @param level maximum level of nesting (how deeply lists/maps * may appear within other lists/maps) * @param ugly if true, any legal SAMP content will be used; * if false, the returned object should be reasonably * human-readable if printed (toString) * @return random SAMP object */ public Object createRandomObject( int level, boolean ugly ) { if ( level == 0 ) { return createRandomString( ugly ); } int type = random_.nextInt( 2 ); if ( type == 0 ) { int nel = random_.nextInt( ugly ? 23 : 3 ); List list = new ArrayList( nel ); for ( int i = 0; i < nel; i++ ) { list.add( createRandomObject( level - 1, ugly ) ); } SampUtils.checkList( list ); return list; } else if ( type == 1 ) { int nent = random_.nextInt( ugly ? 23 : 3 ); Map map = new HashMap( nent ); for ( int i = 0; i < nent; i++ ) { map.put( createRandomString( ugly ), createRandomObject( level - 1, ugly ) ); } SampUtils.checkMap( map ); return map; } else { throw new AssertionError(); } } /** * Creates a new random string for transmission using SAMP. * This may have any legal content according to the SAMP data encoding * rules. * * @param ugly if true, any legal SAMP content will be used; * if false, the returned object should be reasonably * human-readable if printed (toString) */ public String createRandomString( boolean ugly ) { int nchar = random_.nextInt( ugly ? 99 : 4 ); StringBuffer sbuf = new StringBuffer( nchar ); char[] chrs = ugly ? GENERAL_CHARS : ALPHA_CHARS; for ( int i = 0; i < nchar; i++ ) { sbuf.append( chrs[ random_.nextInt( chrs.length ) ] ); } String str = sbuf.toString(); SampUtils.checkString( str ); return str; } /** * Waits for a given number of milliseconds. * * @param millis number of milliseconds */ private static void delay( int millis ) { Object lock = new Object(); try { synchronized ( lock ) { lock.wait( millis ); } } catch ( InterruptedException e ) { throw new RuntimeException( "Interrupted", e ); } } /** * Returns a character array containing each distinct alphanumeric * character. * * @return array of alphanumeric characters */ private static char[] createAlphaCharacters() { StringBuffer sbuf = new StringBuffer(); for ( char c = 'A'; c <= 'Z'; c++ ) { sbuf.append( c ); } for ( char c = '0'; c <= '9'; c++ ) { sbuf.append( c ); } return sbuf.toString().toCharArray(); } /** * Returns a character array containing every character which is legal * for inclusion in a SAMP string. * * @return array of string characters */ private static char[] createGeneralCharacters() { StringBuffer sbuf = new StringBuffer(); sbuf.append( (char) 0x09 ); sbuf.append( (char) 0x0a ); // Character 0x0d is problematic. Although it is permissible to // transmit this in an XML document, it can get transformed to // 0x0a or (if adjacent to an existing 0x0a) elided. // The correct thing to do probably would be to note in the standard // that all bets are off when transmitting line end characters - // but sending a line-end will probably end up as a line-end. // However I can't be bothered to start up a new thread about this // on the apps-samp list, so for the purposes of this test just // avoid sending it. // sbuf.append( (char) 0x0d ); for ( char c = 0x20; c <= 0x7f; c++ ) { sbuf.append( c ); } return sbuf.toString().toCharArray(); } /** * Main method. Tests a hub which is currently running. */ public static void main( String[] args ) throws IOException { int status = runMain( args ); if ( status != 0 ) { System.exit( status ); } } /** * Does the work for the main method. * Use -help flag. */ public static int runMain( String[] args ) throws IOException { String usage = new StringBuffer() .append( "\n Usage:" ) .append( "\n " ) .append( HubTester.class.getName() ) .append( "\n " ) .append( " [-help]" ) .append( " [-/+verbose]" ) .append( "\n " ) .append( " [-gui]" ) .append( "\n" ) .toString(); List argList = new ArrayList( Arrays.asList( args ) ); boolean gui = false; int verbAdjust = 0; for ( Iterator it = argList.iterator(); it.hasNext(); ) { String arg = (String) it.next(); if ( arg.equals( "-gui" ) ) { it.remove(); gui = true; } else if ( arg.equals( "-nogui" ) ) { it.remove(); gui = false; } else if ( arg.startsWith( "-v" ) ) { it.remove(); verbAdjust--; } else if ( arg.startsWith( "+v" ) ) { it.remove(); verbAdjust++; } else if ( arg.startsWith( "-h" ) ) { it.remove(); System.out.println( usage ); return 0; } else { it.remove(); System.err.println( usage ); return 1; } } assert argList.isEmpty(); // Adjust logging in accordance with verboseness flags. int logLevel = Level.WARNING.intValue() + 100 * verbAdjust; Logger.getLogger( "org.astrogrid.samp" ) .setLevel( Level.parse( Integer.toString( logLevel ) ) ); // Get profile. ClientProfile profile = DefaultClientProfile.getProfile(); // Set up GUI monitor if required. JFrame frame; if ( gui ) { frame = new JFrame( "HubTester Monitor" ); frame.getContentPane().add( new HubMonitor( profile, true, 1 ) ); frame.pack(); frame.setVisible( true ); } else { frame = null; } new HubTester( profile ).run(); if ( frame != null ) { frame.dispose(); } return 0; } /** * CallableClient implementation for testing. */ private static class TestCallableClient extends ReplyCollector implements CallableClient { private final HubConnection connection_; private int pingCount_; public static final Subscriptions SUBS = getSubscriptions(); /** * Constructor. * * @param connection hub connection */ TestCallableClient( HubConnection connection ) { super( connection ); connection_ = connection; } public void receiveNotification( String senderId, Message msg ) { processCall( senderId, msg ); } public void receiveCall( String senderId, String msgId, Message msg ) throws SampException { // If the message contains a WAITMILLIS_KEY entry, interpret this // as a number of milliseconds to wait before the response is // sent back to the hub. String swaitMillis = (String) msg.get( WAITMILLIS_KEY ); if ( swaitMillis != null ) { int waitMillis = SampUtils.decodeInt( swaitMillis ); if ( waitMillis > 0 ) { delay( waitMillis ); } } Response response; // Process a FAIL_MTYPE message specially. if ( msg.getMType().equals( FAIL_MTYPE ) ) { Map errs = (Map) msg.getParam( ERROR_KEY ); if ( errs == null ) { throw new IllegalArgumentException(); } response = Response.createErrorResponse( new ErrInfo( errs ) ); } // For other MTypes, pass them to the processCall method. else { try { response = Response .createSuccessResponse( processCall( senderId, msg ) ); } catch ( Throwable e ) { response = Response.createErrorResponse( new ErrInfo( e ) ); } } // Insert the message ID into the response if requested to do so. String msgIdQuery = (String) msg.get( MSGIDQUERY_KEY ); if ( msgIdQuery != null && SampUtils.decodeBoolean( msgIdQuery ) ) { response.put( MSGIDQUERY_KEY, msgId ); } response.check(); // Return the reply, whatever it is, to the hub. connection_.reply( msgId, response ); } /** * Do the work of responding to a given SAMP message. * * @param senderId sender public ID * @param msg message object * @return content of the successful reply's samp.result entry */ private Map processCall( String senderId, Message msg ) { String mtype = msg.getMType(); // Returns the samp.params entry as the samp.result entry. if ( ECHO_MTYPE.equals( mtype ) ) { return msg.getParams(); } // Just bumps a counter and returns an empty samp.result else if ( PING_MTYPE.equals( mtype ) ) { synchronized ( this ) { pingCount_++; } return new HashMap(); } // Shouldn't happen. else { throw new TestException( "Unsubscribed MType? " + mtype ); } } /** * Returns the subscriptions object for this client. * * @return subscriptions */ private static Subscriptions getSubscriptions() { Subscriptions subs = new Subscriptions(); subs.addMType( ECHO_MTYPE ); subs.addMType( PING_MTYPE ); subs.addMType( FAIL_MTYPE ); subs.check(); return subs; } } /** * CallableClient implementation which watches hub.event messages * concerning the registration and attributes of other clients. */ private static class ClientWatcher implements CallableClient { private final HubConnection connection_; private final Map clientMap_; private Throwable error_; /** * Constructor. * * @param connection hub connection */ ClientWatcher( HubConnection connection ) { connection_ = connection; clientMap_ = Collections.synchronizedMap( new HashMap() ); } /** * Returns a WatchedClient object corresponding to a given client * public ID. This will contain information about the hub event * messages this watcher has received concerning that client up till * now. * * @param id public id of a client which has been registered * @return watchedClient object if any messages have been received * about id, otherwise null */ public WatchedClient getClient( String id ) { return (WatchedClient) clientMap_.get( id ); } /** * Returns an error if any error has been thrown during processing * of hub event messages. * * @return deferred throwable, or null in case of no problems */ public Throwable getError() { return error_; } /** * Returns the hub connection used by this client. * * @return hub connection */ public HubConnection getConnection() { return connection_; } public void receiveCall( String senderId, String msgId, Message msg ) { receiveNotification( senderId, msg ); Response response = error_ == null ? Response.createSuccessResponse( new HashMap() ) : Response.createErrorResponse( new ErrInfo( "broken" ) ); try { connection_.reply( msgId, response ); } catch ( SampException e ) { error_ = e; } } public void receiveNotification( String senderId, Message msg ) { if ( error_ == null ) { try { processMessage( senderId, msg ); } catch ( Throwable e ) { error_ = e; } } } private void processMessage( String senderId, Message msg ) throws IOException { // Check the message actually comes from the hub. assertEquals( senderId, connection_.getRegInfo().getHubId() ); String mtype = msg.getMType(); Map params = msg.getParams(); // Get (if necessary lazily creating) a WatchedClient object // which this message concerns. String id = (String) msg.getParam( "id" ); assertTrue( id != null ); synchronized ( clientMap_ ) { if ( ! clientMap_.containsKey( id ) ) { clientMap_.put( id, new WatchedClient() ); } WatchedClient client = (WatchedClient) clientMap_.get( id ); // Handle the various hub event messages by updating fields of // the right WatchedClient object. if ( REGISTER_MTYPE.equals( mtype ) ) { assertTrue( ! client.reg_ ); client.reg_ = true; } else if ( UNREGISTER_MTYPE.equals( mtype ) ) { assertTrue( ! client.unreg_ ); client.unreg_ = true; } else if ( METADATA_MTYPE.equals( mtype ) ) { assertTrue( params.containsKey( "metadata" ) ); Metadata meta = Metadata .asMetadata( (Map) params.get( "metadata" ) ); meta.check(); client.meta_ = meta; } else if ( SUBSCRIPTIONS_MTYPE.equals( mtype ) ) { assertTrue( params.containsKey( "subscriptions" ) ); Subscriptions subs = Subscriptions .asSubscriptions( (Map) params.get( "subscriptions" ) ); subs.check(); client.subs_ = subs; } else { fail(); } clientMap_.notifyAll(); } } public void receiveResponse( String responderId, String msgTag, Response response ) { throw new UnsupportedOperationException(); } /** * Returns a suitable subscriptions object for this client. * * @return subscriptions */ public static Subscriptions getSubscriptions() { Subscriptions subs = new Subscriptions(); subs.addMType( REGISTER_MTYPE ); subs.addMType( UNREGISTER_MTYPE ); subs.addMType( METADATA_MTYPE ); subs.addMType( SUBSCRIPTIONS_MTYPE ); subs.check(); return subs; } /** * Returns a suitable metadata object for this client. */ public static Metadata getMetadata() { Metadata meta = new Metadata(); meta.setName( "ClientWatcher" ); meta.setDescriptionText( "Tracks other clients for HubTester" ); meta.check(); return meta; } } /** * Struct-type utility class which aggregates mutable information about * a client, to be updated in response to hub event messages. */ private static class WatchedClient { /** Whether this client has ever been registered. */ boolean reg_; /** Whether this clent has ever been unregistered. */ boolean unreg_; /** Current metadata object for this client. */ Metadata meta_; /** Current subscriptions object for this client. */ Subscriptions subs_; } } jsamp/src/java/org/astrogrid/samp/test/Calculator.java0000664000175000017500000003032512730747754022663 0ustar sladensladenpackage org.astrogrid.samp.test; import java.io.IOException; import java.net.URL; import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Random; import java.util.logging.Logger; import org.astrogrid.samp.ErrInfo; import org.astrogrid.samp.Message; import org.astrogrid.samp.Metadata; import org.astrogrid.samp.Response; import org.astrogrid.samp.SampUtils; import org.astrogrid.samp.Subscriptions; import org.astrogrid.samp.client.CallableClient; import org.astrogrid.samp.client.HubConnection; import org.astrogrid.samp.client.SampException; import org.astrogrid.samp.httpd.UtilServer; /** * Test client. Performs simple integer arithmetic. * * @author Mark Taylor * @since 21 Jul 2008 */ public class Calculator extends Tester implements CallableClient { private final HubConnection connection_; private final Map callMap_; private final Random random_; private volatile int nCalc_; private volatile int nCall_; private static final String ADD_MTYPE = "calc.int.add"; private static final String SUB_MTYPE = "calc.int.sub"; private static final String MUL_MTYPE = "calc.int.mul"; private static final String DIV_MTYPE = "calc.int.div"; private static final Logger logger_ = Logger.getLogger( Calculator.class.getName() ); private static String iconUrl_; /** Sends messages using the Notify delivery pattern. */ public static final SendMode NOTIFY_MODE = new SendMode( "notify" ) { void send( Calculator calc, String receiverId, CalcRequest request, int iseq ) throws SampException { calc.connection_.notify( receiverId, request.getMessage() ); } }; /** Sends messages using the Synchronous Call/Response delivery pattern. */ public static final SendMode ASYNCH_MODE = new SendMode( "sync" ) { void send( Calculator calc, String receiverId, CalcRequest request, int iseq ) throws SampException { String msgTag = "tag-" + iseq; synchronized ( calc.callMap_ ) { calc.callMap_.put( msgTag, request ); } calc.connection_.call( receiverId, msgTag, request.getMessage() ); } }; /** Sends messages using the Asynchronous Call/Response delivery pattern. */ public static final SendMode SYNCH_MODE = new SendMode( "async" ) { void send( Calculator calc, String receiverId, CalcRequest request, int iseq ) throws SampException { Response response = calc.connection_.callAndWait( receiverId, request.getMessage(), 0 ); request.checkResponse( response ); } }; /** Sends messages using a random choice of one of the other modes. */ public static final SendMode RANDOM_MODE = new SendMode( "mixture" ) { private final SendMode[] otherModes = new SendMode[] { NOTIFY_MODE, ASYNCH_MODE, SYNCH_MODE, }; void send( Calculator calc, String receiverId, CalcRequest request, int iseq ) throws SampException { otherModes[ calc.random_.nextInt( otherModes.length ) ] .send( calc, receiverId, request, iseq ); } }; /** * Constructor. * * @param connection hub connection * @param random random number generator */ public Calculator( HubConnection connection, Random random ) throws SampException { connection_ = connection; random_ = random; callMap_ = Collections.synchronizedMap( new HashMap() ); connection_.setCallable( this ); Metadata meta = new Metadata(); meta.setName( "Calculator" ); meta.setDescriptionText( "Rudimentary integer arithmetic application" ); String iconUrl = getIconUrl(); if ( iconUrl != null ) { meta.setIconUrl( iconUrl ); } connection_.declareMetadata( meta ); Subscriptions subs = new Subscriptions(); subs.addMType( ADD_MTYPE ); subs.addMType( SUB_MTYPE ); subs.addMType( MUL_MTYPE ); subs.addMType( DIV_MTYPE ); connection_.declareSubscriptions( subs ); } /** * Sends a randomly generated message in a randomly generated way to * a given receiver. The receiver should be another calculator client, * like this one. If the message is sent according to one of the * call/response delivery patterns the response will be checked to * ensure that it has the correct value. * * @param receiverId client ID of another Calculator client. */ public void sendMessage( String receiverId, SendMode mode ) throws SampException { mode.send( this, receiverId, createRandomRequest(), nextCall() ); } /** * Returns the total number of messages sent using any delivery pattern. * * @return number of sends */ public int getSendCount() { return nCall_; } /** * Returns the total number of messages received using any delivery pattern. * * @return number of receives */ public int getReceiveCount() { return nCalc_; } /** * Returns the hub connection used by this client. * * @return connection */ public HubConnection getConnection() { return connection_; } /** * Waits until all the responses this client is expecting to get * have been safely received. */ public void flush() { try { synchronized ( callMap_ ) { while ( ! callMap_.isEmpty() ) { callMap_.wait( 100 ); } } } catch ( InterruptedException e ) { throw new RuntimeException( "Interrupted" ); } } public void receiveNotification( String senderId, Message msg ) { processCall( senderId, msg ); } public void receiveCall( String senderId, String msgId, Message msg ) throws SampException { Response response; try { response = Response .createSuccessResponse( processCall( senderId, msg ) ); } catch ( Throwable e ) { response = Response.createErrorResponse( new ErrInfo( e ) ); } connection_.reply( msgId, response ); } public void receiveResponse( String senderId, String msgTag, Response response ) { CalcRequest request; synchronized ( callMap_ ) { request = (CalcRequest) callMap_.remove( msgTag ); } request.checkResponse( response ); } /** * Does the work for both the receiveNotify and receiveCall methods. * * @param senderId sender public ID * @param msg message object * @return content of the successful reply's samp.result entry */ private Map processCall( String senderId, Message msg ) { String mtype = msg.getMType(); if ( ADD_MTYPE.equals( mtype ) || SUB_MTYPE.equals( mtype ) || MUL_MTYPE.equals( mtype ) || DIV_MTYPE.equals( mtype ) ) { synchronized ( this ) { nCalc_++; } int a = SampUtils.decodeInt( (String) msg.getParam( "a" ) ); int b = SampUtils.decodeInt( (String) msg.getParam( "b" ) ); final int x; if ( ADD_MTYPE.equals( mtype ) ) { x = a + b; } else if ( SUB_MTYPE.equals( mtype ) ) { x = a - b; } else if ( MUL_MTYPE.equals( mtype ) ) { x = a * b; } else if ( DIV_MTYPE.equals( mtype ) ) { x = a / b; } else { throw new AssertionError(); } Map result = new HashMap(); result.put( "x", SampUtils.encodeInt( x ) ); return result; } else { throw new TestException(); } } /** * Increments and then returns the number of calls so far made by this * object. * * @return next value of the call counter */ private synchronized int nextCall() { return ++nCall_; } /** * Generates a random calculation request. * * @return new random request */ private CalcRequest createRandomRequest() { String mtype = new String[] { ADD_MTYPE, SUB_MTYPE, MUL_MTYPE, DIV_MTYPE, }[ random_.nextInt( 4 ) ]; return new CalcRequest( mtype, random_.nextInt( 1000 ), 500 + random_.nextInt( 500 ) ); } private static String getIconUrl() { if ( iconUrl_ == null ) { String resource = "/org/astrogrid/samp/images/tinycalc.gif"; URL url; try { url = UtilServer.getInstance().exportResource( resource ); } catch ( IOException e ) { url = null; logger_.warning( "Can't locate icon: " + resource ); } iconUrl_ = url == null ? "" : url.toString(); } return iconUrl_.length() > 0 ? iconUrl_ : null; } /** * Represents a delivery pattern. * Instances are provided as static members of class {@link Calculator}. */ public static abstract class SendMode { private final String name_; /** * Constructor. * * @param name mode name */ private SendMode( String name ) { name_ = name; } /** * Sends a message from one calculator client to another using this * send mode. * * @param calc sending client * @param receiverId public ID of receiving client * @param request calculation request object * @param iseq unique identifier for this request by this calculator */ abstract void send( Calculator calc, String receiverId, CalcRequest request, int iseq ) throws SampException; /** * Returns the name of this mode. */ public String toString() { return name_; } } /** * Represents a request which may be sent to a Calculator object. */ private class CalcRequest { private final int a_; private final int b_; private final String mtype_; private final int x_; /** * Constructor. * * @param mtype operation type as an MType string * @param a first parameter * @param b second parameter */ public CalcRequest( String mtype, int a, int b ) { mtype_ = mtype; a_ = a; b_ = b; if ( ADD_MTYPE.equals( mtype ) ) { x_ = a + b; } else if ( SUB_MTYPE.equals( mtype ) ) { x_ = a - b; } else if ( MUL_MTYPE.equals( mtype ) ) { x_ = a * b; } else if ( DIV_MTYPE.equals( mtype ) ) { x_ = a / b; } else { throw new IllegalArgumentException(); } } /** * Returns a Message object corresponding to this request. */ public Message getMessage() { Message msg = new Message( mtype_ ); msg.addParam( "a", SampUtils.encodeInt( a_ ) ); msg.addParam( "b", SampUtils.encodeInt( b_ ) ); return msg; } /** * Checks that the given response is correct for this request. * * @param response response to check */ public void checkResponse( Response response ) { assertEquals( Response.OK_STATUS, response.getStatus() ); assertEquals( x_, SampUtils.decodeInt( (String) response.getResult() .get( "x" ) ) ); } } } jsamp/src/java/org/astrogrid/samp/test/package.html0000664000175000017500000000040312730747754022202 0ustar sladensladen Classes for testing. As well as unit testing of this SAMP toolkit, it includes the {@link org.astrogrid.samp.test.HubTester} class which tests a running third-party hub implementation and some miscellaneous diagnostic and utility applications. jsamp/src/java/org/astrogrid/samp/Metadata.java0000664000175000017500000000776212730747754021344 0ustar sladensladenpackage org.astrogrid.samp; import java.net.MalformedURLException; import java.net.URL; import java.util.Map; /** * Represents the application metadata associated with a SAMP client. * * @author Mark Taylor * @since 14 Jul 2008 */ public class Metadata extends SampMap { /** Key for application name. */ public static final String NAME_KEY = "samp.name"; /** Key for short description of the application in plain text. */ public static final String DESCTEXT_KEY = "samp.description.text"; /** Key for description of the application in HTML. */ public static final String DESCHTML_KEY = "samp.description.html"; /** Key for the URL of an icon in png, gif or jpeg format. */ public static final String ICONURL_KEY = "samp.icon.url"; /** Key for the URL of a documentation web page. */ public static final String DOCURL_KEY = "samp.documentation.url"; private static final String[] KNOWN_KEYS = new String[] { NAME_KEY, DESCTEXT_KEY, DESCHTML_KEY, ICONURL_KEY, DOCURL_KEY, }; /** * Constructs an empty Metadata map. */ public Metadata() { super( KNOWN_KEYS ); } /** * Constructs a Metadata map based on a given map. * * @param map map containing initial values for this object */ public Metadata( Map map ) { this(); putAll( map ); } /** * Sets the value for the application's name. * * @param name value for {@link #NAME_KEY} key */ public void setName( String name ) { put( NAME_KEY, name ); } /** * Returns the value for the application's name. * * @return value for {@link #NAME_KEY} key */ public String getName() { return (String) get( NAME_KEY ); } /** * Sets a short description of the application. * * @param txt value for {@link #DESCTEXT_KEY} key */ public void setDescriptionText( String txt ) { put( DESCTEXT_KEY, txt ); } /** * Returns a short description of the application. * * @return value for {@link #DESCTEXT_KEY} key */ public String getDescriptionText() { return (String) get( DESCTEXT_KEY ); } /** * Sets an HTML description of the application. * * @param html value for {@link #DESCHTML_KEY} key */ public void setDescriptionHtml( String html ) { put( DESCHTML_KEY, html ); } /** * Returns an HTML description of the application. * * @return value for {@link #DESCHTML_KEY} key */ public String getDescriptionHtml() { return (String) get( DESCHTML_KEY ); } /** * Sets a URL for a gif, png or jpeg icon identifying the application. * * @param url value for {@link #ICONURL_KEY} key */ public void setIconUrl( String url ) { put( ICONURL_KEY, url ); } /** * Returns a URL for a gif, png or jpeg icon identifying the application. * * @return value for {@link #ICONURL_KEY} key */ public URL getIconUrl() { return getUrl( ICONURL_KEY ); } /** * Sets a URL for a documentation web page. * * @param url value for {@link #DOCURL_KEY} key */ public void setDocumentationUrl( String url ) { put( DOCURL_KEY, url ); } /** * Returns a URL for a documentation web page. * * @return value for {@link #DOCURL_KEY} key */ public URL getDocumentationUrl() { return getUrl( DOCURL_KEY ); } public void check() { super.check(); SampUtils.checkUrl( getString( DOCURL_KEY ) ); SampUtils.checkUrl( getString( ICONURL_KEY ) ); } /** * Returns a given map as a Metadata object. * * @param map map * @return metadata */ public static Metadata asMetadata( Map map ) { return ( map instanceof Metadata || map == null ) ? (Metadata) map : new Metadata( map ); } } jsamp/src/java/org/astrogrid/samp/httpd/0000775000175000017500000000000012730747754020070 5ustar sladensladenjsamp/src/java/org/astrogrid/samp/httpd/URLMapperHandler.java0000664000175000017500000001317412730747754024046 0ustar sladensladenpackage org.astrogrid.samp.httpd; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.MalformedURLException; import java.net.URL; import java.net.URLConnection; import java.util.LinkedHashMap; import java.util.Map; /** * Handler implementation which allows the server to serve resources which * are available to it as URLs. The main use for this is if the URLs * are jar:-type ones which are available to the JVM in which the server * is running, but not to it's clients. * Either a single resource or a whole tree may be served. * * @author Mark Taylor * @since 8 Jan 2009 */ public class URLMapperHandler implements HttpServer.Handler { private final String basePath_; private final URL baseUrl_; private final URL sourceUrl_; private final boolean includeRelatives_; /** * Constructor. * * @param server server within which this handler will be used * @param basePath path of served resources relative to the base path * of the server itself * @param sourceUrl URL of the resource which is to be made available * at the basePath by this handler * @param includeRelatives if true, relative URLs based at * basePath * may be requested (potentially giving access to * for instance the entire tree of classpath resources); * if false, only the exact resource named by * sourceUrl is served */ public URLMapperHandler( HttpServer server, String basePath, URL sourceUrl, boolean includeRelatives ) throws MalformedURLException { if ( ! basePath.startsWith( "/" ) ) { basePath = "/" + basePath; } if ( ! basePath.endsWith( "/" ) && includeRelatives ) { basePath = basePath + "/"; } basePath_ = basePath; baseUrl_ = new URL( server.getBaseUrl(), basePath ); sourceUrl_ = sourceUrl; includeRelatives_ = includeRelatives; } /** * Returns the base URL for this handler. * If not including relatives, this will be the only URL served. * * @return URL */ public URL getBaseUrl() { return baseUrl_; } public HttpServer.Response serveRequest( HttpServer.Request request ) { // Determine the source URL from which the data will be obtained. String path = request.getUrl(); if ( ! path.startsWith( basePath_ ) ) { return null; } String relPath = path.substring( basePath_.length() ); final URL srcUrl; if ( includeRelatives_ ) { try { srcUrl = new URL( sourceUrl_, relPath ); } catch ( MalformedURLException e ) { return HttpServer .createErrorResponse( 500, "Internal server error", e ); } } else { if ( relPath.length() == 0 ) { srcUrl = sourceUrl_; } else { return HttpServer.createErrorResponse( 403, "Forbidden" ); } } // Forward header and data from the source URL to the response. return mapUrlResponse( request.getMethod(), srcUrl ); } /** * Repackages a resource from a given target URL as an HTTP response. * The data and relevant headers are copied straight through. * GET and HEAD methods are served. * * @param method HTTP method * @param targetUrl URL containing the resource to forward * @return response redirecting to the given target URL */ public static HttpServer.Response mapUrlResponse( String method, URL targetUrl ) { final URLConnection conn; try { conn = targetUrl.openConnection(); conn.connect(); } catch ( IOException e ) { return HttpServer.createErrorResponse( 404, "Not found", e ); } try { Map hdrMap = new LinkedHashMap(); String contentType = conn.getContentType(); if ( contentType != null ) { hdrMap.put( "Content-Type", contentType ); } int contentLength = conn.getContentLength(); if ( contentLength >= 0 ) { hdrMap.put( "Content-Length", Integer.toString( contentLength ) ); } String contentEncoding = conn.getContentEncoding(); if ( contentEncoding != null ) { hdrMap.put( "Content-Encoding", contentEncoding ); } if ( "GET".equals( method ) ) { return new HttpServer.Response( 200, "OK", hdrMap ) { public void writeBody( OutputStream out ) throws IOException { UtilServer.copy( conn.getInputStream(), out ); } }; } else if ( "HEAD".equals( method ) ) { return new HttpServer.Response( 200, "OK", hdrMap ) { public void writeBody( OutputStream out ) { } }; } else { return HttpServer .create405Response( new String[] { "HEAD", "GET" } ); } } catch ( Exception e ) { return HttpServer .createErrorResponse( 500, "Internal server error", e ); } } } jsamp/src/java/org/astrogrid/samp/httpd/MultiURLMapperHandler.java0000664000175000017500000001207312730747754025056 0ustar sladensladenpackage org.astrogrid.samp.httpd; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import java.util.Collections; import java.util.HashMap; import java.util.Map; /** * Handler implementation which allows the server to serve multiple * separate resources which are available to it, but not necessarily to * external clients, as URLs. The main use for this is if the URLs * are jar:-type ones (not available to clients outside the current JVM) * or file:-type ones (not available to clients on different hosts). * Only single resources, not whole trees, can be exported in this way. * *

      The functionality of this class overlaps with that of * {@link URLMapperHandler}. They may be merged at some point. * * @author Mark Taylor * @since 21 Jul 2009 */ public class MultiURLMapperHandler implements HttpServer.Handler { private final HttpServer server_; private final String basePath_; private final URL baseUrl_; private final Map urlMap_; private int resourceCount_; /** * Constructor. * * @param server server within which this handler will be used * @param basePath path of served resources relative to the base * URL of the server itself */ public MultiURLMapperHandler( HttpServer server, String basePath ) throws MalformedURLException { server_ = server; if ( ! basePath.startsWith( "/" ) ) { basePath = "/" + basePath; } if ( ! basePath.endsWith( "/" ) ) { basePath = basePath + "/"; } basePath_ = basePath; baseUrl_ = new URL( server.getBaseUrl(), basePath ); urlMap_ = Collections.synchronizedMap( new HashMap() ); } /** * Returns the base URL for resources served by this handler. * * @return base URL for output */ public URL getBaseUrl() { return baseUrl_; } /** * Adds a local URL to the list of those which can be served by this * handler, and returns the public URL at which it will be available. * * @param localUrl URL readable within this JVM * @return URL readable in principle by external agents with the same * content as localUrl */ public synchronized URL addLocalUrl( URL localUrl ) { // Get a name for the publicly visible URL, using the same as the // local URL if possible. This is just for cosmetic purposes. String path = localUrl.getPath(); int lastSlash = path.lastIndexOf( "/" ); String name = lastSlash >= 0 && lastSlash < path.length() - 1 ? path.substring( lastSlash + 1 ) : "f"; // Construct a new unique public URL at which this resource will // be made available. String relPath; URL mappedUrl; try { relPath = ++resourceCount_ + "/" + name; mappedUrl = new URL( baseUrl_, relPath ); } catch ( MalformedURLException e ) { try { relPath = resourceCount_ + "/" + "f"; mappedUrl = new URL( baseUrl_, relPath ); } catch ( MalformedURLException e2 ) { throw (AssertionError) new AssertionError().initCause( e2 ); } } // Add the fragment part to the mapped URL if there is one. String frag = localUrl.getRef(); if ( frag != null ) { try { mappedUrl = new URL( mappedUrl.toString() + "#" + frag ); } catch ( MalformedURLException e ) { // shouldn't happen, but if it does, just use the non-frag one. assert false; } } // Remember the mapping between the local URL and the public one. urlMap_.put( relPath, localUrl ); // Return the public URL. return mappedUrl; } /** * Removes access to a resource which was publicised by a previous call * to {@link #addLocalUrl}. * * @param url result of previous call to addLocalUrl */ public synchronized void removeServerUrl( URL url ) { String surl = url.toString(); String sbase = baseUrl_.toString(); if ( ! surl.startsWith( sbase ) ) { return; } String relPath = surl.substring( sbase.length() ); urlMap_.remove( relPath ); } public HttpServer.Response serveRequest( HttpServer.Request request ) { // Determine the source URL from which the data will be obtained. String path = request.getUrl(); if ( ! path.startsWith( basePath_ ) ) { return null; } String relPath = path.substring( basePath_.length() ); if ( ! urlMap_.containsKey( relPath ) ) { return HttpServer.createErrorResponse( 404, "Not found" ); } URL srcUrl = (URL) urlMap_.get( relPath ); // Forward header and data from the source URL to the response. return URLMapperHandler.mapUrlResponse( request.getMethod(), srcUrl ); } } jsamp/src/java/org/astrogrid/samp/httpd/UtilServer.java0000664000175000017500000002044112730747754023040 0ustar sladensladenpackage org.astrogrid.samp.httpd; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.BindException; import java.net.MalformedURLException; import java.net.ServerSocket; import java.net.URL; import java.util.HashSet; import java.util.Set; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * Utility class for use with HttpServer. * *

      This class performs two functions. Firstly it provides a static * {@link #getInstance} method which allows its use in a singleton-like way. * The constructor is public, so singleton use is not enforced, but if * you need a server but don't need exclusive control over it, obtaining * one in this way will ensure that you don't start a new server * (which requires a new socket and other resources) if a suitable one * is already available. * *

      Secondly, it provides some utility methods, * {@link #exportResource} and {@link #exportFile}, * useful for turning files or classpath resources into * publicly viewable URLs, which is sometimes useful within a SAMP * context (for instance when providing an Icon URL in metadata). * * @author Mark Taylor * @since 22 Jul 2009 */ public class UtilServer { private final HttpServer server_; private final Set baseSet_; private MultiURLMapperHandler mapperHandler_; private ResourceHandler resourceHandler_; /** * System Property key giving a preferred port number for the server. * If unset, or 0, or the chosen port is occupied, a system-chosen * value will be used. * The property name is {@value}. */ public static final String PORT_PROP = "jsamp.server.port"; /** Buffer size for copy data from input to output stream. */ private static int BUFSIZ = 16 * 1024; /** Default instance of this class. */ private static UtilServer instance_; private static final Pattern SLASH_REGEX = Pattern.compile( "(/*)(.*?)(/*)" ); private static final Pattern NUMBER_REGEX = Pattern.compile( "(.*?)([0-9]+)" ); private static final Logger logger_ = Logger.getLogger( UtilServer.class.getName() ); /** * Constructor. * Note, it may be more appropriate to use the {@link #getInstance} method. * * @param server HTTP server providing base services */ public UtilServer( HttpServer server ) throws IOException { server_ = server; baseSet_ = new HashSet(); } /** * Returns the HttpServer associated with this object. * * @return a running server instance */ public HttpServer getServer() { return server_; } /** * Returns a handler for mapping local to external URLs associated with * this server. * * @return url mapping handler */ public synchronized MultiURLMapperHandler getMapperHandler() { if ( mapperHandler_ == null ) { try { mapperHandler_ = new MultiURLMapperHandler( server_, getBasePath( "/export" ) ); } catch ( MalformedURLException e ) { throw (AssertionError) new AssertionError().initCause( e ); } server_.addHandler( mapperHandler_ ); } return mapperHandler_; } /** * Returns a handler for general purpose resource serving associated with * this server. * * @return resource serving handler */ public synchronized ResourceHandler getResourceHandler() { if ( resourceHandler_ == null ) { resourceHandler_ = new ResourceHandler( server_, getBasePath( "/docs" ) ); server_.addHandler( resourceHandler_ ); } return resourceHandler_; } /** * Exposes a resource from the JVM's classpath as a publicly visible URL. * The classloader of this class is used. * * @param resource fully qualified path to a resource in the current * classpath; separators are "/" characters * @return URL for external reference to the resource */ public URL exportResource( String resource ) throws IOException { URL localUrl = UtilServer.class.getResource( resource ); if ( localUrl != null ) { return getMapperHandler().addLocalUrl( localUrl ); } else { throw new IOException( "Not found on classpath: " + resource ); } } /** * Exposes a file in the local filesystem as a publicly visible URL. * * @param file a file on a filesystem visible from the local host * @return URL for external reference to the resource */ public URL exportFile( File file ) throws IOException { if ( file.exists() ) { return getMapperHandler().addLocalUrl( file.toURL() ); } else { throw new FileNotFoundException( "No such file: " + file ); } } /** * May be used to return a unique base path for use with this class's * HttpServer. If all users of this server use this method * to get base paths for use with different handlers, nameclash * avoidance is guaranteed. * * @param txt basic text for base path * @return base path; will likely bear some resemblance to * txt, but may be adjusted to ensure uniqueness */ public synchronized String getBasePath( String txt ) { Matcher slashMatcher = SLASH_REGEX.matcher( txt ); String pre; String body; String post; if ( slashMatcher.matches() ) { pre = slashMatcher.group( 1 ); body = slashMatcher.group( 2 ); post = slashMatcher.group( 3 ); } else { assert false; pre = ""; body = txt; post = ""; } if ( baseSet_.contains( body ) ) { String stem = body; int i = 1; while ( baseSet_.contains( stem + "-" + i ) ) { i++; } body = stem + "-" + i; } baseSet_.add( body ); return pre + body + post; } /** * Returns the default instance of this class. * The first time this method is called a new daemon UtilServer * is (lazily) created, and started. Any subsequent calls will * return the same object, unless {@link #getInstance} is called. * * @return default instance of this class */ public static synchronized UtilServer getInstance() throws IOException { if ( instance_ == null ) { ServerSocket sock = null; String sPort = System.getProperty( PORT_PROP ); if ( sPort != null && sPort.length() > 0 ) { int port = Integer.parseInt( sPort ); try { sock = new ServerSocket( port ); } catch ( BindException e ) { logger_.warning( "Can't open socket on port " + port + " (" + e + ") - use another one" ); } } if ( sock == null ) { sock = new ServerSocket( 0 ); } HttpServer server = new HttpServer( sock ); server.setDaemon( true ); server.start(); instance_ = new UtilServer( server ); } return instance_; } /** * Sets the default instance of this class. * * @param server default instance to be returned by {@link #getInstance} */ public static synchronized void setInstance( UtilServer server ) { instance_ = server; } /** * Copies the content of an input stream to an output stream. * The input stream is always closed on exit; the output stream is not. * * @param in input stream * @param out output stream */ static void copy( InputStream in, OutputStream out ) throws IOException { byte[] buf = new byte[ BUFSIZ ]; try { for ( int nb; ( nb = in.read( buf ) ) >= 0; ) { out.write( buf, 0, nb ); } out.flush(); } finally { in.close(); } } } jsamp/src/java/org/astrogrid/samp/httpd/ServerResource.java0000664000175000017500000000145512730747754023716 0ustar sladensladenpackage org.astrogrid.samp.httpd; import java.io.IOException; import java.io.OutputStream; /** * Defines a resource suitable for serving by the {@link ResourceHandler} * HTTP server handler. * * @author Mark Taylor * @since 3 Sep 2008 */ public interface ServerResource { /** * Returns the MIME type of this resource. * * @return value of Content-Type HTTP header */ String getContentType(); /** * Returns the number of bytes in this resource, if known. * * @return value of Content-Length HTTP header if known; * otherwise a negative number */ long getContentLength(); /** * Writes resource body. * * @param out destination stream */ void writeBody( OutputStream out ) throws IOException; } jsamp/src/java/org/astrogrid/samp/httpd/ResourceHandler.java0000664000175000017500000001167212730747754024027 0ustar sladensladenpackage org.astrogrid.samp.httpd; import java.io.IOException; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.net.MalformedURLException; import java.net.URL; import java.net.URLEncoder; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; import java.util.logging.Logger; /** * Handler implementation which implements dynamic resource provision. * HTTP HEAD and GET methods are implemented. * * @author Mark Taylor * @since 7 Jan 2009 */ public class ResourceHandler implements HttpServer.Handler { private final String basePath_; private final URL serverUrl_; private final Map resourceMap_; private int iRes_; private static Logger logger_ = Logger.getLogger( ResourceHandler.class.getName() ); /** Dummy resource indicating a withdrawn item. */ private static final ServerResource EXPIRED = new ServerResource() { public String getContentType() { throw new AssertionError(); } public long getContentLength() { throw new AssertionError(); } public void writeBody( OutputStream out ) { throw new AssertionError(); } }; /** * Constructor. * * @param server HTTP server * @param basePath path from server root beneath which all resources * provided by this handler will appear */ public ResourceHandler( HttpServer server, String basePath ) { if ( ! basePath.startsWith( "/" ) ) { basePath = "/" + basePath; } if ( ! basePath.endsWith( "/" ) ) { basePath = basePath + "/"; } basePath_ = basePath; serverUrl_ = server.getBaseUrl(); resourceMap_ = new HashMap(); } /** * Adds a resource to this server. * * @param name resource name, for cosmetic purposes only * @param resource resource to make available * @return URL at which resource can be found */ public synchronized URL addResource( String name, ServerResource resource ) { String path = basePath_ + Integer.toString( ++iRes_ ) + "/"; if ( name != null ) { try { path += URLEncoder.encode( name, "utf-8" ); } catch ( UnsupportedEncodingException e ) { logger_.warning( "No utf-8?? No cosmetic path name then" ); } } resourceMap_.put( path, resource ); try { URL url = new URL( serverUrl_, path ); logger_.info( "Resource added: " + url ); return new URL( serverUrl_, path ); } catch ( MalformedURLException e ) { throw new AssertionError( "Unknown protocol http??" ); } } /** * Removes a resource from this server. * * @param url URL returned by a previous addResource call */ public synchronized void removeResource( URL url ) { String path = url.getPath(); if ( resourceMap_.containsKey( path ) ) { logger_.info( "Resource expired: " + url ); resourceMap_.put( path, EXPIRED ); } else { throw new IllegalArgumentException( "Unknown URL to expire: " + url ); } } public HttpServer.Response serveRequest( HttpServer.Request request ) { String path = request.getUrl(); if ( ! path.startsWith( basePath_ ) ) { return null; } final ServerResource resource = (ServerResource) resourceMap_.get( path ); if ( resource == EXPIRED ) { return HttpServer.createErrorResponse( 410, "Gone" ); } else if ( resource != null ) { Map hdrMap = new LinkedHashMap(); hdrMap.put( "Content-Type", resource.getContentType() ); long contentLength = resource.getContentLength(); if ( contentLength >= 0 ) { hdrMap.put( "Content-Length", Long.toString( contentLength ) ); } String method = request.getMethod(); if ( method.equals( "HEAD" ) ) { return new HttpServer.Response( 200, "OK", hdrMap ) { public void writeBody( OutputStream out ) { } }; } else if ( method.equals( "GET" ) ) { return new HttpServer.Response( 200, "OK", hdrMap ) { public void writeBody( OutputStream out ) throws IOException { resource.writeBody( out ); } }; } else { return HttpServer .create405Response( new String[] { "HEAD", "GET" } ); } } else { return HttpServer.createErrorResponse( 404, "Not found" ); } } } jsamp/src/java/org/astrogrid/samp/httpd/HttpServer.java0000664000175000017500000007611112730747754023047 0ustar sladensladenpackage org.astrogrid.samp.httpd; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.InputStream; import java.io.IOException; import java.io.OutputStream; import java.io.PrintStream; import java.net.MalformedURLException; import java.net.ServerSocket; import java.net.Socket; import java.net.SocketAddress; import java.net.URL; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.logging.Logger; import java.util.logging.Level; import org.astrogrid.samp.SampUtils; /** * Simple modular HTTP server. * One thread is started per request. Connections are not kept open between * requests. * Suitable for very large response bodies, but not for very large * request bodies. * Add one or more {@link HttpServer.Handler}s to serve actual requests. * The protocol version served is HTTP/1.0. * *

      This class is completely self-contained, so that it can easily be * lifted out and used in other packages if required. * * @author Mark Taylor * @since 21 Aug 2008 */ public class HttpServer { private final ServerSocket serverSocket_; private boolean isDaemon_; private List handlerList_; private final URL baseUrl_; private volatile boolean started_; private volatile boolean stopped_; /** Header string for MIME content type. */ public static final String HDR_CONTENT_TYPE = "Content-Type"; private static final String HDR_CONTENT_LENGTH = "Content-Length"; /** Status code for OK (200). */ public static final int STATUS_OK = 200; private static final String URI_REGEX = "([^\\s\\?]*)\\??([^\\s\\?]*)"; private static final String HTTP_VERSION_REGEX = "HTTP/[0-9]+\\.[0-9]+"; private static final String HTTP_TOKEN_REGEX = "[a-zA-Z0-9_\\.\\-]+"; private static final Pattern SIMPLE_REQUEST_PATTERN = Pattern.compile( "GET" + " " + "(" + "\\S+" + ")" ); private static final Pattern REQUEST_LINE_PATTERN = Pattern.compile( "(" + HTTP_TOKEN_REGEX + ")" // typically GET, HEAD etc + " " + "(" + "\\S+" + ")" + " " + HTTP_VERSION_REGEX ); private static final Pattern HEADER_PATTERN = Pattern.compile( "(" + "[^\\s:]+" +")" + ":\\s*(.*)" ); private static final Logger logger_ = Logger.getLogger( HttpServer.class.getName() ); /** * Constructs a server based on a given socket. * * @param socket listening socket */ public HttpServer( ServerSocket socket ) { serverSocket_ = socket; isDaemon_ = true; handlerList_ = Collections.synchronizedList( new ArrayList() ); StringBuffer ubuf = new StringBuffer() .append( "http://" ) .append( SampUtils.getLocalhost() ); int port = socket.getLocalPort(); if ( port != 80 ) { ubuf.append( ':' ) .append( port ); } try { baseUrl_ = new URL( ubuf.toString() ); } catch ( MalformedURLException e ) { throw new AssertionError( "Bad scheme http:??" ); } } /** * Constructs a server based on a default socket, on any free port. */ public HttpServer() throws IOException { this( new ServerSocket( 0 ) ); } /** * Adds a handler which can serve some requests going through this server. * * @param handler handler to add */ public void addHandler( Handler handler ) { handlerList_.add( handler ); } /** * Removes a handler previously added by {@link #addHandler}. * * @param handler handler to remove */ public void removeHandler( Handler handler ) { handlerList_.remove( handler ); } /** * Returns the socket on which this server listens. * * @return server socket */ public ServerSocket getSocket() { return serverSocket_; } /** * Returns the base URL for this server. * * @return base URL */ public URL getBaseUrl() { return baseUrl_; } /** * Does the work for providing output corresponding to a given HTTP request. * This implementation calls each Handler in turn and the first one * to provide a non-null response is used. * * @param request represents an HTTP request that has been received * @return represents the content of an HTTP response that should be sent */ public Response serve( Request request ) { Handler[] handlers = (Handler[]) handlerList_.toArray( new Handler[ 0 ] ); for ( int ih = 0; ih < handlers.length; ih++ ) { Handler handler = handlers[ ih ]; Response response = handler.serveRequest( request ); if ( response != null ) { return response; } } return createErrorResponse( 404, "No handler for URL" ); } /** * Determines whether the server thread will be a daemon thread or not. * Must be called before {@link #start} to have an effect. * The default is true. * * @param isDaemon whether server thread will be daemon * @see java.lang.Thread#setDaemon */ public void setDaemon( boolean isDaemon ) { isDaemon_ = isDaemon; } /** * Starts the server if it is not already started. */ public synchronized void start() { if ( ! started_ ) { Thread server = new Thread( "HTTP Server" ) { public void run() { try { while ( ! stopped_ ) { try { final Socket sock = serverSocket_.accept(); new Thread( "HTTP Request" ) { public void run() { try { serveRequest( sock ); } catch ( Throwable e ) { logger_.log( Level.WARNING, "Httpd error", e ); } } }.start(); } catch ( IOException e ) { if ( ! stopped_ ) { logger_.log( Level.WARNING, "Socket error", e ); } } } } finally { HttpServer.this.stop(); } } }; server.setDaemon( isDaemon_ ); logger_.info( "Server " + getBaseUrl() + " starting" ); server.start(); started_ = true; logger_.config( "Server " + getBaseUrl() + " started" ); } } /** * Stops the server if it is currently running. Processing of any requests * which have already been received is completed. */ public synchronized void stop() { if ( ! stopped_ ) { stopped_ = true; logger_.info( "Server " + getBaseUrl() + " stopping" ); try { serverSocket_.close(); } catch ( IOException e ) { logger_.log( Level.WARNING, "Error during server stop: " + e, e ); } } } /** * Indicates whether this server is currently running. * * @return true if running */ public boolean isRunning() { return started_ && ! stopped_; } /** * Called by the server thread for each new connection. * * @param sock client connection socket */ protected void serveRequest( Socket sock ) throws IOException { // Try to generate a request object by examining the socket's // input stream. If that fails, generate a response representing // the error. InputStream in = sock.getInputStream(); in = new BufferedInputStream( in ); Response response = null; Request request = null; try { request = parseRequest( in, sock.getRemoteSocketAddress() ); // If there was no input, make no response at all. if ( request == null ) { return; } } catch ( HttpException e ) { response = e.createResponse(); } catch ( IOException e ) { response = createErrorResponse( 400, "I/O error", e ); } catch ( Throwable e ) { response = createErrorResponse( 500, "Server error", e ); } // If we have a request (and hence no error response) process it to // obtain a response object. if ( response == null ) { assert request != null; try { response = serve( request ); } catch ( Throwable e ) { response = createErrorResponse( 500, e.toString(), e ); } } Level level = response.getStatusCode() == 200 ? Level.CONFIG : Level.WARNING; if ( logger_.isLoggable( level ) ) { StringBuffer sbuf = new StringBuffer(); if ( request != null ) { sbuf.append( request.getMethod() ) .append( ' ' ) .append( request.getUrl() ); } else { sbuf.append( "" ); } sbuf.append( " --> " ) .append( response.statusCode_ ) .append( ' ' ) .append( response.statusPhrase_ ); logger_.log( level, sbuf.toString() ); } // Send the response back to the client. BufferedOutputStream bos = new BufferedOutputStream( sock.getOutputStream() ); try { response.writeResponse( bos ); bos.flush(); } finally { try { bos.close(); } catch ( IOException e ) { } } } /** * Takes the input stream from a client connection and turns it into * a Request object. * As a special case, if the input stream has no content at all, * null is returned. * * @param in input stream * @param remoteAddress address of requesting client * @return parsed request, or null */ private static Request parseRequest( InputStream in, SocketAddress remoteAddress ) throws IOException { // Read the pre-body part. String[] hdrLines = readHeaderLines( in ); // No text at all? if ( hdrLines == null ) { return null; } // No header content? if ( hdrLines.length == 0 ) { throw new HttpException( 400, "Empty request" ); } // HTTP/0.9 style simple request - probably rare. Matcher simpleMatcher = SIMPLE_REQUEST_PATTERN.matcher( hdrLines[ 0 ] ); if ( simpleMatcher.matches() ) { return new Request( "GET", simpleMatcher.group( 1 ), new HashMap(), remoteAddress, null ); } // Normal HTTP/1.0 request. // First parse the request line. Matcher fullMatcher = REQUEST_LINE_PATTERN.matcher( hdrLines[ 0 ] ); if ( fullMatcher.matches() ) { String method = fullMatcher.group( 1 ); String uri = fullMatcher.group( 2 ); // Then read and parse header lines. HttpHeaderMap headerMap = new HttpHeaderMap(); int iLine = 1; boolean headerEnd = false; int contentLength = 0; for ( ; iLine < hdrLines.length; iLine++ ) { String line = hdrLines[ iLine ]; Matcher headerMatcher = HEADER_PATTERN.matcher( line ); if ( headerMatcher.matches() ) { String key = headerMatcher.group( 1 ); String value = headerMatcher.group( 2 ); // Cope with continuation lines. boolean cont = true; while ( iLine + 1 < hdrLines.length && cont ) { cont = false; String line1 = hdrLines[ iLine + 1 ]; if ( line1.length() > 0 ) { char c1 = line1.charAt( 0 ); if ( c1 == ' ' || c1 == '\t' ) { value += line1.trim(); iLine++; cont = true; } } } // Store the header. headerMap.addHeader( key, value ); // Iff we have a content-length line it means we can expect // a body later. if ( key.equalsIgnoreCase( HDR_CONTENT_LENGTH ) ) { try { contentLength = Integer.parseInt( value.trim() ); } catch ( NumberFormatException e ) { throw new HttpException( 400, "Failed to parse " + key + " header " + value ); } } } } // Read body if there is one. final byte[] body; if ( contentLength > 0 ) { body = new byte[ contentLength ]; int ib = 0; while ( ib < contentLength ) { int nb = in.read( body, ib, contentLength - ib ); if ( nb < 0 ) { throw new HttpException( 500, "Insufficient bytes for declared Content-Length: " + ib + "<" + contentLength ); } ib += nb; } assert ib == contentLength; } else { body = null; } // Decode escaped characters in the requested URI. uri = SampUtils.uriDecode( uri ); // Make sure it's a relative URI (probably not necessary // at HTTP 1.1). if ( uri.startsWith( "http://" ) ) { String path; try { URL url = new URL( uri ); path = url.getPath(); String query = url.getQuery(); if ( query != null ) { path += '?' + query; } uri = path; } catch ( MalformedURLException e ) { // never mind } } // Return the request. return new Request( method, uri, headerMap, remoteAddress, body ); } // Unrecognised. else { throw new HttpException( 400, "Bad request" ); } } /** * Reads the header lines from an HTTP client connection input stream. * All lines are read up to the first CRLF, which is when the body starts. * The input stream is left in a state ready to read the first body line. * As a special case, if the input stream has no content at all, * null is returned. * * @param is socket input stream * @return array of header lines (including initial request line); * the terminal blank line is not included, or null */ private static String[] readHeaderLines( InputStream is ) throws IOException { List lineList = new ArrayList(); StringBuffer sbuf = new StringBuffer(); boolean hasChars = false; for ( int c; ( c = is.read() ) >= 0; ) { hasChars = true; switch ( c ) { // CRLF is the correct HTTP line terminator. case '\r': if ( is.read() == '\n' ) { if ( sbuf.length() == 0 ) { return (String[]) lineList.toArray( new String[ 0 ] ); } else { lineList.add( sbuf.toString() ); sbuf.setLength( 0 ); } } else { throw new HttpException( 400, "CR w/o LF" ); } break; // HTTP 1.1 recommends that a lone LF is also tolerated as a // line terminator. case '\n': if ( sbuf.length() == 0 ) { return (String[]) lineList.toArray( new String[ 0 ] ); } else { lineList.add( sbuf.toString() ); sbuf.setLength( 0 ); } break; default: sbuf.append( (char) c ); } } // No input. if ( ! hasChars ) { return null; } // Special case: can handle HTTP/0.9 type simple requests. // Probably very rare. if ( lineList.size() == 1 ) { String line = (String) lineList.get( 0 ); if ( SIMPLE_REQUEST_PATTERN.matcher( line ).matches() ) { return new String[] { line }; } } // If it's got this far, there was no blank line. throw new HttpException( 500, "No CRLF line" ); } /** * Returns a header value from a header map. * Key value is case-insensitive. * In the (undesirable) case that multiple keys with the same * case-insensitive value exist, the values are concatenated with * comma separators, as per RFC2616 section 4.2. * * @param headerMap map * @param key header key * @return value of map entry with case-insensitive match for key */ public static String getHeader( Map headerMap, String key ) { List valueList = new ArrayList(); for ( Iterator it = headerMap.entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); if ( ((String) entry.getKey()).equalsIgnoreCase( key ) ) { Object value = entry.getValue(); if ( value instanceof String ) { valueList.add( value ); } } } int nval = valueList.size(); if ( nval == 0 ) { return null; } else if ( nval == 1 ) { return (String) valueList.get( 0 ); } else { StringBuffer sbuf = new StringBuffer(); for ( Iterator vit = valueList.iterator(); vit.hasNext(); ) { sbuf.append( (String) vit.next() ); if ( vit.hasNext() ) { sbuf.append( ", " ); } } return sbuf.toString(); } } /** * Utility method to create an error response. * * @param code status code * @param phrase status phrase * @return new response object */ public static Response createErrorResponse( int code, String phrase ) { return new Response( code, phrase, new HashMap() ) { public void writeBody( OutputStream out ) { } }; } /** * Creates an HTTP response indicating that the requested method * (GET, POST, etc) is not supported. * * @param supportedMethods list of the methods which are supported * @return error response */ public static Response create405Response( String[] supportedMethods ) { Map hdrMap = new LinkedHashMap(); StringBuffer mlist = new StringBuffer(); for ( int i = 0; i < supportedMethods.length; i++ ) { if ( i > 0 ) { mlist.append( ", " ); } mlist.append( supportedMethods[ i ] ); } hdrMap.put( "Allow", mlist.toString() ); hdrMap.put( "Content-Length", "0" ); return new Response( 405, "Method not allowed", hdrMap ) { public void writeBody( OutputStream out ) { } }; } /** * Utility method to create an error response given an exception. * * @param code status code * @param phrase status phrase * @param e exception which caused the trouble * @return new response object */ public static Response createErrorResponse( int code, String phrase, final Throwable e ) { Map hdrMap = new LinkedHashMap(); hdrMap.put( HDR_CONTENT_TYPE, "text/plain" ); return new Response( code, phrase, hdrMap ) { public void writeBody( OutputStream out ) { PrintStream pout = new PrintStream( out ); e.printStackTrace( pout ); pout.flush(); } }; } /** * Represents a parsed HTTP client request. */ public static class Request { private final String method_; private final String url_; private final Map headerMap_; private final SocketAddress remoteAddress_; private final byte[] body_; /** * Constructor. * * @param method HTTP method string (GET, HEAD etc) * @param url requested URL path (should start "/") * @param headerMap map of HTTP request header key-value pairs * @param remoteAddress address of the client making the request * @param body bytes comprising request body, or null if none present */ public Request( String method, String url, Map headerMap, SocketAddress remoteAddress, byte[] body ) { method_ = method; url_ = url; headerMap_ = headerMap; remoteAddress_ = remoteAddress; body_ = body; } /** * Returns the request method string. * * @return GET, HEAD, or whatever */ public String getMethod() { return method_; } /** * Returns the request URL string. This should be a path starting * "/" (the hostname part is not present). * * @return url path */ public String getUrl() { return url_; } /** * Returns a map of key-value pairs representing HTTP request headers. * Note that for HTTP usage, header keys are case-insensitive * (RFC2616 sec 4.2); the {@link #getHeader} utility method * can be used to interrogate the returned map. * * @return header map */ public Map getHeaderMap() { return headerMap_; } /** * Returns the address of the client which made this request. * * @return requesting client's socket address */ public SocketAddress getRemoteAddress() { return remoteAddress_; } /** * Returns the body of the HTTP request if there was one. * * @return body bytes or null */ public byte[] getBody() { return body_; } public String toString() { StringBuffer sbuf = new StringBuffer() .append( method_ ) .append( ' ' ) .append( url_ ); if ( headerMap_ != null && ! headerMap_.isEmpty() ) { sbuf.append( "\n " ) .append( headerMap_ ); } if ( body_ != null && body_.length > 0 ) { sbuf.append( "\n " ) .append( "body[" ) .append( body_.length ) .append( ']' ); } return sbuf.toString(); } } /** * Represents a response to an HTTP request. */ public static abstract class Response { private final int statusCode_; private final String statusPhrase_; private final Map headerMap_; /** * Constructor. * * @param statusCode 3-digit status code * @param statusPhrase text string passed to client along * with the status code * @param headerMap map of key-value pairs representing response * header information; should normally contain * at least a content-type key */ public Response( int statusCode, String statusPhrase, Map headerMap ) { if ( Integer.toString( statusCode ).length() != 3 ) { throw new IllegalArgumentException( "Bad status " + statusCode ); } statusCode_ = statusCode; statusPhrase_ = statusPhrase; headerMap_ = headerMap; } /** * Returns the 3-digit status code. * * @return status code */ public int getStatusCode() { return statusCode_; } /** * Returns the status phrase. * * @return status phrase */ public String getStatusPhrase() { return statusPhrase_; } /** * Returns a map of key-value pairs representing HTTP response headers. * Note that for HTTP usage, header keys are case-insensitive * (RFC2616 sec 4.2); the {@link #getHeader} utility method * can be used to interrogate the returned map. * * @return header map */ public Map getHeaderMap() { return headerMap_; } /** * Implemented to generate the bytes in the body of the response. * * @param out destination stream for body bytes */ public abstract void writeBody( OutputStream out ) throws IOException; /** * Writes this response to an output stream in a way suitable for * replying to the client. * Status line and any headers are written, then {@link #writeBody} * is called. * * @param out destination stream */ public void writeResponse( OutputStream out ) throws IOException { String statusLine = new StringBuffer() .append( "HTTP/1.0" ) .append( ' ' ) .append( getStatusCode() ) .append( ' ' ) .append( getStatusPhrase() ) .append( '\r' ) .append( '\n' ) .toString(); out.write( statusLine.getBytes( "UTF-8" ) ); if ( headerMap_ != null ) { StringBuffer sbuf = new StringBuffer(); for ( Iterator it = getHeaderMap().entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); sbuf.setLength( 0 ); String line = sbuf .append( entry.getKey() ) .append( ':' ) .append( ' ' ) .append( entry.getValue() ) .append( '\r' ) .append( '\n' ) .toString(); out.write( line.getBytes( "UTF-8" ) ); } } out.write( '\r' ); out.write( '\n' ); writeBody( out ); } } /** * Convenience class for representing an error whose content should be * returned to the user as an HTTP erro response of some kind. */ private static class HttpException extends IOException { private final int code_; private final String phrase_; /** * Constructor. * * @param code 3-digit status code * @param phrase status phrase */ HttpException( int code, String phrase ) { code_ = code; phrase_ = phrase; } /** * Turns this exception into a response object. * * @return error response */ Response createResponse() { return createErrorResponse( code_, phrase_ ); } } /** * Map implementation suitable for storing HTTP headers. * It should be populated using the {@link #addHeader}, not {@link #put}, * method. * This implementation should be used when a header is being constructed * from an uncontrolled source of (key,value) pairs. * If you are adding headers yourself and know that you won't duplicate * keys, then a normal Map implementation will do. */ static class HttpHeaderMap extends LinkedHashMap { /** * Adds a header value to this map. * This differs from put in two subtle ways. * First, key matching is case-insensitive. * Second, if a value for the given key already exists, the new * value will be appended after a comma, rather than replacing * the old entry. See RFC2616 section 4.2 for the HTTP rules. * * @param key header name * @param value header value */ public void addHeader( String key, String value ) { boolean added = false; for ( Iterator it = entrySet().iterator(); it.hasNext() && ! added; ) { Map.Entry entry = (Map.Entry) it.next(); if ( ((String) entry.getKey()).equalsIgnoreCase( key ) ) { entry.setValue( entry.getValue() + ", " + value ); added = true; } } if ( ! added ) { put( key, value ); } } } /** * Implemented to serve data for some URLs. */ public interface Handler { /** * Provides a response to an HTTP request. * A handler which does not recognise the URL should simply return null; * in this case there may be another handler which is able to serve * the request. If the URL appears to be in this handler's domain but * the request cannot be served for some reason, an error response * should be returned. * * @param request HTTP request * @return response response to request, or null */ Response serveRequest( Request request ); } } jsamp/src/java/org/astrogrid/samp/httpd/package.html0000664000175000017500000000107112730747754022350 0ustar sladensladen Standalone HTTP server. This is used by the xmlrpc.internal XML-RPC layer implementation. It may also come in useful for client applications which wish to serve resources (such as dynamically-generated data or icons as referenced by the {@link org.astrogrid.samp.Metadata#ICONURL_KEY} item) as part of SAMP operations. Other than its use by the xmlprc.internal classes however this package is not required by the JSAMP package, and it has no dependencies on other parts of it, so it may be used as a separate item if required. jsamp/src/java/org/astrogrid/samp/DataException.java0000664000175000017500000000177312730747754022350 0ustar sladensladenpackage org.astrogrid.samp; /** * Unchecked exception thrown when a data structure for use with * SAMP does not have the correct form. * * @author Mark Taylor * @since 15 Jul 2008 */ public class DataException extends IllegalArgumentException { /** * Constructs an exception with no message. */ public DataException() { super(); } /** * Consructs an exception with a given message. * * @param msg message */ public DataException( String msg ) { super( msg ); } /** * Constructs an exception with a given cause. * * @param e cause of this exception */ public DataException( Throwable e ) { this(); initCause( e ); } /** * Constructs an exception with a given message and cause. * * @param msg message * @param e cause of this exception */ public DataException( String msg, Throwable e ) { this( msg ); initCause( e ); } } jsamp/src/java/org/astrogrid/samp/ShutdownManager.java0000664000175000017500000001171212730747754022720 0ustar sladensladenpackage org.astrogrid.samp; import java.util.Arrays; import java.util.WeakHashMap; import java.util.logging.Level; import java.util.logging.Logger; /** * Handles ordered running of cleanup processes at JVM shutdown. * This is a singleton class, use {@link #getInstance}. * * @author Sylvain Lafrasse * @author Mark Taylor * @since 12 Oct 2012 */ public class ShutdownManager { /** Shutdown sequence for preparatory hooks. */ public static final int PREPARE_SEQUENCE = 0; /** Shutdown sequence value for client hooks. */ public static final int CLIENT_SEQUENCE = 100; /** Shutdown sequence value for hub hooks. */ public static final int HUB_SEQUENCE = 200; private static final ShutdownManager instance_ = new ShutdownManager(); private static final Logger logger_ = Logger.getLogger( "org.astrogrid.samp" ); /** Maps Objects to Hooks. */ private final WeakHashMap hookMap_; /** * Private constructor prevents instantiation. */ private ShutdownManager() { hookMap_ = new WeakHashMap(); try { Runtime.getRuntime() .addShutdownHook( new Thread( "SAMP Shutdown" ) { public void run() { doCleanup(); } } ); } catch ( SecurityException e ) { logger_.log( Level.WARNING, "Can't add shutdown hook: " + e, e ); } } /** * Register a runnable to be run on shutdown with a given key and sequence. * Items with a smaller value of iseq * are run earlier at shutdown. * Suitable sequence values are given by {@link #HUB_SEQUENCE} and * {@link #CLIENT_SEQUENCE}. * The key is kept in a WeakHashMap, so if it is GC'd, * the runnable will never execute. * * @param key key which can be used to unregister the hook later * @param iseq value indicating position in shutdown sequence * @param runnable to be run on shutdown */ public synchronized void registerHook( Object key, int iseq, Runnable runnable ) { hookMap_.put( key, new Hook( runnable, iseq ) ); } /** * Unregisters a key earlier registered using {@link #registerHook}. * * @param key registration key */ public synchronized void unregisterHook( Object key ) { hookMap_.remove( key ); } /** * Invoked on shutdown by runtime. */ private void doCleanup() { Hook[] hooks; synchronized ( this ) { hooks = (Hook[]) hookMap_.values().toArray( new Hook[ 0 ] ); } Arrays.sort( hooks ); logger_.info( "SAMP shutdown start" ); for ( int ih = 0; ih < hooks.length; ih++ ) { try { hooks[ ih ].runnable_.run(); } catch ( RuntimeException e ) { forceLog( logger_, Level.WARNING, "Shutdown hook failure: " + e, e ); } } logger_.info( "SAMP shutdown end" ); } /** * Returns sole instance of this class. * * @return instance */ public static ShutdownManager getInstance() { return instance_; } /** * Writes a log-like message directly to standard error if it has * an appropriate level. * This method is only intended for use during the shutdown process, * when the logging system may be turned off so that normal logging * calls may get ignored (this behaviour is not as far as I know * documented, but seems reliable in for example Oracle JRE1.5). * There may be some good reason for logging services to be withdrawn * during shutdown, so it's not clear that using this method is * a good idea at all even apart from bypassing the logging system; * therefore use it sparingly. * * @param logger logger * @param level level of message to log * @param msg text of logging message * @param error associated throwable if any; may be null */ public static void forceLog( Logger logger, Level level, String msg, Throwable error ) { if ( logger.isLoggable( level ) ) { System.err.println( level + ": " + msg ); if ( error != null ) { error.printStackTrace( System.err ); } } } /** * Aggregates a runnable and an associated sequence value. */ private static class Hook implements Comparable { final Runnable runnable_; final int iseq_; /** * Constructor. * * @param runnable runnable * @param iseq sequence value */ Hook( Runnable runnable, int iseq ) { runnable_ = runnable; iseq_ = iseq; } public int compareTo( Object other ) { return this.iseq_ - ((Hook) other).iseq_; } } } jsamp/src/java/org/astrogrid/samp/Response.java0000664000175000017500000001263312730747754021413 0ustar sladensladenpackage org.astrogrid.samp; import java.util.Map; /** * Represents an encoded SAMP response. * * @author Mark Taylor * @since 14 Jul 2008 */ public class Response extends SampMap { /** Key for response status. May take one of the values * {@link #OK_STATUS}, {@link #WARNING_STATUS} or {@link #ERROR_STATUS}. */ public static final String STATUS_KEY = "samp.status"; /** Key for result map. This is a map of key-value pairs with semantics * defined by the original message's MType. * Only present in case of success (or warning). */ public static final String RESULT_KEY = "samp.result"; /** Key for error map. Only present in case of failure (or warning). */ public static final String ERROR_KEY = "samp.error"; private static final String[] KNOWN_KEYS = new String[] { STATUS_KEY, RESULT_KEY, ERROR_KEY, }; /** {@link #STATUS_KEY} value indicating success. */ public static final String OK_STATUS = "samp.ok"; /** {@link #STATUS_KEY} value indicating partial success. */ public static final String WARNING_STATUS = "samp.warning"; /** {@link #STATUS_KEY} value indicating failure. */ public static final String ERROR_STATUS = "samp.error"; /** * Constructs an empty response. */ public Response() { super( KNOWN_KEYS ); } /** * Constructs a response based on an existing map. * * @param map map containing initial data for this object */ public Response( Map map ) { this(); putAll( map ); } /** * Constructs a response with given status, result and error. * * @param status {@link #STATUS_KEY} value * @param result {@link #RESULT_KEY} value * @param errinfo {@link #ERROR_KEY} value */ public Response( String status, Map result, ErrInfo errinfo ) { this(); put( STATUS_KEY, status ); if ( result != null ) { put( RESULT_KEY, result ); } if ( errinfo != null ) { put( ERROR_KEY, errinfo ); } } /** * Sets the status value. * * @param status {@link #STATUS_KEY} value */ public void setStatus( String status ) { put( STATUS_KEY, status ); } /** * Returns the status value. * * @return {@link #STATUS_KEY} value */ public String getStatus() { return getString( STATUS_KEY ); } /** * Sets the result map. * * @param result {@link #RESULT_KEY} value */ public void setResult( Map result ) { put( RESULT_KEY, result ); } /** * Returns the result map. * * @return {@link #RESULT_KEY} value */ public Map getResult() { return getMap( RESULT_KEY ); } /** * Sets the error object. * * @param errInfo {@link #ERROR_KEY} value * @see ErrInfo */ public void setErrInfo( Map errInfo ) { put( ERROR_KEY, errInfo ); } /** * Returns the error object. * * @return {@link #ERROR_KEY} value as an ErrInfo */ public ErrInfo getErrInfo() { return ErrInfo.asErrInfo( getMap( ERROR_KEY ) ); } /** * Indicates whether the result was an unequivocal success. * * @return true iff getStatus()==OK_STATUS */ public boolean isOK() { return OK_STATUS.equals( get( STATUS_KEY ) ); } public void check() { super.check(); checkHasKeys( new String[] { STATUS_KEY, } ); String status = getStatus(); if ( OK_STATUS.equals( status ) || WARNING_STATUS.equals( status ) ) { if ( ! containsKey( RESULT_KEY ) ) { throw new DataException( STATUS_KEY + "=" + status + " but no " + RESULT_KEY ); } } if ( ERROR_STATUS.equals( status ) || WARNING_STATUS.equals( status ) ) { if ( ! containsKey( ERROR_KEY ) ) { throw new DataException( STATUS_KEY + "=" + status + " but not " + ERROR_KEY ); } } if ( ! containsKey( RESULT_KEY ) && ! containsKey( ERROR_KEY ) ) { throw new DataException( "Neither " + RESULT_KEY + " nor " + ERROR_KEY + " keys present" ); } if ( containsKey( ERROR_KEY ) ) { ErrInfo.asErrInfo( getMap( ERROR_KEY ) ).check(); } } /** * Returns a new response which is a success. * * @param result key-value map representing results of successful call * @return new success response */ public static Response createSuccessResponse( Map result ) { return new Response( OK_STATUS, result, null ); } /** * Returns a new response which is an error. * * @param errinfo error information * @return new error response */ public static Response createErrorResponse( ErrInfo errinfo ) { return new Response( ERROR_STATUS, null, errinfo ); } /** * Returns a map as a Response object. * * @param map map * @return response */ public static Response asResponse( Map map ) { return ( map instanceof Response || map == null ) ? (Response) map : new Response( map ); } } jsamp/src/java/org/astrogrid/samp/SampMap.java0000664000175000017500000001406312730747754021152 0ustar sladensladenpackage org.astrogrid.samp; import java.net.MalformedURLException; import java.net.URL; import java.util.AbstractMap; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; /** * Abstract superclass for objects represented within the SAMP package as * key-value maps. There are several of these, represented by subclasses * of SampMap, for instance {@link Message}, {@link Metadata} etc. * A SampMap is-a {@link java.util.Map}, but has some * additional useful features: *

        *
      • its entries are ordered in a convenient fashion *
      • it contains some general-purpose utility methods appropriate to SAMP maps *
      • particular subclasses contain specific constants and utility methods, * e.g. well-known keys and accessor methods for them *
      • concrete subclasses provide a static asClass * method to convert from a normal Map to the class in question *
      * *

      In general * any time a map-encoded object is required by a method in the toolkit, * any Map can be used. When the toolkit provides a map-encoded * object however (as return value or callback method parameter), an object * of the more specific SampMap type is used. * This allows maximum convenience for the application programmer, but * means that you don't have to use these additional features if you * don't want to, you can treat everything as a plain old Map. * * @author Mark Taylor * @since 14 Jul 2008 */ public abstract class SampMap extends AbstractMap { private final Map baseMap_; public static final Map EMPTY = Collections.unmodifiableMap( new HashMap() ); /** * Constructor. * The given array of well-known keys will appear first in the list of * entries when this map is iterated over. Other entries will appear in * alphabetical order. * * @param knownKeys array of well-known keys for this class */ protected SampMap( String[] knownKeys ) { super(); final List knownKeyList = Arrays.asList( (String[]) knownKeys.clone() ); baseMap_ = new TreeMap( new Comparator() { public int compare( Object o1, Object o2 ) { String s1 = o1.toString(); String s2 = o2.toString(); int k1 = knownKeyList.indexOf( s1 ); int k2 = knownKeyList.indexOf( s2 ); if ( k1 >= 0 ) { if ( k2 >= 0 ) { return k1 - k2; } else { return -1; } } else if ( k2 >= 0 ) { assert k1 < 0; return +1; } boolean f1 = s1.startsWith( "samp." ); boolean f2 = s2.startsWith( "samp." ); if ( f1 && ! f2 ) { return -1; } else if ( ! f1 && f2 ) { return +1; } boolean g1 = s1.startsWith( "x-samp." ); boolean g2 = s2.startsWith( "x-samp." ); if ( g1 && ! g2 ) { return -1; } else if ( ! g1 && g2 ) { return +1; } return s1.compareTo( s2 ); } } ); } public Object put( Object key, Object value ) { return baseMap_.put( key, value ); } public Set entrySet() { return baseMap_.entrySet(); } /** * Checks that this object is ready for use with the SAMP toolkit. * As well as calling {@link SampUtils#checkMap} (ensuring that all keys * are Strings, and all values Strings, Lists or Maps), subclass-specific * invariants may be checked. In the case that there's something wrong, * an informative DataException will be thrown. * * @throws DataException if this object's current state * is not suitable for SAMP use */ public void check() { SampUtils.checkMap( this ); } /** * Checks that this map contains at least the given set of keys. * If any is absent, an informative DataException will be * thrown. Normally called by {@link #check}. * * @param keys array of required keys for this map * @throws DataException if this object does not contain entries * for all elements of the array keys */ public void checkHasKeys( String[] keys ) { for ( int i = 0; i < keys.length; i++ ) { String key = keys[ i ]; if ( ! containsKey( key ) ) { throw new DataException( "Required key " + key + " not present" ); } } } /** * Returns the value for a given key in this map, cast to String. * * @return string value for key */ public String getString( String key ) { return (String) get( key ); } /** * returns the value for a given key in this map, cast to Map. * * @return map value for key */ public Map getMap( String key ) { return (Map) get( key ); } /** * Returns the value for a given key in this map, cast to List. * * @return list value for key */ public List getList( String key ) { return (List) get( key ); } /** * Returns the value for a given key in this map as a URL. * * @return URL value for key */ public URL getUrl( String key ) { String loc = getString( key ); if ( loc == null ) { return null; } else { try { return new URL( loc ); } catch ( MalformedURLException e ) { return null; } } } } jsamp/src/java/org/astrogrid/samp/Message.java0000664000175000017500000000751512730747754021204 0ustar sladensladenpackage org.astrogrid.samp; import java.util.HashMap; import java.util.Map; /** * Represents an encoded SAMP Message. * * @author Mark Taylor * @since 14 Jul 2008 */ public class Message extends SampMap { /** Key for message MType. */ public static final String MTYPE_KEY = "samp.mtype"; /** Key for map of parameters used by this message. */ public static final String PARAMS_KEY = "samp.params"; private static final String[] KNOWN_KEYS = new String[] { MTYPE_KEY, PARAMS_KEY, }; /** * Constructs an empty message. */ public Message() { super( KNOWN_KEYS ); } /** * Constructs a message based on an existing map. * * @param map map containing initial data for this object */ public Message( Map map ) { this(); putAll( map ); } /** * Constructs a message with a given MType and params map. * * @param mtype value for {@link #MTYPE_KEY} key * @param params value for {@link #PARAMS_KEY} key */ public Message( String mtype, Map params ) { this(); put( MTYPE_KEY, mtype ); put( PARAMS_KEY, params == null ? new HashMap() : params ); } /** * Constructs a message with a given MType. * The parameters map will be mutable. * * @param mtype value for {@link #MTYPE_KEY} key */ public Message( String mtype ) { this( mtype, null ); } /** * Returns this message's MType. * * @return value for {@link #MTYPE_KEY} */ public String getMType() { return getString( MTYPE_KEY ); } /** * Sets this message's params map. * * @param params value for {@link #PARAMS_KEY} */ public void setParams( Map params ) { put( PARAMS_KEY, params ); } /** * Returns this message's params map. * * @return value for {@link #PARAMS_KEY} */ public Map getParams() { return getMap( PARAMS_KEY ); } /** * Sets the value for a single entry in this message's * samp.params map. * * @param name param name * @param value param value */ public Message addParam( String name, Object value ) { if ( ! containsKey( PARAMS_KEY ) ) { put( PARAMS_KEY, new HashMap() ); } getParams().put( name, value ); return this; } /** * Returns the value of a single entry in this message's * samp.params map. Null is returned if the parameter * does not appear. * * @param name param name * @return param value, or null */ public Object getParam( String name ) { Map params = getParams(); return params == null ? null : params.get( name ); } /** * Returns the value of a single entry in this message's * samp.params map, throwing an exception * if it is not present. * * @param name param name * @return param value * @throws DataException if no parameter name is present */ public Object getRequiredParam( String name ) { Object param = getParam( name ); if ( param != null ) { return param; } else { throw new DataException( "Required parameter \"" + name + "\" is missing" ); } } public void check() { super.check(); checkHasKeys( new String[] { MTYPE_KEY } ); } /** * Returns a given map as a Message object. * * @param map map * @return message */ public static Message asMessage( Map map ) { return ( map instanceof Message || map == null ) ? (Message) map : new Message( map ); } } jsamp/src/java/org/astrogrid/samp/SampUtils.java0000664000175000017500000005201712730747754021536 0ustar sladensladenpackage org.astrogrid.samp; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.net.ConnectException; import java.net.InetAddress; import java.net.MalformedURLException; import java.net.ServerSocket; import java.net.Socket; import java.net.URL; import java.net.URLDecoder; import java.net.URLEncoder; import java.net.UnknownHostException; import java.util.Arrays; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; /** * Contains static utility methods for use with the SAMP toolkit. * * @author Mark Taylor * @since 15 Jul 2008 */ public class SampUtils { /** * Property which can be used to set name used for localhost in server * endpoints. * Value is {@value}. * @see #getLocalhost */ public static final String LOCALHOST_PROP = "jsamp.localhost"; private static final Logger logger_ = Logger.getLogger( SampUtils.class.getName() ); private static String sampVersion_; private static String softwareVersion_; private static File lockFile_; private static final String NEWLINE = getLineSeparator(); /** * Private constructor prevents instantiation. */ private SampUtils() { } /** * Returns a SAMP int string representation of an integer. * * @param i integer value * @return SAMP int string */ public static String encodeInt( int i ) { return Integer.toString( i ); } /** * Returns the integer value for a SAMP int string. * * @param s SAMP int string * @return integer value * @throws NumberFormatException if conversion fails */ public static int decodeInt( String s ) { return Integer.parseInt( s ); } /** * Returns a SAMP int string representation of a long integer. * * @param i integer value * @return SAMP int string */ public static String encodeLong( long i ) { return Long.toString( i ); } /** * Returns the integer value as a long for a SAMP int * string. * * @param s SAMP int string * @return long integer value * @throws NumberFormatException if conversion fails */ public static long decodeLong( String s ) { return Long.parseLong( s ); } /** * Returns a SAMP float string representation of a floating point * value. * * @param d double value * @return SAMP double string * @throws IllegalArgumentException if d is NaN or infinite */ public static String encodeFloat( double d ) { if ( Double.isInfinite( d ) ) { throw new IllegalArgumentException( "Infinite value " + "not permitted" ); } if ( Double.isNaN( d ) ) { throw new IllegalArgumentException( "NaN not permitted" ); } return Double.toString( d ); } /** * Returns the double value for a SAMP float string. * * @param s SAMP float string * @return double value * @throws NumberFormatException if conversion fails */ public static double decodeFloat( String s ) { return Double.parseDouble( s ); } /** * Returns a SAMP boolean string representation of a boolean value. * * @param b boolean value * @return SAMP boolean string */ public static String encodeBoolean( boolean b ) { return encodeInt( b ? 1 : 0 ); } /** * Returns the boolean value for a SAMP boolean string. * * @param s SAMP boolean string * @return false iff s is equal to zero */ public static boolean decodeBoolean( String s ) { try { return decodeInt( s ) != 0; } catch ( NumberFormatException e ) { return false; } } /** * Checks that a given object is legal for use in a SAMP context. * This checks that it is either a String, List or Map, that * any Map keys are Strings, and that Map values and List elements are * themselves legal (recursively). * * @param obj object to check * @throws DataException in case of an error */ public static void checkObject( Object obj ) { if ( obj instanceof Map ) { checkMap( (Map) obj ); } else if ( obj instanceof List ) { checkList( (List) obj ); } else if ( obj instanceof String ) { checkString( (String) obj ); } else if ( obj == null ) { throw new DataException( "Bad SAMP object: contains a null" ); } else { throw new DataException( "Bad SAMP object: contains a " + obj.getClass().getName() ); } } /** * Checks that a given Map is legal for use in a SAMP context. * All its keys must be strings, and its values must be legal * SAMP objects. * * @param map map to check * @throws DataException in case of an error * @see #checkObject */ public static void checkMap( Map map ) { for ( Iterator it = map.entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = (Map.Entry) it.next(); Object key = entry.getKey(); if ( key instanceof String ) { checkString( (String) key ); checkObject( entry.getValue() ); } else if ( key == null ) { throw new DataException( "Map key null" ); } else { throw new DataException( "Map key not a string (" + key.getClass().getName() + ")" ); } } } /** * Checks that a given List is legal for use in a SAMP context. * All its elements must be legal SAMP objects. * * @param list list to check * @throws DataException in case of error * @see #checkObject */ public static void checkList( List list ) { for ( Iterator it = list.iterator(); it.hasNext(); ) { checkObject( it.next() ); } } /** * Checks that a given String is legal for use in a SAMP context. * All its characters must be in the range 0x01 - 0x7f. * * @param string string to check * @throws DataException in case of error */ public static void checkString( String string ) { int leng = string.length(); for ( int i = 0; i < leng; i++ ) { char c = string.charAt( i ); if ( ! isStringChar( c ) ) { throw new DataException( "Bad SAMP string; contains character " + "0x" + Integer.toHexString( c ) ); } } } /** * Indicates whether a given character is legal to include in a SAMP * string. * * @return true iff c is 0x09, 0x0a, 0x0d or 0x20--0x7f */ public static boolean isStringChar( char c ) { switch ( c ) { case 0x09: case 0x0a: case 0x0d: return true; default: return c >= 0x20 && c <= 0x7f; } } /** * Checks that a string is a legal URL. * * @param url string to check * @throws DataException if url is not a legal URL */ public static void checkUrl( String url ) { if ( url != null ) { try { new URL( (String) url ); } catch ( MalformedURLException e ) { throw new DataException( "Bad URL " + url, e ); } } } /** * Returns a string representation of a client object. * The name is used if present, otherwise the ID. * * @param client client object * @return string */ public static String toString( Client client ) { Metadata meta = client.getMetadata(); if ( meta != null ) { String name = meta.getName(); if ( name != null && name.trim().length() > 0 ) { return name; } } return client.getId(); } /** * Pretty-prints a SAMP object. * * @param obj SAMP-friendly object * @param indent base indent for text block * @return string containing formatted object */ public static String formatObject( Object obj, int indent ) { checkObject( obj ); return new JsonWriter( indent, true ).toJson( obj ); } /** * Parses a command-line string as a SAMP object. * If it can be parsed as a SAMP-friendly JSON string, that interpretation * will be used. Otherwise, the value is just the string as presented. * * @param str command-line argument * @return SAMP object */ public static Object parseValue( String str ) { if ( str == null || str.length() == 0 ) { return null; } else { try { Object obj = fromJson( str ); checkObject( obj ); return obj; } catch ( RuntimeException e ) { logger_.config( "String not JSON (" + e + ")" ); } Object sval = str; checkObject( sval ); return sval; } } /** * Returns a string denoting the local host to be used for communicating * local server endpoints and so on. * *

      The value returned by default is the loopback address, "127.0.0.1". * However this behaviour can be overridden by setting the * {@link #LOCALHOST_PROP} system property to the string which should * be returned instead. * This may be necessary if the loopback address is not appropriate, * for instance in the case of multiple configured loopback interfaces(?) * or where SAMP communication is required across different machines. * There are two special values which may be used for this property: *

        *
      • [hostname]: * uses the fully qualified domain name of the host
      • *
      • [hostnumber]: * uses the IP number of the host
      • *
      * If these determinations fail for some reason, a fallback value of * 127.0.0.1 will be used. * *

      In JSAMP version 0.3-1 and prior versions, the [hostname] * behaviour was the default. * Although this might be seen as more correct, in practice it could cause * a lot of problems with DNS configurations which are incorrect or * unstable (common in laptops outside their usual networks). * See, for instance, AstroGrid bugzilla tickets * 1799, * 2151. * *

      In JSAMP version 0.3-1 and prior versions, the property was * named samp.localhost rather than * jsamp.localhost. This name is still accepted for * backwards compatibility. * * @return local host name */ public static String getLocalhost() { final String defaultHost = "127.0.0.1"; String hostname = System.getProperty( LOCALHOST_PROP, System.getProperty( "samp.localhost", "" ) ); if ( hostname.length() == 0 ) { hostname = defaultHost; } else if ( "[hostname]".equals( hostname ) ) { try { hostname = InetAddress.getLocalHost().getCanonicalHostName(); } catch ( UnknownHostException e ) { logger_.log( Level.WARNING, "Local host determination failed - fall back to " + defaultHost, e ); hostname = defaultHost; } } else if ( "[hostnumber]".equals( hostname ) ) { try { hostname = InetAddress.getLocalHost().getHostAddress(); } catch ( UnknownHostException e ) { logger_.log( Level.WARNING, "Local host determination failed - fall back to " + defaultHost, e ); hostname = defaultHost; } } logger_.config( "Local host is " + hostname ); return hostname; } /** * Returns an unused port number on the local host. * * @param startPort suggested port number; may or may not be used * @return unused port */ public static int getUnusedPort( int startPort ) throws IOException { // Current implementation ignores the given startPort and uses // findAnyPort. return true ? findAnyPort() : scanForPort( startPort, 20 ); } /** * Turns a File into a URL. * Unlike Sun's J2SE, this gives you a URL which conforms to RFC1738 and * looks like "file://localhost/abs-path" rather than * "file:abs-or-rel-path". * * @param file file * @return URL * @see "RFC 1738" * @see Sun Java bug 6356783 */ public static URL fileToUrl( File file ) { try { return new URL( "file", "localhost", file.toURI().toURL().getPath() ); } catch ( MalformedURLException e ) { throw new AssertionError(); } } /** * Reverses URI-style character escaping (%xy) on a string. * Note, unlike {@link java.net.URLDecoder}, * this does not turn "+" characters into spaces. * * @see "RFC 2396, Section 2.4" * @param text escaped text * @return unescaped text */ public static String uriDecode( String text ) { try { return URLDecoder.decode( replaceChar( text, '+', "%2B" ), "UTF-8" ); } catch ( UnsupportedEncodingException e ) { throw new AssertionError( "UTF-8 unsupported??" ); } } /** * Performs URI-style character escaping (%xy) on a string. * Note, unlike {@link java.net.URLEncoder}, * this encodes spaces as "%20" and not "+". * * @see "RFC 2396, Section 2.4" * @param text unescaped text * @return escaped text */ public static String uriEncode( String text ) { try { return replaceChar( URLEncoder.encode( text, "UTF-8" ), '+', "%20" ); } catch ( UnsupportedEncodingException e ) { throw new AssertionError( "UTF-8 unsupported??" ); } } /** * Attempts to interpret a URL as a file. * If the URL does not have the "file:" protocol, null is returned. * * @param url URL, may or may not be file: protocol * @return file, or null */ public static File urlToFile( URL url ) { if ( url.getProtocol().equals( "file" ) && url.getRef() == null && url.getQuery() == null ) { String path = uriDecode( url.getPath() ); String filename = File.separatorChar == '/' ? path : path.replace( '/', File.separatorChar ); return new File( filename ); } else { return null; } } /** * Parses JSON text to give a SAMP object. * Note that double-quoted strings are the only legal scalars * (no unquoted numbers or booleans). * * @param str string to parse * @return SAMP object */ public static Object fromJson( String str ) { return new JsonReader().read( str ); } /** * Serializes a SAMP object to a JSON string. * * @param item to serialize * @param multiline true for formatted multiline output, false for a * single line */ public static String toJson( Object item, boolean multiline ) { checkObject( item ); return new JsonWriter( multiline ? 2 : -1, true ).toJson( item ); } /** * Returns a string giving the version of the SAMP standard which this * software implements. * * @return SAMP standard version */ public static String getSampVersion() { if ( sampVersion_ == null ) { sampVersion_ = readResource( "samp.version" ); } return sampVersion_; } /** * Returns a string giving the version of this software package. * * @return JSAMP version */ public static String getSoftwareVersion() { if ( softwareVersion_ == null ) { softwareVersion_ = readResource( "jsamp.version" ); } return softwareVersion_; } /** * Returns the contents of a resource as a string. * * @param rname resource name * (in the sense of {@link java.lang.Class#getResource}) */ private static String readResource( String rname ) { URL url = SampUtils.class.getResource( rname ); if ( url == null ) { logger_.warning( "No such resource " + rname ); return "??"; } else { try { InputStream in = url.openStream(); StringBuffer sbuf = new StringBuffer(); for ( int c; ( c = in.read() ) >= 0; ) { sbuf.append( (char) c ); } in.close(); return sbuf.toString().trim(); } catch ( IOException e ) { logger_.warning( "Failed to read resource " + url ); return "??"; } } } /** * Returns the system-dependent line separator sequence. * * @return line separator */ private static String getLineSeparator() { try { return System.getProperty( "line.separator", "\n" ); } catch ( SecurityException e ) { return "\n"; } } /** * Replaces all occurrences of a single character with a given replacement * string. * * @param in input string * @param oldChar character to replace * @param newText replacement string * @return modified string */ private static String replaceChar( String in, char oldChar, String newTxt ) { int len = in.length(); StringBuffer sbuf = new StringBuffer( len ); for ( int i = 0; i < len; i++ ) { char c = in.charAt( i ); if ( c == oldChar ) { sbuf.append( newTxt ); } else { sbuf.append( c ); } } return sbuf.toString(); } /** * Locates an unused server port on the local host. * Potential problem: between when this method completes and when * the return value of this method is used by its caller, it's possible * that the port will get used by somebody else. * Probably this will not happen much in practice?? * * @return unused server port */ private static int findAnyPort() throws IOException { ServerSocket socket = new ServerSocket( 0 ); try { return socket.getLocalPort(); } finally { try { socket.close(); } catch ( IOException e ) { } } } /** * Two problems with this one - it may be a bit inefficient, and * there's an annoying bug in the Apache XML-RPC WebServer class * which causes it to print "java.util.NoSuchElementException" to * the server's System.err for every port scanned by this routine * that an org.apache.xmlrpc.WebServer server is listening on. * * @param startPort port to start scanning upwards from * @param nTry number of ports in sequence to try before admitting defeat * @return unused server port */ private static int scanForPort( int startPort, int nTry ) throws IOException { for ( int iPort = startPort; iPort < startPort + nTry; iPort++ ) { try { Socket trySocket = new Socket( "localhost", iPort ); if ( ! trySocket.isClosed() ) { trySocket.shutdownOutput(); trySocket.shutdownInput(); trySocket.close(); } } catch ( ConnectException e ) { /* Can't connect - this hopefully means that the socket is * unused. */ return iPort; } } throw new IOException( "Can't locate an unused port in range " + startPort + " ... " + ( startPort + nTry ) ); } } jsamp/src/java/org/astrogrid/samp/JsonReader.java0000664000175000017500000002003112730747754021640 0ustar sladensladen/* * Copyright (C) 2011 Miami-Dade County. * * 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 * * http://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. * * Note: this file incorporates source code from 3d party entities. Such code * is copyrighted by those entities as indicated below. */ /* * This code is based on the mjson library found here: * http://www.sharegov.org/mjson/Json.java * http://sharegov.blogspot.com/2011/06/json-library.html */ package org.astrogrid.samp; import java.text.CharacterIterator; import java.text.StringCharacterIterator; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * Simple JSON parser which only copes with SAMP-friendly JSON, * that is strings, lists and objects. * This code is a stripped-down and somewhat fixed copy of the mjson * libraray written by Borislav Iordanov, from * http://www.sharegov.org/mjson/Json.java. * * @author Borislav Iordanov * @author Mark Taylor */ class JsonReader { private static final Object OBJECT_END = new Token("OBJECT_END"); private static final Object ARRAY_END = new Token("ARRAY_END"); private static final Object COLON = new Token("COLON"); private static final Object COMMA = new Token("COMMA"); public static final int FIRST = 0; public static final int CURRENT = 1; public static final int NEXT = 2; private static Map escapes = new HashMap(); static { escapes.put(new Character('"'), new Character('"')); escapes.put(new Character('\\'), new Character('\\')); escapes.put(new Character('/'), new Character('/')); escapes.put(new Character('b'), new Character('\b')); escapes.put(new Character('f'), new Character('\f')); escapes.put(new Character('n'), new Character('\n')); escapes.put(new Character('r'), new Character('\r')); escapes.put(new Character('t'), new Character('\t')); } private CharacterIterator it; private char c; private Object token; private StringBuffer buf = new StringBuffer(); private char next() { if (it.getIndex() == it.getEndIndex()) throw new DataException("Reached end of input at the " + it.getIndex() + "th character."); c = it.next(); return c; } private char previous() { c = it.previous(); return c; } private void skipWhiteSpace() { do { if (Character.isWhitespace(c)) ; else if (c == '/') { next(); if (c == '*') { // skip multiline comments while (c != CharacterIterator.DONE) if (next() == '*' && next() == '/') break; if (c == CharacterIterator.DONE) throw new DataException("Unterminated comment while parsing JSON string."); } else if (c == '/') while (c != '\n' && c != CharacterIterator.DONE) next(); else { previous(); break; } } else break; } while (next() != CharacterIterator.DONE); } public Object read(CharacterIterator ci, int start) { it = ci; switch (start) { case FIRST: c = it.first(); break; case CURRENT: c = it.current(); break; case NEXT: c = it.next(); break; } return read(); } public Object read(CharacterIterator it) { return read(it, NEXT); } public Object read(String string) { return read(new StringCharacterIterator(string), FIRST); } private Object read() { skipWhiteSpace(); char ch = c; next(); switch (ch) { case '"': token = readString(); break; case '[': token = readArray(); break; case ']': token = ARRAY_END; break; case ',': token = COMMA; break; case '{': token = readObject(); break; case '}': token = OBJECT_END; break; case ':': token = COLON; break; default: { throw new DataException( "Unexpected character '" + ch + "'" ); } } return token; } private Map readObject() { Map ret = new LinkedHashMap(); read(); while (true) { if (token == OBJECT_END) { return ret; } if (!(token instanceof String)) { throw new DataException("Missing/illegal object key"); } String key = (String) token; if (read() != COLON) { throw new DataException("Missing colon in JSON object"); } Object value = read(); ret.put(key, value); read(); if (token == COMMA) { read(); } else if (token != OBJECT_END) { throw new DataException("Unexpected token " + token); } } } private List readArray() { List ret = new ArrayList(); Object value = read(); while (token != ARRAY_END) { ret.add(value); if (read() == COMMA) value = read(); else if (token != ARRAY_END) throw new DataException("Unexpected token in array " + token); } return ret; } private String readString() { buf.setLength(0); while (c != '"') { if (c == '\\') { next(); if (c == 'u') { add(unicode()); } else { Object value = escapes.get(new Character(c)); if (value != null) { add(((Character) value).charValue()); } } } else { add(); } } next(); return buf.toString(); } private void add(char cc) { buf.append(cc); next(); } private void add() { add(c); } private char unicode() { int value = 0; for (int i = 0; i < 4; ++i) { switch (next()) { case '0': case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9': value = (value << 4) + c - '0'; break; case 'a': case 'b': case 'c': case 'd': case 'e': case 'f': value = (value << 4) + (c - 'a') + 10; break; case 'A': case 'B': case 'C': case 'D': case 'E': case 'F': value = (value << 4) + (c - 'A') + 10; break; } } return (char) value; } /** * Named object. */ private static class Token { private final String name; Token(String name) { this.name = name; } public String toString() { return this.name; } } } jsamp/src/java/org/astrogrid/samp/package.html0000664000175000017500000000007312730747754021226 0ustar sladensladen Classes common to SAMP hub and client code.

Current Version (${jsampVersion}):
Unsigned jar file:
jsamp-${jsampVersion}.jar
Signed jar file:
jsamp-${jsampVersion}_signed.jar
Other versions:
All previous releases are available from the FTP archive at ftp://andromeda.star.bristol.ac.uk/pub/star/jsamp. This may also contain pre-release versions. Older versions are also still available from their historical home on the Astrogrid Maven server.
Source code:
The source code is available on github at https://github.com/mbtaylor/jsamp/.
Dependencies:
The only (non-J2SE) external library which is sometimes used by JSAMP is Apache XML-RPC: xmlrpc-1.2-b1.jar. Note however that this library is not necessary for using JSAMP, it is only required if you want to use the Apache XML-RPC implementation. If you want to use the internal XML-RPC implementation instead, which is generally recommended, then no extenal libraries are required.